@celilo/cli 1.9.0 → 1.10.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/package.json +2 -2
- package/src/hooks/broker.test.ts +1 -0
- package/src/hooks/define-hook.test.ts +1 -0
- package/src/hooks/executor.test.ts +7 -0
- package/src/hooks/executor.ts +11 -0
- package/src/hooks/hook-state-dir.test.ts +109 -0
- package/src/hooks/hook-timeout.test.ts +1 -0
- package/src/hooks/hook-trespass.test.ts +1 -0
- package/src/hooks/mount-set.test.ts +148 -0
- package/src/hooks/mount-set.ts +234 -0
- package/src/manifest/contracts/v1.ts +45 -6
- package/src/manifest/schema.ts +1 -0
- package/src/module/packaging/module-state-directory.test.ts +9 -3
- package/src/policy/module-dep-reachability.test.ts +167 -0
- package/src/test-utils/module-fixtures.ts +147 -35
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@aws-sdk/client-s3": "^3.1109.0",
|
|
60
60
|
"@aws-sdk/lib-storage": "^3.1101.0",
|
|
61
|
-
"@celilo/capabilities": "^3.
|
|
61
|
+
"@celilo/capabilities": "^3.1.0",
|
|
62
62
|
"@celilo/cli-display": "^0.2.0",
|
|
63
63
|
"@celilo/core": "^0.9.1",
|
|
64
64
|
"@celilo/event-bus": "^0.6.0",
|
package/src/hooks/broker.test.ts
CHANGED
|
@@ -118,6 +118,7 @@ describe('Hook Executor', () => {
|
|
|
118
118
|
logger,
|
|
119
119
|
debug: false,
|
|
120
120
|
screenshotDir: '/tmp',
|
|
121
|
+
stateDir: '/tmp',
|
|
121
122
|
capabilities: {},
|
|
122
123
|
vps_ip: '10.0.0.1',
|
|
123
124
|
});
|
|
@@ -138,6 +139,7 @@ describe('Hook Executor', () => {
|
|
|
138
139
|
logger,
|
|
139
140
|
debug: false,
|
|
140
141
|
screenshotDir: '/tmp',
|
|
142
|
+
stateDir: '/tmp',
|
|
141
143
|
capabilities: {},
|
|
142
144
|
}),
|
|
143
145
|
).rejects.toThrow('Hook script not found');
|
|
@@ -155,6 +157,7 @@ describe('Hook Executor', () => {
|
|
|
155
157
|
logger,
|
|
156
158
|
debug: false,
|
|
157
159
|
screenshotDir: '/tmp',
|
|
160
|
+
stateDir: '/tmp',
|
|
158
161
|
capabilities: {},
|
|
159
162
|
}),
|
|
160
163
|
).rejects.toThrow('must export a default function');
|
|
@@ -176,6 +179,7 @@ describe('Hook Executor', () => {
|
|
|
176
179
|
logger,
|
|
177
180
|
debug: false,
|
|
178
181
|
screenshotDir: '/tmp',
|
|
182
|
+
stateDir: '/tmp',
|
|
179
183
|
capabilities: {},
|
|
180
184
|
}),
|
|
181
185
|
).rejects.toThrow('does not use defineHook()');
|
|
@@ -192,6 +196,7 @@ describe('Hook Executor', () => {
|
|
|
192
196
|
logger,
|
|
193
197
|
debug: false,
|
|
194
198
|
screenshotDir: '/tmp',
|
|
199
|
+
stateDir: '/tmp',
|
|
195
200
|
capabilities: {},
|
|
196
201
|
});
|
|
197
202
|
|
|
@@ -210,6 +215,7 @@ describe('Hook Executor', () => {
|
|
|
210
215
|
logger,
|
|
211
216
|
debug: false,
|
|
212
217
|
screenshotDir: '/tmp',
|
|
218
|
+
stateDir: '/tmp',
|
|
213
219
|
capabilities: {},
|
|
214
220
|
}),
|
|
215
221
|
).rejects.toThrow('Hook execution failed: simulated error');
|
|
@@ -229,6 +235,7 @@ describe('Hook Executor', () => {
|
|
|
229
235
|
logger,
|
|
230
236
|
debug: false,
|
|
231
237
|
screenshotDir: '/tmp',
|
|
238
|
+
stateDir: '/tmp',
|
|
232
239
|
capabilities: {},
|
|
233
240
|
};
|
|
234
241
|
|
package/src/hooks/executor.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
type DeployedSystem,
|
|
40
40
|
isMissingProviderInputError,
|
|
41
41
|
moduleArtifactDir,
|
|
42
|
+
moduleStateDir,
|
|
42
43
|
} from '@celilo/capabilities';
|
|
43
44
|
import {
|
|
44
45
|
type ContractHookSignature,
|
|
@@ -628,6 +629,10 @@ export async function invokeHook(
|
|
|
628
629
|
// below — the capability pre-flight is one — and nothing ever cleans
|
|
629
630
|
// those up, so a module accrues one per affected run forever.
|
|
630
631
|
const screenshotDir = moduleArtifactDir(modulePath, `${hookName}-${startTime}`);
|
|
632
|
+
// Unlike the artifact directory, this one is NOT per-run and is created
|
|
633
|
+
// unconditionally: a hook must be able to write state on its first ever run,
|
|
634
|
+
// and should never have to mkdir its own sanctioned location (celilo#1000).
|
|
635
|
+
const stateDir = moduleStateDir(modulePath);
|
|
631
636
|
|
|
632
637
|
// Build context
|
|
633
638
|
const loadedCapabilities = options.capabilities ?? {};
|
|
@@ -639,6 +644,7 @@ export async function invokeHook(
|
|
|
639
644
|
logger,
|
|
640
645
|
debug,
|
|
641
646
|
screenshotDir,
|
|
647
|
+
stateDir,
|
|
642
648
|
capabilities: loadedCapabilities,
|
|
643
649
|
};
|
|
644
650
|
|
|
@@ -673,6 +679,11 @@ export async function invokeHook(
|
|
|
673
679
|
// Create the artifact directory only once every early return is behind
|
|
674
680
|
// us, so the `finally` below is guaranteed to run and reclaim it.
|
|
675
681
|
mkdirSync(screenshotDir, { recursive: true });
|
|
682
|
+
// Created every run and reclaimed on none. An empty `state/` is not litter
|
|
683
|
+
// the way an empty per-run artifact directory is: there is one of them, it is
|
|
684
|
+
// where the module is told to write, and deleting it between runs would make
|
|
685
|
+
// its existence depend on whether the last hook happened to use it.
|
|
686
|
+
mkdirSync(stateDir, { recursive: true });
|
|
676
687
|
// Prune on write, so retention needs no scheduler of its own and cannot
|
|
677
688
|
// fall behind a module that runs often.
|
|
678
689
|
pruneModuleArtifacts(dirname(screenshotDir));
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { moduleStateDir } from '@celilo/capabilities';
|
|
6
|
+
import { classifyModulePath } from '../module/packaging/package-rules';
|
|
7
|
+
import { invokeHook } from './executor';
|
|
8
|
+
import type { HookLogger } from './types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* celilo#1000's FIRST acceptance criterion, which #1091 did not meet:
|
|
12
|
+
* "a hook has a documented, framework-provided place to write."
|
|
13
|
+
*
|
|
14
|
+
* The classification landed without the surface, so `module audit` tolerated
|
|
15
|
+
* `state/` while a hook had no way to ask where it was. Every assertion below
|
|
16
|
+
* is about the hook LEARNING the path, never about the path's spelling. A test
|
|
17
|
+
* that asserts `ctx.stateDir === join(root, 'state')` would pass just as well
|
|
18
|
+
* with no surface at all, because it computes the answer it is checking.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const silent: HookLogger = {
|
|
22
|
+
info() {},
|
|
23
|
+
warn() {},
|
|
24
|
+
error() {},
|
|
25
|
+
success() {},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function scratchModule(script: string): string {
|
|
29
|
+
const root = mkdtempSync(join(tmpdir(), 'celilo-statedir-'));
|
|
30
|
+
Bun.write(join(root, 'hook.ts'), script);
|
|
31
|
+
return root;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('celilo#1000: a hook is TOLD where its state directory is', () => {
|
|
35
|
+
test('the hook receives a usable stateDir it did not construct', async () => {
|
|
36
|
+
const root = scratchModule(`
|
|
37
|
+
import { defineHook } from '@celilo/capabilities';
|
|
38
|
+
import { writeFileSync } from 'node:fs';
|
|
39
|
+
import { join } from 'node:path';
|
|
40
|
+
export default defineHook({
|
|
41
|
+
requires: [] as const,
|
|
42
|
+
handler: async (ctx) => {
|
|
43
|
+
// The hook builds NOTHING. It writes where it was told.
|
|
44
|
+
writeFileSync(join(ctx.stateDir as string, 'cursor'), 'run-1');
|
|
45
|
+
return { received: ctx.stateDir };
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
`);
|
|
49
|
+
try {
|
|
50
|
+
const result = await invokeHook(
|
|
51
|
+
root,
|
|
52
|
+
'on_install',
|
|
53
|
+
'1.0',
|
|
54
|
+
{ script: 'hook.ts' },
|
|
55
|
+
{},
|
|
56
|
+
{},
|
|
57
|
+
{},
|
|
58
|
+
silent,
|
|
59
|
+
);
|
|
60
|
+
expect(result.error).toBeUndefined();
|
|
61
|
+
expect(result.success).toBe(true);
|
|
62
|
+
|
|
63
|
+
// The framework's own answer, not this test's guess.
|
|
64
|
+
const expected = moduleStateDir(root);
|
|
65
|
+
expect(result.outputs.received).toBe(expected);
|
|
66
|
+
expect(readFileSync(join(expected, 'cursor'), 'utf-8')).toBe('run-1');
|
|
67
|
+
} finally {
|
|
68
|
+
rmSync(root, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('it exists before the hook runs, on a module that never wrote state', async () => {
|
|
73
|
+
const root = scratchModule(`
|
|
74
|
+
import { defineHook } from '@celilo/capabilities';
|
|
75
|
+
import { existsSync } from 'node:fs';
|
|
76
|
+
export default defineHook({
|
|
77
|
+
requires: [] as const,
|
|
78
|
+
handler: async (ctx) => ({ existed: existsSync(ctx.stateDir as string) }),
|
|
79
|
+
});
|
|
80
|
+
`);
|
|
81
|
+
try {
|
|
82
|
+
// Nothing has ever written here. A hook must not have to mkdir -p its
|
|
83
|
+
// own sanctioned location.
|
|
84
|
+
expect(existsSync(moduleStateDir(root))).toBe(false);
|
|
85
|
+
const result = await invokeHook(
|
|
86
|
+
root,
|
|
87
|
+
'on_install',
|
|
88
|
+
'1.0',
|
|
89
|
+
{ script: 'hook.ts' },
|
|
90
|
+
{},
|
|
91
|
+
{},
|
|
92
|
+
{},
|
|
93
|
+
silent,
|
|
94
|
+
);
|
|
95
|
+
expect(result.outputs.existed).toBe(true);
|
|
96
|
+
} finally {
|
|
97
|
+
rmSync(root, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('what the hook was told to use is what the audit tolerates', () => {
|
|
102
|
+
// The two halves of celilo#1000 agree by construction rather than by
|
|
103
|
+
// someone remembering to keep them in step.
|
|
104
|
+
const root = '/tmp/some-module';
|
|
105
|
+
const told = moduleStateDir(root);
|
|
106
|
+
const relative = told.slice(root.length + 1);
|
|
107
|
+
expect(classifyModulePath(`${relative}/anything.db`)).toBe('derived');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -117,6 +117,7 @@ async function runTrespass(): Promise<{ report: TrespassReport; lines: string[]
|
|
|
117
117
|
logger,
|
|
118
118
|
debug: false,
|
|
119
119
|
screenshotDir: mkdtempSync(join(tmpdir(), 'celilo-trespass-artifacts-')),
|
|
120
|
+
stateDir: mkdtempSync(join(tmpdir(), 'celilo-trespass-artifacts-')),
|
|
120
121
|
capabilities: {},
|
|
121
122
|
};
|
|
122
123
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hermetic guards on the hook jail's mount set (design D9).
|
|
3
|
+
*
|
|
4
|
+
* `deriveMountSet` is pure, so every property D9 asserts is checkable here
|
|
5
|
+
* without spawning anything. The two that matter most are absence properties:
|
|
6
|
+
* what the jail does NOT contain is the acceptance criterion.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import { deriveMountSet, forbiddenPaths, isForbidden, toBwrapArgs } from './mount-set';
|
|
11
|
+
|
|
12
|
+
const BASE = {
|
|
13
|
+
modulePath: '/var/celilo/modules/caddy',
|
|
14
|
+
stateDir: '/var/celilo/modules/caddy/state',
|
|
15
|
+
socketDir: '/tmp/celilo-hook-a1b2c3',
|
|
16
|
+
runtimePath: '/usr/local/bin/bun',
|
|
17
|
+
runnerPath: '/opt/celilo/src/hooks/hook-runner.ts',
|
|
18
|
+
pathInputs: [],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const pathsOf = (r: Parameters<typeof deriveMountSet>[0]) =>
|
|
22
|
+
deriveMountSet(r).entries.map((e) => e.path);
|
|
23
|
+
|
|
24
|
+
describe('bwrap is never in the mount set', () => {
|
|
25
|
+
// The AppArmor profile grants userns to /usr/bin/bwrap for anyone on the
|
|
26
|
+
// box. A jailed hook that could exec it would be uid 0 with CAP_SYS_ADMIN in
|
|
27
|
+
// its own namespace. bwrap runs OUTSIDE the jail because it creates the
|
|
28
|
+
// jail, so withholding it costs nothing.
|
|
29
|
+
test('is absent from an ordinary derivation', () => {
|
|
30
|
+
expect(pathsOf(BASE).filter(isForbidden)).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('is dropped even when a contract input names it', () => {
|
|
34
|
+
// The realistic route back in is a developer debugging `bwrap: command not
|
|
35
|
+
// found` and adding it. This asserts the derivation refuses, rather than
|
|
36
|
+
// trusting nobody will ask.
|
|
37
|
+
const set = deriveMountSet({
|
|
38
|
+
...BASE,
|
|
39
|
+
pathInputs: [{ name: 'evil', value: '/usr/bin/bwrap', access: 'read' }],
|
|
40
|
+
});
|
|
41
|
+
expect(set.entries.map((e) => e.path)).not.toContain('/usr/bin/bwrap');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('every forbidden path is actually recognised', () => {
|
|
45
|
+
for (const p of forbiddenPaths()) expect(isForbidden(p)).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('what the jail must not contain', () => {
|
|
50
|
+
test("celilo's data directory and its secrets are absent", () => {
|
|
51
|
+
const paths = pathsOf(BASE);
|
|
52
|
+
// Absence, not a check. These simply do not exist inside the jail.
|
|
53
|
+
expect(paths).not.toContain('/var/celilo');
|
|
54
|
+
expect(paths).not.toContain('/var/celilo/master.key');
|
|
55
|
+
expect(paths).not.toContain('/var/celilo/celilo.db');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a sibling module's tree is absent", () => {
|
|
59
|
+
const paths = pathsOf(BASE);
|
|
60
|
+
expect(paths).not.toContain('/var/celilo/modules');
|
|
61
|
+
expect(paths.some((p) => p.includes('/modules/technitium'))).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('the celilo CLI is absent, so a hook cannot re-enter it (celilo#1121)', () => {
|
|
65
|
+
expect(pathsOf(BASE)).not.toContain('/usr/local/bin/celilo');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('ordering is semantic', () => {
|
|
70
|
+
test('the tmpfs leads, so it cannot erase the socket or a staged input', () => {
|
|
71
|
+
// Every staged contract input and the broker socket live under os.tmpdir().
|
|
72
|
+
// Bind them before the tmpfs and they vanish — and a hook whose backup_dir
|
|
73
|
+
// is silently empty SUCCEEDS and produces a backup containing nothing.
|
|
74
|
+
const set = deriveMountSet({
|
|
75
|
+
...BASE,
|
|
76
|
+
pathInputs: [
|
|
77
|
+
{ name: 'backup_dir', value: '/tmp/celilo-backup-9f/envelope/data', access: 'write' },
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
const tmpfsAt = set.entries.findIndex((e) => e.mode === 'tmpfs' && e.path === '/tmp');
|
|
81
|
+
const socketAt = set.entries.findIndex((e) => e.path === BASE.socketDir);
|
|
82
|
+
const inputAt = set.entries.findIndex((e) => e.path.startsWith('/tmp/celilo-backup-'));
|
|
83
|
+
|
|
84
|
+
expect(tmpfsAt).toBeGreaterThanOrEqual(0);
|
|
85
|
+
expect(socketAt).toBeGreaterThan(tmpfsAt);
|
|
86
|
+
expect(inputAt).toBeGreaterThan(tmpfsAt);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('writable directories come after the read-only module tree', () => {
|
|
90
|
+
const set = deriveMountSet({ ...BASE, screenshotDir: `${BASE.modulePath}/screenshots/run1` });
|
|
91
|
+
const treeAt = set.entries.findIndex((e) => e.path === BASE.modulePath && e.mode === 'ro');
|
|
92
|
+
for (const carved of ['state', 'generated', 'screenshots/run1']) {
|
|
93
|
+
const at = set.entries.findIndex((e) => e.path === `${BASE.modulePath}/${carved}`);
|
|
94
|
+
expect(at).toBeGreaterThan(treeAt);
|
|
95
|
+
expect(set.entries[at]?.mode).toBe('rw');
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('contract inputs are bound at their declared access', () => {
|
|
101
|
+
test("'write' is read-write and 'read' is read-only", () => {
|
|
102
|
+
const set = deriveMountSet({
|
|
103
|
+
...BASE,
|
|
104
|
+
pathInputs: [
|
|
105
|
+
{ name: 'backup_dir', value: '/tmp/stage/data', access: 'write' },
|
|
106
|
+
{ name: 'artifact_path', value: '/tmp/stage/db.sqlite', access: 'read' },
|
|
107
|
+
],
|
|
108
|
+
});
|
|
109
|
+
expect(set.entries.find((e) => e.path === '/tmp/stage/data')?.mode).toBe('rw');
|
|
110
|
+
expect(set.entries.find((e) => e.path === '/tmp/stage/db.sqlite')?.mode).toBe('ro');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('an undeclared input contributes nothing', () => {
|
|
114
|
+
// db_path was passed for months without being declared. A derivation that
|
|
115
|
+
// walks declarations cannot see it, which is the correct outcome here and
|
|
116
|
+
// the reason the contract has to declare what it passes (celilo#1118).
|
|
117
|
+
expect(pathsOf(BASE)).not.toContain('/var/celilo/celilo.db');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('paths are identical inside and outside', () => {
|
|
122
|
+
test('no entry is remapped', () => {
|
|
123
|
+
const args = toBwrapArgs(deriveMountSet(BASE));
|
|
124
|
+
for (let i = 0; i < args.length; i++) {
|
|
125
|
+
if (args[i] === '--bind' || args[i] === '--ro-bind') {
|
|
126
|
+
expect(args[i + 1]).toBe(args[i + 2] as string);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('the jail names its own working directory', () => {
|
|
132
|
+
// The spawn sets no cwd, so the child inherits celilo's — a directory that
|
|
133
|
+
// usually does not exist inside the jail.
|
|
134
|
+
expect(deriveMountSet(BASE).chdir).toBe(BASE.modulePath);
|
|
135
|
+
expect(toBwrapArgs(deriveMountSet(BASE))).toContain('--chdir');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('~/.ssh is stage 2 only', () => {
|
|
140
|
+
test('bound read-only when supplied', () => {
|
|
141
|
+
const set = deriveMountSet({ ...BASE, sshDir: '/var/celilo/.ssh' });
|
|
142
|
+
expect(set.entries.find((e) => e.path === '/var/celilo/.ssh')?.mode).toBe('ro');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('absent when not supplied, which is what stage 3 does', () => {
|
|
146
|
+
expect(pathsOf(BASE).some((p) => p.endsWith('/.ssh'))).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hook jail's mount set (openspec/changes/hook-process-boundary, D9).
|
|
3
|
+
*
|
|
4
|
+
* A jailed hook sees exactly the paths listed here and nothing else. Not
|
|
5
|
+
* "denied" — ABSENT. A hook reaching for celilo's master key gets `ENOENT`,
|
|
6
|
+
* because inside the jail there is no such file. That is the acceptance
|
|
7
|
+
* criterion, and it is satisfied by absence rather than by a check.
|
|
8
|
+
*
|
|
9
|
+
* The set is DERIVED, never declared by the module. A module cannot ask for
|
|
10
|
+
* more, which is the whole point.
|
|
11
|
+
*
|
|
12
|
+
* This file is pure. It computes a description of a filesystem view and
|
|
13
|
+
* touches nothing. That is what lets `bwrap` is never in the mount set be a
|
|
14
|
+
* hermetic test rather than an integration one, and what lets the unjailed
|
|
15
|
+
* advisory lint (task 4.7) consume the same computation instead of a second
|
|
16
|
+
* one that can drift from it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
20
|
+
import type { PathAccess } from '@celilo/capabilities';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `tmpfs` is not an access level, it is "put a fresh empty filesystem here".
|
|
24
|
+
* It has to be its own mode because ORDER matters and a tmpfs erases whatever
|
|
25
|
+
* the jail would otherwise see at that path.
|
|
26
|
+
*/
|
|
27
|
+
export type MountMode = 'ro' | 'rw' | 'tmpfs';
|
|
28
|
+
|
|
29
|
+
export interface MountEntry {
|
|
30
|
+
/**
|
|
31
|
+
* The path, IDENTICAL inside and outside the jail. Never remapped.
|
|
32
|
+
*
|
|
33
|
+
* Paths cross the capability boundary as strings: a hook hands the broker a
|
|
34
|
+
* path it wrote to, and the broker has to be able to read it. A remapped
|
|
35
|
+
* mount would make those two names disagree and the failure would look like
|
|
36
|
+
* a missing file rather than a translation bug.
|
|
37
|
+
*/
|
|
38
|
+
readonly path: string;
|
|
39
|
+
readonly mode: MountMode;
|
|
40
|
+
/** Why this row exists. Surfaced by the unjailed lint and by `system doctor`. */
|
|
41
|
+
readonly reason: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface MountSet {
|
|
45
|
+
/**
|
|
46
|
+
* ORDER IS SEMANTIC. bubblewrap applies these in sequence and a later entry
|
|
47
|
+
* wins over an earlier one, which is what lets a read-write directory sit
|
|
48
|
+
* inside a read-only tree. Do not sort this list.
|
|
49
|
+
*/
|
|
50
|
+
readonly entries: readonly MountEntry[];
|
|
51
|
+
/**
|
|
52
|
+
* The jail's working directory.
|
|
53
|
+
*
|
|
54
|
+
* The spawn does not set `cwd`, so a hook child inherits celilo's — whatever
|
|
55
|
+
* directory the operator's shell happened to be in. Inside the jail that
|
|
56
|
+
* directory usually does not exist, and bubblewrap fails on a path nobody
|
|
57
|
+
* chose. So the jail names one explicitly.
|
|
58
|
+
*/
|
|
59
|
+
readonly chdir: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A path input the contract declared, paired with the value the framework resolved. */
|
|
63
|
+
export interface DeclaredPathInput {
|
|
64
|
+
readonly name: string;
|
|
65
|
+
readonly value: string;
|
|
66
|
+
readonly access: PathAccess;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface MountSetRequest {
|
|
70
|
+
/**
|
|
71
|
+
* The module's own tree. Comes from the DB as `module.sourcePath`, which is
|
|
72
|
+
* NOT guaranteed to sit under the module store — a restored database carries
|
|
73
|
+
* the absolute paths of the box it was taken from (ISS-0052). Resolved below
|
|
74
|
+
* before it becomes a bind-mount argument.
|
|
75
|
+
*/
|
|
76
|
+
readonly modulePath: string;
|
|
77
|
+
/** `<module>/state`, celilo#1000's sanctioned writable directory. */
|
|
78
|
+
readonly stateDir: string;
|
|
79
|
+
/** `<module>/screenshots/<run>`, this run only. Absent when the hook takes none. */
|
|
80
|
+
readonly screenshotDir?: string;
|
|
81
|
+
/** The directory holding the broker's unix socket. */
|
|
82
|
+
readonly socketDir: string;
|
|
83
|
+
/** The interpreter celilo spawns (`process.execPath`). */
|
|
84
|
+
readonly runtimePath: string;
|
|
85
|
+
/** The runner shim, which lives in celilo's tree rather than the module's. */
|
|
86
|
+
readonly runnerPath: string;
|
|
87
|
+
/** Contract-declared path inputs, already resolved to values. */
|
|
88
|
+
readonly pathInputs: readonly DeclaredPathInput[];
|
|
89
|
+
/**
|
|
90
|
+
* The operator's `~/.ssh`, read-only, STAGE 2 ONLY.
|
|
91
|
+
*
|
|
92
|
+
* `remote.ts` still runs inside the hook and needs the key. Stage 3 brokers
|
|
93
|
+
* those calls and drops this row, which is what turns D12's target check
|
|
94
|
+
* from a convention into a boundary. Dropping it before stage 3 lands
|
|
95
|
+
* hardens nothing — it just stops every hook reaching its own systems.
|
|
96
|
+
*/
|
|
97
|
+
readonly sshDir?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Directories whose contents the runtime needs in order to start at all. */
|
|
101
|
+
const RUNTIME_SUPPORT_DIRS = ['/usr/lib', '/lib', '/lib64', '/etc/ssl'] as const;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Paths that must NEVER appear in a mount set, whatever asks for them.
|
|
105
|
+
*
|
|
106
|
+
* `bwrap` is the load-bearing entry and the reason this list exists rather
|
|
107
|
+
* than being a comment. celilo ships an AppArmor profile granting `userns` to
|
|
108
|
+
* `/usr/bin/bwrap` so the jail can be built at all, and that grant applies to
|
|
109
|
+
* anyone on the box who runs it. A jailed hook that could exec `bwrap` would
|
|
110
|
+
* get a namespace of its own, be uid 0 inside it with `CAP_SYS_ADMIN`, and
|
|
111
|
+
* reach kernel surface an unprivileged user cannot otherwise touch — which is
|
|
112
|
+
* the entire reason Ubuntu restricts unprivileged user namespaces.
|
|
113
|
+
*
|
|
114
|
+
* `bwrap` runs OUTSIDE the jail because it is what creates the jail. It does
|
|
115
|
+
* not need to exist inside one, so leaving it out costs nothing.
|
|
116
|
+
*
|
|
117
|
+
* Task 4.10 already predicts how this gets undone: a browser hook fails with
|
|
118
|
+
* `bwrap: command not found`, a developer reads a missing binary and adds it,
|
|
119
|
+
* the suite goes green, and the escape path is open with nothing to read. The
|
|
120
|
+
* test on this constant is that missing thing to read.
|
|
121
|
+
*/
|
|
122
|
+
const NEVER_MOUNT = ['/usr/bin/bwrap', '/usr/local/bin/bwrap', '/bin/bwrap'] as const;
|
|
123
|
+
|
|
124
|
+
function entry(path: string, mode: MountMode, reason: string): MountEntry {
|
|
125
|
+
return { path, mode, reason };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Compute the filesystem view a hook gets.
|
|
130
|
+
*
|
|
131
|
+
* Pure: resolves paths lexically and reads nothing from disk. `resolve` is not
|
|
132
|
+
* `realpath` — it cannot follow a symlink, because following one is I/O. The
|
|
133
|
+
* caller supplies already-real paths; on macOS that distinction is the
|
|
134
|
+
* difference between a rule that applies and one that silently does not
|
|
135
|
+
* (task 4.8), so the caller's `realpath` is not optional.
|
|
136
|
+
*/
|
|
137
|
+
export function deriveMountSet(request: MountSetRequest): MountSet {
|
|
138
|
+
const modulePath = resolve(request.modulePath);
|
|
139
|
+
const entries: MountEntry[] = [];
|
|
140
|
+
|
|
141
|
+
// 1. A private /tmp FIRST, because it erases everything beneath it.
|
|
142
|
+
//
|
|
143
|
+
// This has to lead. The broker's socket directory is an `mkdtemp` under
|
|
144
|
+
// `os.tmpdir()`, and every staged contract input (backup_dir, restore_dir,
|
|
145
|
+
// the cross-module roots) comes from `stagingDirFor`, also under
|
|
146
|
+
// `os.tmpdir()`. Bind those first and the tmpfs wipes them.
|
|
147
|
+
//
|
|
148
|
+
// The failure that causes is not a crash. A hook whose `backup_dir` is
|
|
149
|
+
// silently an empty tmpfs directory writes into it, returns success, and
|
|
150
|
+
// produces a backup containing NOTHING. It is found at restore. So the gate
|
|
151
|
+
// on this asserts the artifact is non-empty, never that the hook exited zero.
|
|
152
|
+
entries.push(entry('/tmp', 'tmpfs', 'private scratch, per run'));
|
|
153
|
+
|
|
154
|
+
// 2. The runtime. Without it nothing runs, so it is not really a policy row.
|
|
155
|
+
entries.push(entry(request.runtimePath, 'ro', 'the interpreter'));
|
|
156
|
+
entries.push(entry(dirname(request.runnerPath), 'ro', 'the runner shim celilo spawns'));
|
|
157
|
+
for (const dir of RUNTIME_SUPPORT_DIRS) {
|
|
158
|
+
entries.push(entry(dir, 'ro', 'shared libraries and trust store'));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 3. The module's own tree, read-only, then its writable directories carved
|
|
162
|
+
// on top. bubblewrap resolves that in the right order, which is why the
|
|
163
|
+
// order here is not cosmetic.
|
|
164
|
+
//
|
|
165
|
+
// D9 says "the module's own tree is bound read-only". These are the
|
|
166
|
+
// carved exceptions to that sentence, and there are three of them rather
|
|
167
|
+
// than the one D9's prose implies.
|
|
168
|
+
entries.push(entry(modulePath, 'ro', "the module's own tree"));
|
|
169
|
+
entries.push(
|
|
170
|
+
entry(resolve(request.stateDir), 'rw', 'ctx.stateDir, the sanctioned writable directory'),
|
|
171
|
+
);
|
|
172
|
+
entries.push(
|
|
173
|
+
entry(join(modulePath, 'generated'), 'rw', "celilo's generated output the hook may amend"),
|
|
174
|
+
);
|
|
175
|
+
if (request.screenshotDir) {
|
|
176
|
+
entries.push(entry(resolve(request.screenshotDir), 'rw', 'ctx.screenshotDir, this run only'));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 4. The broker channel. Bound AFTER the tmpfs, per the note above.
|
|
180
|
+
entries.push(entry(resolve(request.socketDir), 'rw', 'the capability broker socket'));
|
|
181
|
+
|
|
182
|
+
// 5. Contract-declared path inputs, at the access the contract declares.
|
|
183
|
+
// Never inferred from the name — see ContractField.path.
|
|
184
|
+
for (const input of request.pathInputs) {
|
|
185
|
+
entries.push(
|
|
186
|
+
entry(
|
|
187
|
+
resolve(input.value),
|
|
188
|
+
input.access === 'write' ? 'rw' : 'ro',
|
|
189
|
+
`contract input '${input.name}' (${input.access})`,
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 6. Stage 2 only. See MountSetRequest.sshDir.
|
|
195
|
+
if (request.sshDir) {
|
|
196
|
+
entries.push(
|
|
197
|
+
entry(
|
|
198
|
+
resolve(request.sshDir),
|
|
199
|
+
'ro',
|
|
200
|
+
'remote.ts needs the key until the broker holds it (D12)',
|
|
201
|
+
),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
entries: entries.filter((e) => !isForbidden(e.path)),
|
|
207
|
+
// The module's own tree is the only directory guaranteed to exist inside
|
|
208
|
+
// the jail and to mean something to the hook.
|
|
209
|
+
chdir: modulePath,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Is this path one nothing may ever mount? See NEVER_MOUNT. */
|
|
214
|
+
export function isForbidden(path: string): boolean {
|
|
215
|
+
const resolved = isAbsolute(path) ? resolve(path) : path;
|
|
216
|
+
return NEVER_MOUNT.some((forbidden) => resolved === forbidden);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The forbidden list, for the test that asserts it is honoured. */
|
|
220
|
+
export function forbiddenPaths(): readonly string[] {
|
|
221
|
+
return NEVER_MOUNT;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Render a mount set as bubblewrap arguments, in order. */
|
|
225
|
+
export function toBwrapArgs(set: MountSet): string[] {
|
|
226
|
+
const args: string[] = [];
|
|
227
|
+
for (const e of set.entries) {
|
|
228
|
+
if (e.mode === 'tmpfs') args.push('--tmpfs', e.path);
|
|
229
|
+
else if (e.mode === 'rw') args.push('--bind', e.path, e.path);
|
|
230
|
+
else args.push('--ro-bind', e.path, e.path);
|
|
231
|
+
}
|
|
232
|
+
args.push('--chdir', set.chdir);
|
|
233
|
+
return args;
|
|
234
|
+
}
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* change and requires a v2.0 contract.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import type { HookName } from '@celilo/capabilities';
|
|
25
|
+
import type { HookName, PathAccess } from '@celilo/capabilities';
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* Per-input/output metadata.
|
|
@@ -33,6 +33,21 @@ import type { HookName } from '@celilo/capabilities';
|
|
|
33
33
|
*/
|
|
34
34
|
export interface ContractField {
|
|
35
35
|
required: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Set when the framework supplies a filesystem path in this field, and the
|
|
38
|
+
* access the hook is given to it. Absent means "not a path".
|
|
39
|
+
*
|
|
40
|
+
* The hook jail derives its bind mounts from this (design D9). Path-ness is
|
|
41
|
+
* NEVER inferred from the field name at runtime: a name heuristic silently
|
|
42
|
+
* changes behaviour the day somebody adds an input called `workspace`, and
|
|
43
|
+
* the symptom is an ENOENT on a path that visibly exists on the box.
|
|
44
|
+
*
|
|
45
|
+
* A field carrying a path-shaped value with no annotation here is a defect.
|
|
46
|
+
* `db_path` was one for months — passed by `backup-create.ts` and declared
|
|
47
|
+
* nowhere, so anything reasoning from this table was wrong about what a
|
|
48
|
+
* backup hook receives.
|
|
49
|
+
*/
|
|
50
|
+
path?: { access: PathAccess };
|
|
36
51
|
}
|
|
37
52
|
|
|
38
53
|
/**
|
|
@@ -112,7 +127,7 @@ export const V1_HOOKS: ContractHooks = {
|
|
|
112
127
|
},
|
|
113
128
|
on_backup: {
|
|
114
129
|
inputs: {
|
|
115
|
-
backup_dir: { required: true },
|
|
130
|
+
backup_dir: { required: true, path: { access: 'write' } },
|
|
116
131
|
/**
|
|
117
132
|
* Path to a directory containing read-only mirrors of OTHER modules'
|
|
118
133
|
* `generated/terraform/` trees, plus an `index.json` enumerating
|
|
@@ -136,7 +151,7 @@ export const V1_HOOKS: ContractHooks = {
|
|
|
136
151
|
* terraform.tfstate
|
|
137
152
|
* terraform.tfstate.backup
|
|
138
153
|
*/
|
|
139
|
-
cross_module_root: { required: false },
|
|
154
|
+
cross_module_root: { required: false, path: { access: 'read' } },
|
|
140
155
|
},
|
|
141
156
|
outputs: {
|
|
142
157
|
artifact_count: { required: true },
|
|
@@ -146,7 +161,7 @@ export const V1_HOOKS: ContractHooks = {
|
|
|
146
161
|
},
|
|
147
162
|
on_backup_analyze: {
|
|
148
163
|
inputs: {
|
|
149
|
-
artifact_path: { required: true },
|
|
164
|
+
artifact_path: { required: true, path: { access: 'read' } },
|
|
150
165
|
},
|
|
151
166
|
outputs: {
|
|
152
167
|
artifact_count: { required: true },
|
|
@@ -156,7 +171,7 @@ export const V1_HOOKS: ContractHooks = {
|
|
|
156
171
|
},
|
|
157
172
|
on_restore: {
|
|
158
173
|
inputs: {
|
|
159
|
-
restore_dir: { required: true },
|
|
174
|
+
restore_dir: { required: true, path: { access: 'read' } },
|
|
160
175
|
schema_version: { required: true },
|
|
161
176
|
/**
|
|
162
177
|
* Path to a writable staging directory the framework atomically
|
|
@@ -170,7 +185,7 @@ export const V1_HOOKS: ContractHooks = {
|
|
|
170
185
|
* a single rename + cleanup of any stale files, so a partial
|
|
171
186
|
* write (hook crashed mid-restore) leaves the live state intact.
|
|
172
187
|
*/
|
|
173
|
-
cross_module_write_root: { required: false },
|
|
188
|
+
cross_module_write_root: { required: false, path: { access: 'write' } },
|
|
174
189
|
},
|
|
175
190
|
outputs: {
|
|
176
191
|
restored_items: { required: true },
|
|
@@ -296,6 +311,30 @@ export const V1_HOOKS: ContractHooks = {
|
|
|
296
311
|
inputs: {},
|
|
297
312
|
outputs: {},
|
|
298
313
|
},
|
|
314
|
+
/**
|
|
315
|
+
* Reclaim disk a module's own store has accumulated and has no way to
|
|
316
|
+
* release on its own.
|
|
317
|
+
*
|
|
318
|
+
* Distinct from `on_backup`'s retention, which prunes copies celilo itself
|
|
319
|
+
* made and records. This is for state the module's SERVICE owns and celilo
|
|
320
|
+
* never sees — a registry's package store, a cache, an artifact directory —
|
|
321
|
+
* where the only thing that knows what is superseded is the service.
|
|
322
|
+
*
|
|
323
|
+
* Deliberately not a converge hook: convergence re-asserts a desired state
|
|
324
|
+
* and is safe to run constantly, while this DELETES and its mistakes are not
|
|
325
|
+
* recoverable. So it declares its retention policy in config, supports a dry
|
|
326
|
+
* run, and is expected to be conservative — the celilo-registry sweep never
|
|
327
|
+
* removes the last revision of a release, which is what makes running it
|
|
328
|
+
* unattended defensible.
|
|
329
|
+
*
|
|
330
|
+
* Driven on demand (`celilo module run-hook <module> sweep_revisions`) and,
|
|
331
|
+
* where the store grows without an operator, by a `timer.tick.*`
|
|
332
|
+
* subscription.
|
|
333
|
+
*/
|
|
334
|
+
sweep_revisions: {
|
|
335
|
+
inputs: {},
|
|
336
|
+
outputs: {},
|
|
337
|
+
},
|
|
299
338
|
/**
|
|
300
339
|
* Build-bus upstream publish hook. The executor passes the
|
|
301
340
|
* PublishEvent fields as env vars (CELILO_EVENT_PAYLOAD,
|
package/src/manifest/schema.ts
CHANGED
|
@@ -366,6 +366,7 @@ const LIFECYCLE_HOOK_SCHEMAS = {
|
|
|
366
366
|
reconcile_clients: LifecycleHookSchema.optional(),
|
|
367
367
|
list_peers: LifecycleHookSchema.optional(),
|
|
368
368
|
reconcile_peers: LifecycleHookSchema.optional(),
|
|
369
|
+
sweep_revisions: LifecycleHookSchema.optional(),
|
|
369
370
|
} satisfies Record<HookName, z.ZodTypeAny>;
|
|
370
371
|
|
|
371
372
|
/**
|
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
|
|
2
2
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { moduleStateDir } from '@celilo/capabilities';
|
|
5
6
|
import { moduleIntegrity, modules } from '../../db/schema';
|
|
6
7
|
import { cleanupTestDatabase, setupTestDatabase } from '../../test-utils/database';
|
|
7
8
|
import { auditModule } from './audit';
|
|
@@ -78,9 +79,14 @@ describe('celilo#1000: state/ is the hook-writable directory', () => {
|
|
|
78
79
|
const before = await auditModule('state-gate', db);
|
|
79
80
|
expect(before.violations).toEqual([]);
|
|
80
81
|
|
|
81
|
-
// Now a hook runs and writes something nobody declared.
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
// Now a hook runs and writes something nobody declared. The path comes
|
|
83
|
+
// from the framework, NOT from this test: a gate that hand-rolls
|
|
84
|
+
// `join(root, 'state')` proves the audit tolerates a directory while
|
|
85
|
+
// saying nothing about whether a module can find it, which is exactly
|
|
86
|
+
// how the surface half of celilo#1000 shipped missing.
|
|
87
|
+
const stateDir = moduleStateDir(root);
|
|
88
|
+
mkdirSync(stateDir, { recursive: true });
|
|
89
|
+
writeFileSync(join(stateDir, unanticipatedName(42)), 'whatever the hook needed');
|
|
84
90
|
|
|
85
91
|
const after = await auditModule('state-gate', db);
|
|
86
92
|
expect(after.violations).toEqual([]);
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every module's bundled `@celilo/*` range must be able to resolve the version
|
|
3
|
+
* the workspace is about to publish.
|
|
4
|
+
*
|
|
5
|
+
* The failure this exists to catch has no symptom. A module bundles its own
|
|
6
|
+
* copy of `@celilo/capabilities` and runs THAT copy (celilo#173), so when the
|
|
7
|
+
* workspace publishes a new major, a module still pinned to the old caret
|
|
8
|
+
* range keeps installing the old copy. Everything typechecks. Every test
|
|
9
|
+
* passes. The new export is simply unreachable to every module in the fleet,
|
|
10
|
+
* and nothing anywhere says so.
|
|
11
|
+
*
|
|
12
|
+
* That is not hypothetical. `firewall_registry` was registered in
|
|
13
|
+
* `CapabilityRegistry` and npm's published `@celilo/capabilities@2.6.0`
|
|
14
|
+
* contains zero occurrences of it. Every module bundled `^2.6.0`, and a caret
|
|
15
|
+
* range never crosses a major, so the registration reached nothing. It was
|
|
16
|
+
* found by a person reading the diff (celilo#1089), not by a gate.
|
|
17
|
+
*
|
|
18
|
+
* `npm-consumer-smoke` cannot see it: it installs from locally-packed tarballs
|
|
19
|
+
* and never consults a registry, which is exactly why this class drifts unseen.
|
|
20
|
+
* `check:modules` cannot see it either — it installs each module's DECLARED
|
|
21
|
+
* range and typechecks, so a module that does not yet USE the new export is
|
|
22
|
+
* perfectly happy on the old copy. Both gates are green while the thing is
|
|
23
|
+
* broken.
|
|
24
|
+
*
|
|
25
|
+
* ## THIS GATE IS DELIBERATELY NARROW. READ THIS BEFORE TRUSTING IT.
|
|
26
|
+
*
|
|
27
|
+
* Its name invites a stronger reading than it earns. It compares the WORKSPACE
|
|
28
|
+
* version against each module's range. It does NOT consult a registry, so it
|
|
29
|
+
* cannot see a gap between what is PUBLISHED and what a module bundles.
|
|
30
|
+
*
|
|
31
|
+
* Measured, not assumed: run against `00c12ec2^1` — the commit immediately
|
|
32
|
+
* before the Version Packages PR that shipped capabilities 3.0.0 — this gate
|
|
33
|
+
* passes 3/0. At that commit the workspace was 2.6.0 and every module was
|
|
34
|
+
* `^2.6.0`, which satisfies, while npm's published 2.6.0 contained zero
|
|
35
|
+
* occurrences of `firewall_registry`. **So it would not have caught
|
|
36
|
+
* celilo#1089, the bug it was written for.** Nor `hello-trespass` sitting at
|
|
37
|
+
* `^2.3.0`, because `satisfies('2.6.0', '^2.3.0')` is true: a caret crosses
|
|
38
|
+
* minors freely and stops only at a major.
|
|
39
|
+
*
|
|
40
|
+
* If you are here because a published export turned out to be unreachable and
|
|
41
|
+
* you are wondering why this was green: it never looked. That check needs a
|
|
42
|
+
* registry query, which is network-bound and therefore does not belong beside
|
|
43
|
+
* hermetic checks in `ci/validate` — a flaky gate gets disabled. Its home is
|
|
44
|
+
* the release pipeline or a scheduled job.
|
|
45
|
+
*
|
|
46
|
+
* And one limitation no in-repo gate can ever close, however it is written:
|
|
47
|
+
* out-of-repo modules. `lunacycle` lives outside this checkout and no sweep
|
|
48
|
+
* here can see its pin at all (lunacycle#64).
|
|
49
|
+
*
|
|
50
|
+
* What this DOES catch is a module whose pin is out of lockstep with the
|
|
51
|
+
* workspace, which happens when a module is added or edited outside the
|
|
52
|
+
* release sweep's window: the Version Packages PR is open with everything at
|
|
53
|
+
* `^3.0.0`, a new module merges to main at `^2.6.0`, the version PR merges,
|
|
54
|
+
* and nothing re-runs the sweep until the next release.
|
|
55
|
+
*
|
|
56
|
+
* `scripts/sync-consumer-pins.ts` is what normally keeps these aligned, and it
|
|
57
|
+
* runs inside the version-PR phase of `release.yml`. This gate is the check
|
|
58
|
+
* that it RAN and covered everything — a module added between releases carries
|
|
59
|
+
* whatever range its author typed until the next release sweeps it.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import { describe, expect, test } from 'bun:test';
|
|
63
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
64
|
+
import { join } from 'node:path';
|
|
65
|
+
import { repoRoot } from './capability-shape';
|
|
66
|
+
|
|
67
|
+
interface PackageJson {
|
|
68
|
+
name?: string;
|
|
69
|
+
version?: string;
|
|
70
|
+
dependencies?: Record<string, string>;
|
|
71
|
+
devDependencies?: Record<string, string>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readJson(path: string): PackageJson {
|
|
75
|
+
return JSON.parse(readFileSync(path, 'utf-8')) as PackageJson;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `@celilo/*` package name → the version this checkout would publish. */
|
|
79
|
+
function workspaceVersions(root: string): Map<string, string> {
|
|
80
|
+
const versions = new Map<string, string>();
|
|
81
|
+
for (const dir of ['packages', 'apps']) {
|
|
82
|
+
const base = join(root, dir);
|
|
83
|
+
if (!existsSync(base)) continue;
|
|
84
|
+
for (const entry of readdirSync(base)) {
|
|
85
|
+
const manifest = join(base, entry, 'package.json');
|
|
86
|
+
if (!existsSync(manifest)) continue;
|
|
87
|
+
const pkg = readJson(manifest);
|
|
88
|
+
if (pkg.name?.startsWith('@celilo/') && pkg.version) versions.set(pkg.name, pkg.version);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return versions;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface ModuleDep {
|
|
95
|
+
module: string;
|
|
96
|
+
dep: string;
|
|
97
|
+
range: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Every `@celilo/*` dependency declared by any module's `scripts/` package. */
|
|
101
|
+
function moduleDeps(root: string): ModuleDep[] {
|
|
102
|
+
const out: ModuleDep[] = [];
|
|
103
|
+
const modulesDir = join(root, 'modules');
|
|
104
|
+
for (const module of readdirSync(modulesDir)) {
|
|
105
|
+
const manifest = join(modulesDir, module, 'scripts', 'package.json');
|
|
106
|
+
if (!existsSync(manifest)) continue;
|
|
107
|
+
const pkg = readJson(manifest);
|
|
108
|
+
for (const [dep, range] of Object.entries({
|
|
109
|
+
...(pkg.dependencies ?? {}),
|
|
110
|
+
...(pkg.devDependencies ?? {}),
|
|
111
|
+
})) {
|
|
112
|
+
if (dep.startsWith('@celilo/')) out.push({ module, dep, range });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe('a module can reach what the workspace publishes', () => {
|
|
119
|
+
const root = repoRoot();
|
|
120
|
+
const versions = workspaceVersions(root);
|
|
121
|
+
const deps = moduleDeps(root);
|
|
122
|
+
|
|
123
|
+
test('the scan found the workspace packages and the module deps', () => {
|
|
124
|
+
// An empty scan passes every assertion below while checking nothing, which
|
|
125
|
+
// is the failure mode that makes a green gate worse than no gate.
|
|
126
|
+
expect(versions.size).toBeGreaterThan(0);
|
|
127
|
+
expect(deps.length).toBeGreaterThan(0);
|
|
128
|
+
expect(versions.has('@celilo/capabilities')).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('every module range resolves the version this checkout would publish', () => {
|
|
132
|
+
const unreachable = deps
|
|
133
|
+
.filter(({ dep, range }) => {
|
|
134
|
+
const version = versions.get(dep);
|
|
135
|
+
// A dep on a package this checkout does not build is out of scope: it
|
|
136
|
+
// resolves from the registry like any third-party dependency.
|
|
137
|
+
if (!version) return false;
|
|
138
|
+
return !Bun.semver.satisfies(version, range);
|
|
139
|
+
})
|
|
140
|
+
.map(
|
|
141
|
+
({ module, dep, range }) =>
|
|
142
|
+
`${module} pins ${dep}@${range}, which cannot resolve the workspace's ${versions.get(dep)}`,
|
|
143
|
+
)
|
|
144
|
+
.sort();
|
|
145
|
+
|
|
146
|
+
const guidance = [
|
|
147
|
+
'A module bundles its own copy and runs THAT copy (celilo#173). A range that',
|
|
148
|
+
'cannot reach the version about to be published means every export added',
|
|
149
|
+
'since is unreachable to that module, silently.',
|
|
150
|
+
'',
|
|
151
|
+
'Fix: bun scripts/sync-consumer-pins.ts (then commit the rewritten pins)',
|
|
152
|
+
'',
|
|
153
|
+
...unreachable,
|
|
154
|
+
].join('\n');
|
|
155
|
+
|
|
156
|
+
expect(unreachable, guidance).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('PROVE IT FAILS: a caret range does not cross a major', () => {
|
|
160
|
+
// The exact shape of celilo#1089: modules on ^2.6.0, workspace at 3.0.0.
|
|
161
|
+
expect(Bun.semver.satisfies('3.0.0', '^2.6.0')).toBe(false);
|
|
162
|
+
// And the near-miss that makes it easy to believe you are covered: a caret
|
|
163
|
+
// DOES cross a minor, so a lagging pin looks harmless right up until the
|
|
164
|
+
// major.
|
|
165
|
+
expect(Bun.semver.satisfies('2.6.0', '^2.3.0')).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { CLIContext } from './cli-context';
|
|
11
|
+
import { getModuleTestConfig } from './fixtures';
|
|
11
12
|
import { type IntegrationTestContext, setupIntegrationTest } from './integration';
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -38,6 +39,52 @@ interface HomebridgeModuleOptions extends BaseFixtureOptions {
|
|
|
38
39
|
withMockInfrastructure?: boolean;
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* A verified container service covering every allocatable zone, so a fixture
|
|
44
|
+
* can reach `module generate`.
|
|
45
|
+
*
|
|
46
|
+
* Extracted rather than copied: generation refuses without infrastructure, so
|
|
47
|
+
* every fixture that generates needs this, and a second inline copy would drift
|
|
48
|
+
* from the first the moment `containerServices` gains a column.
|
|
49
|
+
*/
|
|
50
|
+
async function insertMockContainerService(dbPath: string): Promise<void> {
|
|
51
|
+
const { getDb } = await import('../db/client');
|
|
52
|
+
const { containerServices } = await import('../db/schema');
|
|
53
|
+
const originalDbPath = process.env.CELILO_DB_PATH;
|
|
54
|
+
process.env.CELILO_DB_PATH = dbPath;
|
|
55
|
+
try {
|
|
56
|
+
const db = getDb();
|
|
57
|
+
await db.insert(containerServices).values({
|
|
58
|
+
id: 'test-proxmox',
|
|
59
|
+
serviceId: 'test-proxmox',
|
|
60
|
+
name: 'Test Proxmox',
|
|
61
|
+
providerName: 'proxmox',
|
|
62
|
+
zones: ['internal', 'dmz', 'app', 'secure'],
|
|
63
|
+
apiCredentialsEncrypted: JSON.stringify({
|
|
64
|
+
encryptedValue: 'dummy',
|
|
65
|
+
iv: 'dummy',
|
|
66
|
+
authTag: 'dummy',
|
|
67
|
+
}),
|
|
68
|
+
providerConfig: {
|
|
69
|
+
default_target_node: 'pve',
|
|
70
|
+
lxc_template: 'local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst',
|
|
71
|
+
storage: 'local-lvm',
|
|
72
|
+
},
|
|
73
|
+
verified: true,
|
|
74
|
+
verifiedAt: new Date(),
|
|
75
|
+
verificationError: null,
|
|
76
|
+
createdAt: new Date(),
|
|
77
|
+
updatedAt: new Date(),
|
|
78
|
+
});
|
|
79
|
+
} finally {
|
|
80
|
+
if (originalDbPath) {
|
|
81
|
+
process.env.CELILO_DB_PATH = originalDbPath;
|
|
82
|
+
} else {
|
|
83
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
41
88
|
/**
|
|
42
89
|
* Homebridge module fixture result
|
|
43
90
|
*/
|
|
@@ -83,41 +130,7 @@ export async function homebridgeModule(
|
|
|
83
130
|
|
|
84
131
|
// Setup mock infrastructure if requested (needed for generation)
|
|
85
132
|
if (options.withMockInfrastructure) {
|
|
86
|
-
|
|
87
|
-
const { containerServices } = await import('../db/schema');
|
|
88
|
-
const originalDbPath = process.env.CELILO_DB_PATH;
|
|
89
|
-
process.env.CELILO_DB_PATH = ctx.dbPath;
|
|
90
|
-
try {
|
|
91
|
-
const db = getDb();
|
|
92
|
-
await db.insert(containerServices).values({
|
|
93
|
-
id: 'test-proxmox',
|
|
94
|
-
serviceId: 'test-proxmox',
|
|
95
|
-
name: 'Test Proxmox',
|
|
96
|
-
providerName: 'proxmox',
|
|
97
|
-
zones: ['internal', 'dmz', 'app', 'secure'],
|
|
98
|
-
apiCredentialsEncrypted: JSON.stringify({
|
|
99
|
-
encryptedValue: 'dummy',
|
|
100
|
-
iv: 'dummy',
|
|
101
|
-
authTag: 'dummy',
|
|
102
|
-
}),
|
|
103
|
-
providerConfig: {
|
|
104
|
-
default_target_node: 'pve',
|
|
105
|
-
lxc_template: 'local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst',
|
|
106
|
-
storage: 'local-lvm',
|
|
107
|
-
},
|
|
108
|
-
verified: true,
|
|
109
|
-
verifiedAt: new Date(),
|
|
110
|
-
verificationError: null,
|
|
111
|
-
createdAt: new Date(),
|
|
112
|
-
updatedAt: new Date(),
|
|
113
|
-
});
|
|
114
|
-
} finally {
|
|
115
|
-
if (originalDbPath) {
|
|
116
|
-
process.env.CELILO_DB_PATH = originalDbPath;
|
|
117
|
-
} else {
|
|
118
|
-
process.env.CELILO_DB_PATH = undefined;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
133
|
+
await insertMockContainerService(ctx.dbPath);
|
|
121
134
|
}
|
|
122
135
|
|
|
123
136
|
// Create CLI context with integration test env
|
|
@@ -195,6 +208,105 @@ export async function homebridgeModule(
|
|
|
195
208
|
};
|
|
196
209
|
}
|
|
197
210
|
|
|
211
|
+
/**
|
|
212
|
+
* `dnsmasq-dhcp` fixture, for the golden comparison.
|
|
213
|
+
*
|
|
214
|
+
* ⚠️ IMPORTS THE REAL MODULE, `../../modules/dnsmasq-dhcp`, and not a copy under
|
|
215
|
+
* `test-fixtures/modules/`. That is the whole point of it.
|
|
216
|
+
*
|
|
217
|
+
* `homebridgeModule` imports a copy, and the copy has drifted 29 lines in the
|
|
218
|
+
* manifest and 6 in the Terraform template. So the golden gate compares
|
|
219
|
+
* generated-from-a-copy against golden-from-the-same-copy: both sides move
|
|
220
|
+
* together, and the shipped module is on neither. Worse, the copy is frozen at
|
|
221
|
+
* `version: 1.0.0` with the container-infrastructure variables `required: true`
|
|
222
|
+
* — the pre-fix state of a real bug (marking them required made homebridge
|
|
223
|
+
* container-only). The gate still passes against that shape and cannot notice
|
|
224
|
+
* it returning. Tracked as celilo#1084.
|
|
225
|
+
*
|
|
226
|
+
* Pointing at the shipped module means this golden fails when the generator's
|
|
227
|
+
* output for the shipped module changes, which is the only version worth having.
|
|
228
|
+
*/
|
|
229
|
+
export interface DnsmasqDhcpModuleFixture {
|
|
230
|
+
cli: CLIContext;
|
|
231
|
+
moduleId: string;
|
|
232
|
+
config: Record<string, unknown>;
|
|
233
|
+
context: IntegrationTestContext;
|
|
234
|
+
cleanup: () => Promise<void>;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export interface DnsmasqDhcpModuleOptions extends BaseFixtureOptions {
|
|
238
|
+
/** Seed a container service, without which generation has no infrastructure. */
|
|
239
|
+
withMockInfrastructure?: boolean;
|
|
240
|
+
/** Set the `network.internal.*` system config the templates resolve against. */
|
|
241
|
+
withSystemConfig?: boolean;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function dnsmasqDhcpModule(
|
|
245
|
+
options: DnsmasqDhcpModuleOptions = {},
|
|
246
|
+
): Promise<DnsmasqDhcpModuleFixture> {
|
|
247
|
+
const ctx = await setupIntegrationTest();
|
|
248
|
+
|
|
249
|
+
if (options.withMockInfrastructure) {
|
|
250
|
+
await insertMockContainerService(ctx.dbPath);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const cli = await CLIContext.create('src/cli/index.ts', {
|
|
254
|
+
CELILO_DB_PATH: ctx.dbPath,
|
|
255
|
+
CELILO_DATA_DIR: ctx.dataDir,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const moduleId = 'dnsmasq-dhcp';
|
|
259
|
+
|
|
260
|
+
if (options.withSystemConfig) {
|
|
261
|
+
// `internal` only — it is the single zone this module declares, and adding
|
|
262
|
+
// others would put values in the golden that nothing generated from.
|
|
263
|
+
(await cli.run('system config set network.bridge vmbr0')).expectSuccess();
|
|
264
|
+
(await cli.run('system config set network.internal.vlan 1')).expectSuccess();
|
|
265
|
+
(await cli.run('system config set network.internal.gateway 10.99.1.1')).expectSuccess();
|
|
266
|
+
(await cli.run('system config set network.internal.subnet 10.99.1.0/24')).expectSuccess();
|
|
267
|
+
(
|
|
268
|
+
await cli.run(
|
|
269
|
+
'system config set ssh.public_key ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@celilo',
|
|
270
|
+
)
|
|
271
|
+
).expectSuccess();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Read from `test-fixtures/test-values.yml` rather than written out here, so
|
|
275
|
+
// that file's entry for this module is load-bearing rather than decorative. A
|
|
276
|
+
// golden generated from values nothing else references would drift from the
|
|
277
|
+
// canonical set with nothing noticing.
|
|
278
|
+
//
|
|
279
|
+
// That entry carries EVERY `required: true` variable, including the ones with
|
|
280
|
+
// defaults: celilo asks for a required variable whether or not it has one,
|
|
281
|
+
// and an unanswered ask has no headless answer, so the command blocks until
|
|
282
|
+
// its timeout naming no variable.
|
|
283
|
+
const config = await getModuleTestConfig(moduleId);
|
|
284
|
+
if (Object.keys(config).length === 0) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`test-values.yml carries no entry for '${moduleId}', so this fixture would generate from an empty config and the golden would record that. Add one.`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
(await cli.run(`module import ../../modules/${moduleId}`)).expectSuccess();
|
|
291
|
+
|
|
292
|
+
for (const [key, value] of Object.entries(config)) {
|
|
293
|
+
// An array has to reach the CLI as JSON; a scalar is quoted as-is.
|
|
294
|
+
const encoded = Array.isArray(value) ? `'${JSON.stringify(value)}'` : `"${value}"`;
|
|
295
|
+
(await cli.run(`module config set ${moduleId} ${key} ${encoded}`)).expectSuccess();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (options.generated) {
|
|
299
|
+
(await cli.run(`module generate ${moduleId}`)).expectSuccess();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const cleanup = async () => {
|
|
303
|
+
await cli.dispose();
|
|
304
|
+
await ctx.cleanup();
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
return { cli, moduleId, config, context: ctx, cleanup };
|
|
308
|
+
}
|
|
309
|
+
|
|
198
310
|
/**
|
|
199
311
|
* Caddy module fixture options
|
|
200
312
|
*/
|