@celilo/cli 2.3.0 → 3.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/CELILO_CORE_MODULES.md +1 -1
- package/package.json +3 -3
- package/src/api/serve.ts +13 -1
- package/src/cli/commands/module-health.test.ts +35 -0
- package/src/cli/commands/module-health.ts +11 -3
- package/src/cli/commands/module-publish.test.ts +22 -0
- package/src/cli/commands/module-publish.ts +14 -12
- package/src/cli/commands/module-update.ts +69 -17
- package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
- package/src/cli/commands/module-upgrade.ts +43 -3
- package/src/cli/commands/publish/helpers.ts +4 -3
- package/src/cli/commands/publish/index.ts +13 -1
- package/src/cli/commands/publish/plan.test.ts +64 -0
- package/src/cli/commands/publish/plan.ts +52 -19
- package/src/cli/commands/publish/types.ts +16 -3
- package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
- package/src/cli/commands/subscribers-install-daemon.ts +107 -0
- package/src/cli/commands/subscribers-serve.test.ts +22 -0
- package/src/cli/commands/subscribers-serve.ts +22 -4
- package/src/cli/commands/system-audit.ts +14 -5
- package/src/cli/commands/system-update.ts +11 -6
- package/src/cli/completion.ts +10 -1
- package/src/cli/fuel-gauge.ts +12 -4
- package/src/cli/index.ts +12 -0
- package/src/cli/json-output.test.ts +81 -0
- package/src/cli/types.ts +9 -0
- package/src/hooks/broker.test.ts +9 -3
- package/src/hooks/executor.test.ts +5 -2
- package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
- package/src/hooks/hook-jail-unreachability.test.ts +10 -2
- package/src/hooks/hook-trespass.test.ts +13 -4
- package/src/hooks/run-named-hook.ts +19 -16
- package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
- package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
- package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
- package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
- package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
- package/src/hooks/test-fixtures/silent-hook.ts +0 -1
- package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
- package/src/hooks/test-fixtures/success-hook.ts +4 -4
- package/src/manifest/contracts/v1.ts +19 -14
- package/src/manifest/json-schema-roundtrip.test.ts +1 -1
- package/src/manifest/schema.ts +66 -16
- package/src/manifest/validate.test.ts +47 -0
- package/src/policy/capability-shape-baseline.ts +63 -21
- package/src/policy/capability-shape-drift.test.ts +53 -1
- package/src/policy/capability-shape.test.ts +105 -0
- package/src/policy/capability-shape.ts +283 -2
- package/src/registry/client.test.ts +65 -0
- package/src/registry/client.ts +7 -7
- package/src/secrets/storage.test.ts +69 -3
- package/src/secrets/storage.ts +71 -1
- package/src/services/audit/health.test.ts +58 -0
- package/src/services/audit/health.ts +15 -3
- package/src/services/audit/index.test.ts +1 -1
- package/src/services/audit/interface-classification.test.ts +16 -5
- package/src/services/audit/interface-classification.ts +25 -2
- package/src/services/audit/module-versions.ts +5 -1
- package/src/services/audit/public-dns.test.ts +20 -0
- package/src/services/audit/public-dns.ts +7 -2
- package/src/services/audit/recurrence-gate.test.ts +225 -0
- package/src/services/audit/trusted-sources.test.ts +17 -0
- package/src/services/audit/trusted-sources.ts +21 -0
- package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +102 -150
- package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
- package/src/services/build-bus/hook-dispatch.test.ts +86 -116
- package/src/services/build-bus/hook-dispatch.ts +99 -121
- package/src/services/build-bus/hook-dispatcher.ts +106 -28
- package/src/services/build-bus/receiver-daemon.test.ts +189 -0
- package/src/services/build-bus/receiver-daemon.ts +355 -0
- package/src/services/build-bus/self-update.ts +156 -0
- package/src/services/capability-compat.test.ts +90 -0
- package/src/services/capability-compat.ts +128 -0
- package/src/services/deploy-terraform.ts +38 -1
- package/src/services/events-daemon.test.ts +57 -0
- package/src/services/events-daemon.ts +76 -0
- package/src/services/firewall-reach.ts +21 -8
- package/src/services/fleet-checks.test.ts +37 -1
- package/src/services/fleet-checks.ts +31 -1
- package/src/services/health-runner.ts +43 -5
- package/src/services/module-deploy-prune.test.ts +89 -0
- package/src/services/module-deploy.ts +72 -125
- package/src/services/module-types-drift.test.ts +1 -1
- package/src/services/module-validator/capability-versions.test.ts +13 -2
- package/src/services/terraform-safety.test.ts +83 -0
- package/src/services/terraform-safety.ts +53 -0
- package/src/services/update/orchestrator.test.ts +1 -1
- package/tsconfig.json +2 -13
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* have failed anything. See celilo#703.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
+
import { Database } from 'bun:sqlite';
|
|
30
31
|
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
31
32
|
import { spawnSync } from 'node:child_process';
|
|
32
33
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
@@ -49,6 +50,7 @@ const JSON_COMMANDS = [
|
|
|
49
50
|
'module list --json',
|
|
50
51
|
'module health --json',
|
|
51
52
|
'system audit --json',
|
|
53
|
+
'system update --dry-run --json',
|
|
52
54
|
'alerts list --json',
|
|
53
55
|
'commands --json',
|
|
54
56
|
] as const;
|
|
@@ -90,6 +92,85 @@ describe('celilo#698 — JSON commands emit parseable JSON', () => {
|
|
|
90
92
|
}
|
|
91
93
|
});
|
|
92
94
|
|
|
95
|
+
/**
|
|
96
|
+
* celilo#1362 — a --json command must stay parseable while its health checks
|
|
97
|
+
* run. The celilo#698 gates above all fire against an EMPTY fleet, where no
|
|
98
|
+
* health check ever executes and the pollution path never wakes up: the
|
|
99
|
+
* gauge silently animated frames onto stdout (plus plain `[module:hook]`
|
|
100
|
+
* logger lines) for every check, and `system audit --json` redirected to a
|
|
101
|
+
* file carried ~1 MB of `Testing app` frames before the first `{`.
|
|
102
|
+
*
|
|
103
|
+
* This gate seeds a module with a logging, sleeping health_check hook
|
|
104
|
+
* (`test-fixtures/modules/gauge-pollution-test`) so the checks genuinely
|
|
105
|
+
* run, then parses each fleet-wide --json command's stdout verbatim.
|
|
106
|
+
*/
|
|
107
|
+
describe('celilo#1362 — --json output stays parseable while health checks run', () => {
|
|
108
|
+
let ctx: IntegrationTestContext;
|
|
109
|
+
const FIXTURE_PATH = join(import.meta.dir, '../../test-fixtures/modules/gauge-pollution-test');
|
|
110
|
+
const MODULE_ID = 'gauge-pollution-test';
|
|
111
|
+
const SPAWN_ENV = () => ({
|
|
112
|
+
...process.env,
|
|
113
|
+
CELILO_DB_PATH: ctx.dbPath,
|
|
114
|
+
CELILO_DATA_DIR: ctx.dataDir,
|
|
115
|
+
CELILO_SUPPRESS_DEPRECATION: '1',
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
beforeAll(async () => {
|
|
119
|
+
ctx = await setupIntegrationTest();
|
|
120
|
+
|
|
121
|
+
const imported = spawnSync(
|
|
122
|
+
'bun',
|
|
123
|
+
['run', 'src/cli/index.ts', 'module', 'import', FIXTURE_PATH],
|
|
124
|
+
{ encoding: 'utf8', env: SPAWN_ENV(), stdio: ['ignore', 'pipe', 'pipe'], timeout: 120_000 },
|
|
125
|
+
);
|
|
126
|
+
expect(
|
|
127
|
+
imported.status,
|
|
128
|
+
`module import exited ${imported.status}\nstdout: ${imported.stdout}\nstderr: ${imported.stderr}`,
|
|
129
|
+
).toBe(0);
|
|
130
|
+
|
|
131
|
+
// runAllHealthChecks only checks INSTALLED/VERIFIED modules. Seeded
|
|
132
|
+
// the same way capability-abi-mismatch.test.ts seeds providers: the
|
|
133
|
+
// lifecycle under test is output purity, not the deploy flow.
|
|
134
|
+
const db = new Database(ctx.dbPath);
|
|
135
|
+
try {
|
|
136
|
+
db.run(`UPDATE modules SET state = 'VERIFIED' WHERE id = '${MODULE_ID}'`);
|
|
137
|
+
} finally {
|
|
138
|
+
db.close();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
afterAll(async () => {
|
|
143
|
+
await ctx.cleanup();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
for (const command of [
|
|
147
|
+
'system audit --json',
|
|
148
|
+
'system update --dry-run --json',
|
|
149
|
+
'module health --json',
|
|
150
|
+
]) {
|
|
151
|
+
test(`celilo ${command} emits parseable JSON with a health-checked module deployed`, () => {
|
|
152
|
+
const result = spawnSync('bun', ['run', 'src/cli/index.ts', ...command.split(' ')], {
|
|
153
|
+
encoding: 'utf8',
|
|
154
|
+
env: SPAWN_ENV(),
|
|
155
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
156
|
+
timeout: 120_000,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(
|
|
160
|
+
result.status,
|
|
161
|
+
`celilo ${command} exited ${result.status}\nstderr: ${result.stderr}`,
|
|
162
|
+
).toBe(0);
|
|
163
|
+
|
|
164
|
+
// Verbatim JSON.parse — the gauge frames and plain logger lines both
|
|
165
|
+
// land here pre-fix, and either one alone breaks the parse.
|
|
166
|
+
expect(() => JSON.parse(result.stdout)).not.toThrow();
|
|
167
|
+
|
|
168
|
+
// And the fixture's own log lines must not ride along on stdout.
|
|
169
|
+
expect(result.stdout).not.toContain('gauge-pollution step');
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
93
174
|
/**
|
|
94
175
|
* Walk from the index of a `{` to the index just past its matching `}`,
|
|
95
176
|
* skipping over string literals so a brace inside a string doesn't unbalance
|
package/src/cli/types.ts
CHANGED
|
@@ -35,6 +35,15 @@ export interface CommandError {
|
|
|
35
35
|
success: false;
|
|
36
36
|
error: string;
|
|
37
37
|
details?: unknown;
|
|
38
|
+
/**
|
|
39
|
+
* Marks a refusal that is not a failure: the action was skipped BEFORE any
|
|
40
|
+
* state changed and will be retried when its precondition holds (the
|
|
41
|
+
* registry poll defers a consumer upgrade whose capability requirements the
|
|
42
|
+
* deployed providers cannot serve — celilo#1361). Callers that read only
|
|
43
|
+
* `success` still see a non-deploy; the poll uses this to report deferrals
|
|
44
|
+
* separately from failures instead of counting them as red.
|
|
45
|
+
*/
|
|
46
|
+
deferred?: boolean;
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
export type CommandResult = CommandSuccess | CommandError;
|
package/src/hooks/broker.test.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { describe, expect, test } from 'bun:test';
|
|
14
14
|
import { execSync } from 'node:child_process';
|
|
15
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
15
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
16
16
|
import { tmpdir } from 'node:os';
|
|
17
17
|
import { join } from 'node:path';
|
|
18
18
|
import { MissingProviderInputError } from '@celilo/capabilities';
|
|
@@ -60,10 +60,13 @@ async function runCapabilityHook(): Promise<Record<string, unknown>> {
|
|
|
60
60
|
stateDir: dir,
|
|
61
61
|
capabilities: demoCapabilities(),
|
|
62
62
|
};
|
|
63
|
-
|
|
63
|
+
await executeHookScript(join(FIXTURES, 'capability-calling-hook.ts'), context, {
|
|
64
64
|
timeoutMs: 30_000,
|
|
65
65
|
idleTimeoutMs: 30_000,
|
|
66
66
|
});
|
|
67
|
+
// The fixture hands its report through the state directory: hook return
|
|
68
|
+
// values are no longer carried anywhere (hook-owned-state D5).
|
|
69
|
+
return JSON.parse(readFileSync(join(dir, 'report.json'), 'utf-8')) as Record<string, unknown>;
|
|
67
70
|
} finally {
|
|
68
71
|
rmSync(dir, { recursive: true, force: true });
|
|
69
72
|
}
|
|
@@ -137,11 +140,14 @@ async function runStoreHook(
|
|
|
137
140
|
capabilities: {},
|
|
138
141
|
...contextData,
|
|
139
142
|
} as unknown as HookContext;
|
|
140
|
-
|
|
143
|
+
await executeHookScript(join(FIXTURES, 'store-writing-hook.ts'), context, {
|
|
141
144
|
timeoutMs: 30_000,
|
|
142
145
|
idleTimeoutMs: 30_000,
|
|
143
146
|
hookStores: () => Promise.resolve(memoryStores()),
|
|
144
147
|
});
|
|
148
|
+
// The fixture hands its report through the state directory: hook return
|
|
149
|
+
// values are no longer carried anywhere (hook-owned-state D5).
|
|
150
|
+
return JSON.parse(readFileSync(join(dir, 'report.json'), 'utf-8')) as Record<string, unknown>;
|
|
145
151
|
} finally {
|
|
146
152
|
rmSync(dir, { recursive: true, force: true });
|
|
147
153
|
}
|
|
@@ -131,7 +131,8 @@ describe('Hook Executor', () => {
|
|
|
131
131
|
vps_ip: '10.0.0.1',
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
-
|
|
134
|
+
// Void hook: no outputs. The envelope carries an empty record.
|
|
135
|
+
expect(result).toEqual({});
|
|
135
136
|
expect(messages).toContainEqual({ level: 'info', message: 'Starting test hook' });
|
|
136
137
|
expect(messages).toContainEqual({ level: 'success', message: 'Test hook completed' });
|
|
137
138
|
});
|
|
@@ -622,7 +623,9 @@ describe('Hook Executor', () => {
|
|
|
622
623
|
);
|
|
623
624
|
|
|
624
625
|
expect(result.success).toBe(true);
|
|
625
|
-
|
|
626
|
+
// Hook return values are no longer consumed (hook-owned-state D5): a
|
|
627
|
+
// void hook's outputs are empty, and nothing persists them.
|
|
628
|
+
expect(result.outputs).toEqual({});
|
|
626
629
|
expect(result.duration).toBeGreaterThanOrEqual(0);
|
|
627
630
|
expect(messages.some((m) => m.message.includes('completed successfully'))).toBe(true);
|
|
628
631
|
});
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
import { describe, expect, test } from 'bun:test';
|
|
34
|
-
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
34
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
35
35
|
import { tmpdir } from 'node:os';
|
|
36
36
|
import { dirname, join, resolve } from 'node:path';
|
|
37
37
|
import { executeHookScript } from './executor';
|
|
@@ -132,7 +132,7 @@ async function measureReach(): Promise<{ reach: Reach; cleanup: () => void }> {
|
|
|
132
132
|
const savedPolicy = process.env.CELILO_HOOK_JAIL;
|
|
133
133
|
process.env.CELILO_HOOK_JAIL = 'required';
|
|
134
134
|
try {
|
|
135
|
-
|
|
135
|
+
await executeHookScript(PROBE_HOOK, context, {
|
|
136
136
|
timeoutMs: 60_000,
|
|
137
137
|
idleTimeoutMs: 60_000,
|
|
138
138
|
jail: {
|
|
@@ -145,7 +145,12 @@ async function measureReach(): Promise<{ reach: Reach; cleanup: () => void }> {
|
|
|
145
145
|
? [{ name: 'browser_executable', value: probeBrowserDir(), access: 'read' as const }]
|
|
146
146
|
: [],
|
|
147
147
|
},
|
|
148
|
-
})
|
|
148
|
+
});
|
|
149
|
+
// The fixture hands its report through the state directory: hook return
|
|
150
|
+
// values are no longer carried anywhere (hook-owned-state D5).
|
|
151
|
+
const reach = JSON.parse(
|
|
152
|
+
readFileSync(join(stateDir, 'report.json'), 'utf-8'),
|
|
153
|
+
) as unknown as Reach;
|
|
149
154
|
|
|
150
155
|
return { reach, cleanup: restorePolicy };
|
|
151
156
|
} catch (error) {
|
|
@@ -69,6 +69,9 @@ interface Rig {
|
|
|
69
69
|
cleanup: () => void;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/** Where the probe fixture hands its report to the parent (hook-owned-state D5). */
|
|
73
|
+
const REPORT_PATH = 'report.json';
|
|
74
|
+
|
|
72
75
|
/**
|
|
73
76
|
* Lay out a module store the way celilo does, run the probe hook against it,
|
|
74
77
|
* and hand back both the hook's report and the paths so the parent can check
|
|
@@ -134,14 +137,19 @@ async function runProbe(): Promise<Rig> {
|
|
|
134
137
|
const savedPolicy = process.env.CELILO_HOOK_JAIL;
|
|
135
138
|
process.env.CELILO_HOOK_JAIL = 'required';
|
|
136
139
|
try {
|
|
137
|
-
|
|
140
|
+
await executeHookScript(PROBE_HOOK, context, {
|
|
138
141
|
timeoutMs: 60_000,
|
|
139
142
|
idleTimeoutMs: 60_000,
|
|
140
143
|
jail: {
|
|
141
144
|
modulePath,
|
|
142
145
|
pathInputs: [{ name: 'staged_input', value: stagedInput, access: 'write' }],
|
|
143
146
|
},
|
|
144
|
-
})
|
|
147
|
+
});
|
|
148
|
+
// The fixture hands its report through the state directory: hook return
|
|
149
|
+
// values are no longer carried anywhere (hook-owned-state D5).
|
|
150
|
+
const outputs = JSON.parse(
|
|
151
|
+
readFileSync(join(stateDir, REPORT_PATH), 'utf-8'),
|
|
152
|
+
) as unknown as Rig['outputs'];
|
|
145
153
|
return {
|
|
146
154
|
outputs,
|
|
147
155
|
stagedInput,
|
|
@@ -155,14 +155,16 @@ afterAll(() => {
|
|
|
155
155
|
|
|
156
156
|
async function runTrespass(jail = false): Promise<{ report: TrespassReport; lines: string[] }> {
|
|
157
157
|
const { logger, messages } = createCapturingLogger();
|
|
158
|
+
const screenshotDir = mkdtempSync(join(tmpdir(), 'celilo-trespass-artifacts-'));
|
|
159
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'celilo-trespass-artifacts-'));
|
|
158
160
|
const context: HookContext = {
|
|
159
161
|
config: configStore({ sibling_module_id: 'hello-foo', other_system_ip: '' }),
|
|
160
162
|
secrets: secretStore(),
|
|
161
163
|
systems: [],
|
|
162
164
|
logger,
|
|
163
165
|
debug: false,
|
|
164
|
-
screenshotDir
|
|
165
|
-
stateDir
|
|
166
|
+
screenshotDir,
|
|
167
|
+
stateDir,
|
|
166
168
|
capabilities: {},
|
|
167
169
|
};
|
|
168
170
|
|
|
@@ -173,7 +175,7 @@ async function runTrespass(jail = false): Promise<{ report: TrespassReport; line
|
|
|
173
175
|
const savedPolicy = process.env.CELILO_HOOK_JAIL;
|
|
174
176
|
if (jail) process.env.CELILO_HOOK_JAIL = 'required';
|
|
175
177
|
try {
|
|
176
|
-
|
|
178
|
+
await executeHookScript(TRESPASS_SCRIPT, context, {
|
|
177
179
|
timeoutMs: 60_000,
|
|
178
180
|
idleTimeoutMs: 60_000,
|
|
179
181
|
// The module's own tree, two levels above the hook script. Absent means
|
|
@@ -181,12 +183,19 @@ async function runTrespass(jail = false): Promise<{ report: TrespassReport; line
|
|
|
181
183
|
...(jail ? { jail: { modulePath: dirname(dirname(TRESPASS_SCRIPT)), pathInputs: [] } } : {}),
|
|
182
184
|
});
|
|
183
185
|
return {
|
|
184
|
-
report
|
|
186
|
+
// The fixture hands its report through the state directory the jail
|
|
187
|
+
// binds read-write: the same channel works jailed and unjailed (D5
|
|
188
|
+
// removed the return channel).
|
|
189
|
+
report: JSON.parse(
|
|
190
|
+
readFileSync(join(stateDir, 'report.json'), 'utf-8'),
|
|
191
|
+
) as unknown as TrespassReport,
|
|
185
192
|
lines: messages.map((m) => m.message),
|
|
186
193
|
};
|
|
187
194
|
} finally {
|
|
188
195
|
if (savedPolicy === undefined) delete process.env.CELILO_HOOK_JAIL;
|
|
189
196
|
else process.env.CELILO_HOOK_JAIL = savedPolicy;
|
|
197
|
+
rmSync(screenshotDir, { recursive: true, force: true });
|
|
198
|
+
rmSync(stateDir, { recursive: true, force: true });
|
|
190
199
|
}
|
|
191
200
|
}
|
|
192
201
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { HookDefinition } from '@celilo/capabilities';
|
|
1
2
|
/**
|
|
2
3
|
* Helper to load + invoke a single named hook on a module.
|
|
3
4
|
*
|
|
@@ -41,6 +42,16 @@ export interface RunNamedHookOptions {
|
|
|
41
42
|
* hook's own `timeout` or the executor default applies.
|
|
42
43
|
*/
|
|
43
44
|
timeoutMs?: number;
|
|
45
|
+
/**
|
|
46
|
+
* Use this hook definition instead of the manifest's own entry for the
|
|
47
|
+
* name. Only `on_upstream_publish` needs it: its manifest value is an
|
|
48
|
+
* ARRAY of match-rule entries and the build-bus dispatcher has already
|
|
49
|
+
* chosen the matching one, so the definition it passes is the matched
|
|
50
|
+
* entry (module-orchestrator-primitives slice 7). Every other hook keeps
|
|
51
|
+
* reading the manifest, and `celilo module run-hook` — which has no
|
|
52
|
+
* matched entry to supply — still gets `notDefined` for the array form.
|
|
53
|
+
*/
|
|
54
|
+
hookDefinition?: HookDefinition;
|
|
44
55
|
}
|
|
45
56
|
|
|
46
57
|
export interface RunNamedHookResult extends HookResult {
|
|
@@ -113,22 +124,14 @@ export async function runNamedHook(
|
|
|
113
124
|
}
|
|
114
125
|
|
|
115
126
|
const manifest = module.manifestData as ModuleManifest;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
// Defensive narrow: `on_upstream_publish` is also under
|
|
126
|
-
// `manifest.hooks` (build-bus, Phase 4) but is an ARRAY of
|
|
127
|
-
// match-rule hooks dispatched by the receiver daemon — NOT
|
|
128
|
-
// runnable via `celilo module run-hook`. The HookName type
|
|
129
|
-
// excludes it, but the indexed-access return type can't prove
|
|
130
|
-
// that, so narrow at the value level.
|
|
131
|
-
if (Array.isArray(hookDef)) {
|
|
127
|
+
// `on_upstream_publish` is under `manifest.hooks` as an ARRAY of
|
|
128
|
+
// match-rule entries; the dispatcher supplies the matched entry via
|
|
129
|
+
// `options.hookDefinition`. Without one (e.g. `celilo module run-hook`)
|
|
130
|
+
// there is no single definition to run, so the hook reports notDefined
|
|
131
|
+
// rather than guessing an entry.
|
|
132
|
+
const hookDef =
|
|
133
|
+
options.hookDefinition ?? manifest.hooks?.[hookName as keyof typeof manifest.hooks];
|
|
134
|
+
if (!hookDef || Array.isArray(hookDef)) {
|
|
132
135
|
return {
|
|
133
136
|
success: true,
|
|
134
137
|
outputs: {},
|
|
@@ -20,6 +20,5 @@ export default defineHook({
|
|
|
20
20
|
writeFileSync(join(dir, 'spa-failure.png'), 'not-really-a-png');
|
|
21
21
|
writeFileSync(join(dir, 'spa-failure.html'), '<html></html>');
|
|
22
22
|
writeFileSync(join(dir, 'spa-failure.requests.txt'), 'GET /api/getActiveMonth 200');
|
|
23
|
-
return { api_key: 'wrote-artifacts' };
|
|
24
23
|
},
|
|
25
24
|
});
|
|
@@ -5,8 +5,13 @@
|
|
|
5
5
|
* One test per SHAPE rather than per method: the broker is generic, so a
|
|
6
6
|
* per-method suite would prove the same thing thirty-seven times and drift the
|
|
7
7
|
* moment a capability gained a method.
|
|
8
|
+
*
|
|
9
|
+
* Reports through a JSON file in its state directory: hook return values are
|
|
10
|
+
* no longer carried anywhere (hook-owned-state D5).
|
|
8
11
|
*/
|
|
9
12
|
|
|
13
|
+
import { writeFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
10
15
|
import { defineHook, isMissingProviderInputError } from '@celilo/capabilities';
|
|
11
16
|
|
|
12
17
|
interface DemoCapability {
|
|
@@ -27,25 +32,25 @@ export default defineHook({
|
|
|
27
32
|
// `demo` is a fixture capability, not a registry entry, so the typed
|
|
28
33
|
// capability map does not know it. The broker is generic and does not care.
|
|
29
34
|
const demo = (ctx.capabilities as unknown as Record<string, DemoCapability>).demo;
|
|
30
|
-
const
|
|
35
|
+
const report: Record<string, unknown> = {};
|
|
31
36
|
|
|
32
37
|
// Non-function properties are copied verbatim, which is what keeps
|
|
33
38
|
// `providerModuleId` readable — a hook names the provider in its errors.
|
|
34
|
-
|
|
35
|
-
|
|
39
|
+
report.providerModuleId = demo.providerModuleId;
|
|
40
|
+
report.version = demo.version;
|
|
36
41
|
|
|
37
42
|
// An optional method the provider did not implement must be ABSENT, not a
|
|
38
43
|
// proxy that throws, or `if (cap.registerTrustedSource)` answers wrongly.
|
|
39
|
-
|
|
44
|
+
report.optionalMethodAbsent = demo.sometimesAbsent === undefined;
|
|
40
45
|
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
report.returned = await demo.echo({ x: 1, nested: { y: [2, 3] } });
|
|
47
|
+
report.undefinedBecomesNull = await demo.returnsNothing({});
|
|
43
48
|
|
|
44
49
|
try {
|
|
45
50
|
await demo.boom({});
|
|
46
|
-
|
|
51
|
+
report.plainThrow = 'did not throw';
|
|
47
52
|
} catch (error) {
|
|
48
|
-
|
|
53
|
+
report.plainThrow = {
|
|
49
54
|
isError: error instanceof Error,
|
|
50
55
|
name: (error as Error).name,
|
|
51
56
|
message: (error as Error).message,
|
|
@@ -55,10 +60,10 @@ export default defineHook({
|
|
|
55
60
|
|
|
56
61
|
try {
|
|
57
62
|
await demo.missingInput({});
|
|
58
|
-
|
|
63
|
+
report.missingProviderInput = 'did not throw';
|
|
59
64
|
} catch (error) {
|
|
60
65
|
const e = error as Record<string, unknown>;
|
|
61
|
-
|
|
66
|
+
report.missingProviderInput = {
|
|
62
67
|
recognised: isMissingProviderInputError(error),
|
|
63
68
|
providerModuleId: e.providerModuleId,
|
|
64
69
|
ensureId: e.ensureId,
|
|
@@ -69,11 +74,13 @@ export default defineHook({
|
|
|
69
74
|
|
|
70
75
|
try {
|
|
71
76
|
await (demo as unknown as { nope(): Promise<unknown> }).nope();
|
|
72
|
-
|
|
77
|
+
report.unknownMethod = 'did not throw';
|
|
73
78
|
} catch (error) {
|
|
74
|
-
|
|
79
|
+
report.unknownMethod = (error as Error).message;
|
|
75
80
|
}
|
|
76
81
|
|
|
77
|
-
|
|
82
|
+
// Hand the report to the caller through the state directory, the one
|
|
83
|
+
// channel a hook still has.
|
|
84
|
+
writeFileSync(join(ctx.stateDir as string, 'report.json'), JSON.stringify(report));
|
|
78
85
|
},
|
|
79
86
|
});
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
* bubblewrap removes the path and gives `ENOENT`; `sandbox-exec` denies it and
|
|
7
7
|
* gives `EPERM`. Both satisfy the requirement, so this fixture records only
|
|
8
8
|
* whether the access worked and hands the message back for the post-mortem.
|
|
9
|
+
*
|
|
10
|
+
* The report is written to a JSON file in the state directory: hook return
|
|
11
|
+
* values are no longer carried anywhere (hook-owned-state D5), and `state/`
|
|
12
|
+
* is the one writable path inside the jail.
|
|
9
13
|
*/
|
|
10
14
|
|
|
11
15
|
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
@@ -32,7 +36,7 @@ export default defineHook({
|
|
|
32
36
|
staged_input: string;
|
|
33
37
|
};
|
|
34
38
|
|
|
35
|
-
|
|
39
|
+
const report = {
|
|
36
40
|
// Outside the module tree entirely, and the whole acceptance criterion:
|
|
37
41
|
// celilo's data directory is not bound, so the key is not merely denied
|
|
38
42
|
// but absent.
|
|
@@ -68,5 +72,9 @@ export default defineHook({
|
|
|
68
72
|
for (const name of names) readFileSync(join(sshDir, name));
|
|
69
73
|
}),
|
|
70
74
|
};
|
|
75
|
+
|
|
76
|
+
// Hand the report to the parent through the state directory — the one
|
|
77
|
+
// channel a jailed hook still has (D5 removed the return channel).
|
|
78
|
+
writeFileSync(`${ctx.stateDir}/report.json`, JSON.stringify(report));
|
|
71
79
|
},
|
|
72
80
|
});
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { execFileSync, execSync } from 'node:child_process';
|
|
24
|
-
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
24
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
25
25
|
import { dirname } from 'node:path';
|
|
26
26
|
import { BROWSER_ROOT, defineHook } from '@celilo/capabilities';
|
|
27
27
|
|
|
@@ -81,7 +81,7 @@ export default defineHook({
|
|
|
81
81
|
// run that supplied one — a false ABSENT indistinguishable from a real one.
|
|
82
82
|
const browser = (ctx.config as { browser_executable?: string }).browser_executable ?? '';
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
const reach = {
|
|
85
85
|
// The one every other shell-out depends on. `execSync` spawns
|
|
86
86
|
// `/bin/sh -c`, so a jail without it cannot run ANY hook that uses
|
|
87
87
|
// `node:child_process`, whatever binary that hook was reaching for.
|
|
@@ -223,5 +223,10 @@ export default defineHook({
|
|
|
223
223
|
throw new Error('no bwrap on any known path');
|
|
224
224
|
}),
|
|
225
225
|
};
|
|
226
|
+
|
|
227
|
+
// Hand the report to the parent through the state directory — the one
|
|
228
|
+
// channel a jailed hook still has (hook-owned-state D5 removed the
|
|
229
|
+
// return channel).
|
|
230
|
+
writeFileSync(`${ctx.stateDir}/report.json`, JSON.stringify(reach));
|
|
226
231
|
},
|
|
227
232
|
});
|
|
@@ -3,25 +3,32 @@
|
|
|
3
3
|
* the process boundary — set/get round trip on both stores, delete, a
|
|
4
4
|
* committed transaction, a discarded transaction, and the undeclared-name
|
|
5
5
|
* throw the whole design exists to produce.
|
|
6
|
+
*
|
|
7
|
+
* Reports through a JSON file in its state directory: hook return values
|
|
8
|
+
* are no longer carried anywhere (hook-owned-state D5), and the state
|
|
9
|
+
* directory is the sanctioned place a hook hands structured data to its
|
|
10
|
+
* caller.
|
|
6
11
|
*/
|
|
7
12
|
|
|
13
|
+
import { writeFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
8
15
|
import { defineHook } from '@celilo/capabilities';
|
|
9
16
|
|
|
10
17
|
export default defineHook({
|
|
11
18
|
hook: 'on_install',
|
|
12
19
|
requires: [],
|
|
13
20
|
handler: async (ctx) => {
|
|
14
|
-
const
|
|
21
|
+
const report: Record<string, unknown> = {};
|
|
15
22
|
|
|
16
23
|
// Round trip on each store.
|
|
17
24
|
await ctx.secrets.set('bot_token', 'token-value');
|
|
18
|
-
|
|
25
|
+
report.secretRoundTrip = await ctx.secrets.get('bot_token');
|
|
19
26
|
|
|
20
27
|
await ctx.config.set('public_ip', '203.0.113.7');
|
|
21
|
-
|
|
28
|
+
report.configRoundTrip = await ctx.config.get('public_ip');
|
|
22
29
|
|
|
23
30
|
// The map surface still answers from the values the context carried.
|
|
24
|
-
|
|
31
|
+
report.mapRead = ctx.config.mapOnlyValue;
|
|
25
32
|
|
|
26
33
|
// Delete removes; deleting a declared-but-never-written name is a no-op.
|
|
27
34
|
// (An UNDECLARED name on delete throws — that is 3.4, tested below.
|
|
@@ -29,15 +36,15 @@ export default defineHook({
|
|
|
29
36
|
await ctx.secrets.delete('bot_token');
|
|
30
37
|
await ctx.secrets.set('api_key', 'to-delete');
|
|
31
38
|
await ctx.secrets.delete('api_key');
|
|
32
|
-
|
|
39
|
+
report.deletedIsGone = await ctx.secrets.get('api_key');
|
|
33
40
|
|
|
34
41
|
// Committed transaction: both writes land.
|
|
35
42
|
await ctx.secrets.transaction((s) => {
|
|
36
43
|
s.set('bot_token', 'committed-a');
|
|
37
44
|
s.set('api_key', 'committed-b');
|
|
38
45
|
});
|
|
39
|
-
|
|
40
|
-
|
|
46
|
+
report.transactionA = await ctx.secrets.get('bot_token');
|
|
47
|
+
report.transactionB = await ctx.secrets.get('api_key');
|
|
41
48
|
|
|
42
49
|
// Discarded transaction: nothing lands, including the overwrite of the
|
|
43
50
|
// committed value above.
|
|
@@ -47,17 +54,19 @@ export default defineHook({
|
|
|
47
54
|
throw new Error('hook failed midway');
|
|
48
55
|
});
|
|
49
56
|
} catch (error) {
|
|
50
|
-
|
|
57
|
+
report.discarded = (error as Error).message;
|
|
51
58
|
}
|
|
52
|
-
|
|
59
|
+
report.afterDiscard = await ctx.secrets.get('bot_token');
|
|
53
60
|
|
|
54
61
|
// An undeclared name throws, naming the module and the declared set.
|
|
55
62
|
try {
|
|
56
63
|
await ctx.secrets.set('not_declared', 'x');
|
|
57
64
|
} catch (error) {
|
|
58
|
-
|
|
65
|
+
report.undeclaredSecret = (error as Error).message;
|
|
59
66
|
}
|
|
60
67
|
|
|
61
|
-
|
|
68
|
+
// Hand the report to the caller through the state directory, the one
|
|
69
|
+
// channel a hook still has.
|
|
70
|
+
writeFileSync(join(ctx.stateDir as string, 'report.json'), JSON.stringify(report));
|
|
62
71
|
},
|
|
63
72
|
});
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Test fixture: successful hook script. Migrated to defineHook in
|
|
3
3
|
* HOOK_API_V2 Phase 8 — the executor now requires the brand.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Declares `hook: 'container_created'` and returns void: hook return
|
|
6
|
+
* values are no longer persisted or consumed (hook-owned-state D5), so a
|
|
7
|
+
* hook reports through the logger and, where a test needs structured data
|
|
8
|
+
* back, through a file written to its state directory.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { defineHook } from '@celilo/capabilities';
|
|
@@ -15,6 +16,5 @@ export default defineHook({
|
|
|
15
16
|
handler: async (ctx) => {
|
|
16
17
|
ctx.logger.info('Starting test hook');
|
|
17
18
|
ctx.logger.success('Test hook completed');
|
|
18
|
-
return { api_key: `test-key-for-${(ctx.vps_ip as string | undefined) ?? 'unknown'}` };
|
|
19
19
|
},
|
|
20
20
|
});
|