@celilo/cli 1.11.0 → 1.13.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 +2 -1
- package/CELILO_SUBSYSTEMS.md +17 -2
- package/package.json +3 -3
- package/src/cli/commands/alerts-list.ts +16 -1
- package/src/cli/commands/backup-list.test.ts +82 -1
- package/src/cli/commands/backup-list.ts +113 -4
- package/src/cli/commands/console.ts +122 -0
- package/src/cli/commands/module-list.ts +3 -41
- package/src/cli/commands/module-publish.ts +2 -0
- package/src/cli/completion.ts +5 -0
- package/src/cli/index.ts +25 -1
- package/src/console/closure.test.ts +246 -0
- package/src/console/closure.ts +208 -0
- package/src/console/control-plane-boundary.test.ts +75 -0
- package/src/console/projection.test.ts +231 -0
- package/src/console/projection.ts +327 -0
- package/src/db/schema.ts +19 -14
- package/src/hooks/broker.test.ts +4 -6
- package/src/hooks/executor.test.ts +85 -4
- package/src/hooks/executor.ts +164 -9
- package/src/hooks/hook-jail-unreachability.test.ts +173 -0
- package/src/hooks/hook-state-dir.test.ts +14 -2
- package/src/hooks/hook-timeout.test.ts +2 -4
- package/src/hooks/hook-trespass.test.ts +50 -5
- package/src/hooks/jail.test.ts +370 -0
- package/src/hooks/jail.ts +491 -0
- package/src/hooks/mount-set.ts +24 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +59 -0
- package/src/manifest/icon-schema.test.ts +48 -0
- package/src/manifest/schema.ts +92 -0
- package/src/manifest/validate.test.ts +142 -0
- package/src/manifest/validate.ts +101 -0
- package/src/module/import.test.ts +116 -0
- package/src/module/import.ts +73 -1
- package/src/module/packaging/audit.ts +103 -1
- package/src/module/packaging/classify-module-path.test.ts +36 -0
- package/src/module/packaging/package-rules.ts +18 -0
- package/src/policy/capability-shape-baseline.ts +8 -0
- package/src/policy/module-business-baseline.ts +12 -0
- package/src/registry/client.ts +9 -0
- package/src/services/alerting/observed-health.ts +71 -0
- package/src/services/api-principal-enrolment.test.ts +179 -0
- package/src/services/api-principal-enrolment.ts +103 -0
- package/src/services/audit/backups.ts +10 -1
- package/src/services/backup-metadata.ts +19 -11
- package/src/services/consumer-cleanup.ts +31 -5
- package/src/services/instance-ops.test.ts +302 -0
- package/src/services/instance-ops.ts +292 -0
- package/src/services/module-instances.test.ts +428 -42
- package/src/services/module-instances.ts +219 -26
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import {
|
|
4
|
+
lstat,
|
|
5
|
+
mkdir,
|
|
6
|
+
mkdtemp,
|
|
7
|
+
readFile,
|
|
8
|
+
readlink,
|
|
9
|
+
rm,
|
|
10
|
+
symlink,
|
|
11
|
+
writeFile,
|
|
12
|
+
} from 'node:fs/promises';
|
|
3
13
|
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
14
|
+
import { join, resolve } from 'node:path';
|
|
5
15
|
import { type DbClient, createDbClient } from '../db/client';
|
|
6
|
-
import {
|
|
16
|
+
import { auditModule } from '../module/packaging/audit';
|
|
17
|
+
import { computeFileChecksum } from '../module/packaging/checksum';
|
|
18
|
+
import {
|
|
19
|
+
buildInstanceLinkFarm,
|
|
20
|
+
deriveInstanceModuleId,
|
|
21
|
+
ownedSystemModuleIds,
|
|
22
|
+
planInstanceLinks,
|
|
23
|
+
submoduleSourcePath,
|
|
24
|
+
verifyInstanceLinks,
|
|
25
|
+
} from './module-instances';
|
|
7
26
|
|
|
8
27
|
describe('deriveInstanceModuleId', () => {
|
|
9
28
|
test('is deterministic, so an instantiate is safe to retry', () => {
|
|
@@ -13,30 +32,30 @@ describe('deriveInstanceModuleId', () => {
|
|
|
13
32
|
});
|
|
14
33
|
|
|
15
34
|
test('produces a valid kebab-case module id from an opaque key', () => {
|
|
16
|
-
// celilo never interprets the key, so it may be anything at all.
|
|
35
|
+
// D2: celilo never interprets the key, so it may be anything at all.
|
|
17
36
|
const id = deriveInstanceModuleId('byoi', 'lab', 'Sally Smith <sally@example.com> 🎉');
|
|
18
37
|
expect(id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
|
|
19
38
|
expect(id.startsWith('byoi-lab-')).toBe(true);
|
|
20
39
|
});
|
|
21
40
|
|
|
22
41
|
test('two parents using the same key get different ids', () => {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
);
|
|
42
|
+
const a = deriveInstanceModuleId('byoi', 'lab', 'shared-key');
|
|
43
|
+
const b = deriveInstanceModuleId('forgejo', 'lab', 'shared-key');
|
|
44
|
+
expect(a).not.toBe(b);
|
|
26
45
|
});
|
|
27
46
|
|
|
28
47
|
test('one parent reusing a key across two submodules gets different ids', () => {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
);
|
|
48
|
+
const a = deriveInstanceModuleId('forgejo', 'runner', 'proj-1');
|
|
49
|
+
const b = deriveInstanceModuleId('forgejo', 'builder', 'proj-1');
|
|
50
|
+
expect(a).not.toBe(b);
|
|
32
51
|
});
|
|
33
52
|
|
|
34
53
|
// Without a separator in the hash input, ("a", "b-c") and ("a-b", "c") hash
|
|
35
54
|
// the same string. The kind of ambiguity that never shows up until it does.
|
|
36
55
|
test('a hyphen moving between parent and submodule changes the id', () => {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
);
|
|
56
|
+
const a = deriveInstanceModuleId('a', 'b-c', 'k');
|
|
57
|
+
const b = deriveInstanceModuleId('a-b', 'c', 'k');
|
|
58
|
+
expect(a).not.toBe(b);
|
|
40
59
|
});
|
|
41
60
|
|
|
42
61
|
test('a different key changes the id', () => {
|
|
@@ -46,7 +65,212 @@ describe('deriveInstanceModuleId', () => {
|
|
|
46
65
|
});
|
|
47
66
|
});
|
|
48
67
|
|
|
49
|
-
describe('
|
|
68
|
+
describe('planInstanceLinks', () => {
|
|
69
|
+
test('links the authored subtrees', () => {
|
|
70
|
+
expect(planInstanceLinks(['ansible', 'terraform', 'scripts', 'manifest.yml'])).toEqual([
|
|
71
|
+
'ansible',
|
|
72
|
+
'manifest.yml',
|
|
73
|
+
'scripts',
|
|
74
|
+
'terraform',
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Linking these would put every instance's writes back into one shared
|
|
79
|
+
// directory, which is the exact failure the farm exists to prevent.
|
|
80
|
+
test('never links generated/ or the derived hook outputs', () => {
|
|
81
|
+
const planned = planInstanceLinks([
|
|
82
|
+
'ansible',
|
|
83
|
+
'generated',
|
|
84
|
+
'screenshots',
|
|
85
|
+
'cookies.json',
|
|
86
|
+
'checksums.json',
|
|
87
|
+
]);
|
|
88
|
+
expect(planned).toEqual(['ansible']);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('the instance symlink farm', () => {
|
|
93
|
+
let root: string;
|
|
94
|
+
let submodulePath: string;
|
|
95
|
+
let instancePath: string;
|
|
96
|
+
|
|
97
|
+
beforeEach(async () => {
|
|
98
|
+
root = await mkdtemp(join(tmpdir(), 'celilo-instances-'));
|
|
99
|
+
submodulePath = submoduleSourcePath(join(root, 'forgejo'), 'runner');
|
|
100
|
+
instancePath = join(root, 'forgejo-runner-abc123def456');
|
|
101
|
+
|
|
102
|
+
await mkdir(join(submodulePath, 'ansible'), { recursive: true });
|
|
103
|
+
await mkdir(join(submodulePath, 'terraform'), { recursive: true });
|
|
104
|
+
await writeFile(join(submodulePath, 'manifest.yml'), 'id: runner\n');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
afterEach(async () => {
|
|
108
|
+
await rm(root, { recursive: true, force: true });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('links the source and gives the instance its own real generated/', async () => {
|
|
112
|
+
const linked = await buildInstanceLinkFarm({ instancePath, submodulePath });
|
|
113
|
+
|
|
114
|
+
expect(linked).toEqual(['ansible', 'manifest.yml', 'terraform']);
|
|
115
|
+
expect((await lstat(join(instancePath, 'ansible'))).isSymbolicLink()).toBe(true);
|
|
116
|
+
expect((await lstat(join(instancePath, 'generated'))).isSymbolicLink()).toBe(false);
|
|
117
|
+
expect((await lstat(join(instancePath, 'generated'))).isDirectory()).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// This is the invariant nine call sites depend on and the reason the farm
|
|
121
|
+
// exists rather than a rewritten sourcePath.
|
|
122
|
+
test('join(instancePath, "generated") is the instance own directory, not the submodule one', async () => {
|
|
123
|
+
await buildInstanceLinkFarm({ instancePath, submodulePath });
|
|
124
|
+
await writeFile(join(instancePath, 'generated', 'terraform.tfstate'), '{}');
|
|
125
|
+
|
|
126
|
+
expect(existsSync(join(submodulePath, 'generated'))).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('two instances of one submodule get separate generated/ and shared source', async () => {
|
|
130
|
+
const other = join(root, 'forgejo-runner-999888777666');
|
|
131
|
+
await buildInstanceLinkFarm({ instancePath, submodulePath });
|
|
132
|
+
await buildInstanceLinkFarm({ instancePath: other, submodulePath });
|
|
133
|
+
|
|
134
|
+
await writeFile(join(instancePath, 'generated', 'terraform.tfstate'), 'first');
|
|
135
|
+
await writeFile(join(other, 'generated', 'terraform.tfstate'), 'second');
|
|
136
|
+
|
|
137
|
+
expect(await readFile(join(instancePath, 'generated', 'terraform.tfstate'), 'utf-8')).toBe(
|
|
138
|
+
'first',
|
|
139
|
+
);
|
|
140
|
+
expect(await readFile(join(other, 'generated', 'terraform.tfstate'), 'utf-8')).toBe('second');
|
|
141
|
+
// Same authored bytes, one copy on disk.
|
|
142
|
+
expect(await readFile(join(instancePath, 'manifest.yml'), 'utf-8')).toBe('id: runner\n');
|
|
143
|
+
expect(await readFile(join(other, 'manifest.yml'), 'utf-8')).toBe('id: runner\n');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('links are relative, so the farm survives the data directory moving', async () => {
|
|
147
|
+
await buildInstanceLinkFarm({ instancePath, submodulePath });
|
|
148
|
+
const target = await readlink(join(instancePath, 'ansible'));
|
|
149
|
+
expect(target.startsWith('/')).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('is idempotent', async () => {
|
|
153
|
+
await buildInstanceLinkFarm({ instancePath, submodulePath });
|
|
154
|
+
const second = await buildInstanceLinkFarm({ instancePath, submodulePath });
|
|
155
|
+
expect(second).toEqual(['ansible', 'manifest.yml', 'terraform']);
|
|
156
|
+
expect(await readFile(join(instancePath, 'manifest.yml'), 'utf-8')).toBe('id: runner\n');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// A submodule gaining a directory in a later version would otherwise leave
|
|
160
|
+
// every existing instance stale, which is why this converges rather than creates.
|
|
161
|
+
test('picks up a subtree the submodule gained since the instance was built', async () => {
|
|
162
|
+
await buildInstanceLinkFarm({ instancePath, submodulePath });
|
|
163
|
+
await mkdir(join(submodulePath, 'base-module-aspect'), { recursive: true });
|
|
164
|
+
|
|
165
|
+
const linked = await buildInstanceLinkFarm({ instancePath, submodulePath });
|
|
166
|
+
|
|
167
|
+
expect(linked).toContain('base-module-aspect');
|
|
168
|
+
expect(existsSync(join(instancePath, 'base-module-aspect'))).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('removes a link whose subtree the submodule dropped', async () => {
|
|
172
|
+
await buildInstanceLinkFarm({ instancePath, submodulePath });
|
|
173
|
+
await rm(join(submodulePath, 'terraform'), { recursive: true });
|
|
174
|
+
|
|
175
|
+
await buildInstanceLinkFarm({ instancePath, submodulePath });
|
|
176
|
+
|
|
177
|
+
await expect(lstat(join(instancePath, 'terraform'))).rejects.toThrow();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('repairs a link that points somewhere else', async () => {
|
|
181
|
+
await buildInstanceLinkFarm({ instancePath, submodulePath });
|
|
182
|
+
const elsewhere = join(root, 'somebody-elses-source');
|
|
183
|
+
await mkdir(elsewhere, { recursive: true });
|
|
184
|
+
await rm(join(instancePath, 'ansible'), { force: true });
|
|
185
|
+
await symlink(elsewhere, join(instancePath, 'ansible'));
|
|
186
|
+
|
|
187
|
+
await buildInstanceLinkFarm({ instancePath, submodulePath });
|
|
188
|
+
|
|
189
|
+
expect(resolve(instancePath, await readlink(join(instancePath, 'ansible')))).toBe(
|
|
190
|
+
resolve(submodulePath, 'ansible'),
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// existsSync follows links, so a dangling one reports absent while symlink()
|
|
195
|
+
// still refuses to overwrite it. Worth a test because it fails confusingly.
|
|
196
|
+
test('replaces a dangling link rather than failing on it', async () => {
|
|
197
|
+
await mkdir(instancePath, { recursive: true });
|
|
198
|
+
await symlink('../gone-away', join(instancePath, 'ansible'));
|
|
199
|
+
|
|
200
|
+
await buildInstanceLinkFarm({ instancePath, submodulePath });
|
|
201
|
+
|
|
202
|
+
expect(existsSync(join(instancePath, 'ansible'))).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('refuses to build against a submodule source that is not there', async () => {
|
|
206
|
+
await expect(
|
|
207
|
+
buildInstanceLinkFarm({ instancePath, submodulePath: join(root, 'nope') }),
|
|
208
|
+
).rejects.toThrow(/does not exist/);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe('verifyInstanceLinks', () => {
|
|
213
|
+
let root: string;
|
|
214
|
+
let submodulePath: string;
|
|
215
|
+
let instancePath: string;
|
|
216
|
+
|
|
217
|
+
beforeEach(async () => {
|
|
218
|
+
root = await mkdtemp(join(tmpdir(), 'celilo-verify-'));
|
|
219
|
+
submodulePath = submoduleSourcePath(join(root, 'forgejo'), 'runner');
|
|
220
|
+
instancePath = join(root, 'forgejo-runner-abc123def456');
|
|
221
|
+
await mkdir(join(submodulePath, 'ansible'), { recursive: true });
|
|
222
|
+
await writeFile(join(submodulePath, 'manifest.yml'), 'id: runner\n');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
afterEach(async () => {
|
|
226
|
+
await rm(root, { recursive: true, force: true });
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('a freshly built farm is clean', async () => {
|
|
230
|
+
await buildInstanceLinkFarm({ instancePath, submodulePath });
|
|
231
|
+
expect(await verifyInstanceLinks({ instancePath, submodulePath })).toEqual([]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// The one integrity property unique to an instance. Nothing else checks it,
|
|
235
|
+
// because an instance carries no authored bytes of its own to checksum.
|
|
236
|
+
test('reports a link repointed at somebody else source', async () => {
|
|
237
|
+
await buildInstanceLinkFarm({ instancePath, submodulePath });
|
|
238
|
+
const elsewhere = join(root, 'attacker');
|
|
239
|
+
await mkdir(elsewhere, { recursive: true });
|
|
240
|
+
await rm(join(instancePath, 'ansible'), { force: true });
|
|
241
|
+
await symlink(elsewhere, join(instancePath, 'ansible'));
|
|
242
|
+
|
|
243
|
+
const violations = await verifyInstanceLinks({ instancePath, submodulePath });
|
|
244
|
+
|
|
245
|
+
expect(violations).toHaveLength(1);
|
|
246
|
+
expect(violations[0]?.entry).toBe('ansible');
|
|
247
|
+
expect(violations[0]?.actual).toBe(resolve(elsewhere));
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('reports a missing link', async () => {
|
|
251
|
+
await buildInstanceLinkFarm({ instancePath, submodulePath });
|
|
252
|
+
await rm(join(instancePath, 'manifest.yml'), { force: true });
|
|
253
|
+
|
|
254
|
+
const violations = await verifyInstanceLinks({ instancePath, submodulePath });
|
|
255
|
+
|
|
256
|
+
expect(violations).toHaveLength(1);
|
|
257
|
+
expect(violations[0]?.entry).toBe('manifest.yml');
|
|
258
|
+
expect(violations[0]?.actual).toBeNull();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('reports a real file standing where a link belongs', async () => {
|
|
262
|
+
await buildInstanceLinkFarm({ instancePath, submodulePath });
|
|
263
|
+
await rm(join(instancePath, 'manifest.yml'), { force: true });
|
|
264
|
+
await writeFile(join(instancePath, 'manifest.yml'), 'id: not-the-real-one\n');
|
|
265
|
+
|
|
266
|
+
const violations = await verifyInstanceLinks({ instancePath, submodulePath });
|
|
267
|
+
|
|
268
|
+
expect(violations).toHaveLength(1);
|
|
269
|
+
expect(violations[0]?.entry).toBe('manifest.yml');
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe('module_instances storage (D2, D5)', () => {
|
|
50
274
|
let root: string;
|
|
51
275
|
let db: DbClient;
|
|
52
276
|
|
|
@@ -56,21 +280,17 @@ describe('module_instances storage', () => {
|
|
|
56
280
|
[id, id, id],
|
|
57
281
|
);
|
|
58
282
|
|
|
59
|
-
const addInstance = (parent: string, submodule: string, key: string) =>
|
|
60
|
-
const id = deriveInstanceModuleId(parent, submodule, key);
|
|
61
|
-
addModule(id);
|
|
283
|
+
const addInstance = (parent: string, submodule: string, key: string) =>
|
|
62
284
|
db.$client.run(
|
|
63
285
|
'INSERT INTO module_instances (module_id,parent_id,submodule,instance_key) VALUES (?,?,?,?)',
|
|
64
|
-
[
|
|
286
|
+
[deriveInstanceModuleId(parent, submodule, key), parent, submodule, key],
|
|
65
287
|
);
|
|
66
|
-
return id;
|
|
67
|
-
};
|
|
68
288
|
|
|
69
289
|
const instanceCount = () =>
|
|
70
290
|
(db.$client.query('SELECT count(*) n FROM module_instances').get() as { n: number }).n;
|
|
71
291
|
|
|
72
292
|
beforeEach(async () => {
|
|
73
|
-
root = await mkdtemp(join(tmpdir(), 'celilo-
|
|
293
|
+
root = await mkdtemp(join(tmpdir(), 'celilo-instance-db-'));
|
|
74
294
|
db = createDbClient({ path: join(root, 'celilo.db') });
|
|
75
295
|
for (const id of ['byoi', 'forgejo']) addModule(id);
|
|
76
296
|
});
|
|
@@ -87,49 +307,150 @@ describe('module_instances storage', () => {
|
|
|
87
307
|
expect(columns.map((c) => c.name)).toEqual(['parent_id', 'submodule', 'instance_key']);
|
|
88
308
|
});
|
|
89
309
|
|
|
310
|
+
// The spec scenario: identity is the owner, module and id together.
|
|
90
311
|
test('two parents may use the same instance key', () => {
|
|
312
|
+
addModule(deriveInstanceModuleId('byoi', 'lab', 'shared'));
|
|
313
|
+
addModule(deriveInstanceModuleId('forgejo', 'lab', 'shared'));
|
|
314
|
+
|
|
91
315
|
addInstance('byoi', 'lab', 'shared');
|
|
92
316
|
expect(() => addInstance('forgejo', 'lab', 'shared')).not.toThrow();
|
|
93
317
|
expect(instanceCount()).toBe(2);
|
|
94
318
|
});
|
|
95
319
|
|
|
96
320
|
test('one parent cannot reuse a key for the same submodule', () => {
|
|
321
|
+
addModule(deriveInstanceModuleId('byoi', 'lab', 'dup'));
|
|
97
322
|
addInstance('byoi', 'lab', 'dup');
|
|
98
|
-
expect(() =>
|
|
99
|
-
db.$client.run(
|
|
100
|
-
'INSERT INTO module_instances (module_id,parent_id,submodule,instance_key) VALUES (?,?,?,?)',
|
|
101
|
-
['byoi-lab-other', 'byoi', 'lab', 'dup'],
|
|
102
|
-
),
|
|
103
|
-
).toThrow(/UNIQUE constraint failed/);
|
|
323
|
+
expect(() => addInstance('byoi', 'lab', 'dup')).toThrow(/UNIQUE constraint failed/);
|
|
104
324
|
});
|
|
105
325
|
|
|
106
|
-
//
|
|
326
|
+
// D5's containment, at the storage layer. The cascade is not the whole of the
|
|
327
|
+
// removal path (that refuses first and destroys deliberately), but a row must
|
|
328
|
+
// never outlive the parent it names.
|
|
107
329
|
test('removing a parent removes its instance rows and leaves other parents alone', () => {
|
|
330
|
+
addModule(deriveInstanceModuleId('byoi', 'lab', 'a'));
|
|
331
|
+
addModule(deriveInstanceModuleId('forgejo', 'runner', 'b'));
|
|
108
332
|
addInstance('byoi', 'lab', 'a');
|
|
109
333
|
addInstance('forgejo', 'runner', 'b');
|
|
110
334
|
expect(instanceCount()).toBe(2);
|
|
111
335
|
|
|
112
336
|
db.$client.run("DELETE FROM modules WHERE id='byoi'");
|
|
113
337
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
).toEqual([{ parent_id: 'forgejo' }]);
|
|
338
|
+
const left = db.$client.query('SELECT parent_id FROM module_instances').all() as Array<{
|
|
339
|
+
parent_id: string;
|
|
340
|
+
}>;
|
|
341
|
+
expect(left).toEqual([{ parent_id: 'forgejo' }]);
|
|
119
342
|
});
|
|
120
343
|
|
|
121
344
|
test('an instance defaults to pending with no failure recorded', () => {
|
|
345
|
+
addModule(deriveInstanceModuleId('byoi', 'lab', 'fresh'));
|
|
122
346
|
addInstance('byoi', 'lab', 'fresh');
|
|
123
347
|
const row = db.$client
|
|
124
348
|
.query('SELECT state, failure_reason, retryable FROM module_instances')
|
|
125
|
-
.get() as {
|
|
349
|
+
.get() as {
|
|
350
|
+
state: string;
|
|
351
|
+
failure_reason: string | null;
|
|
352
|
+
retryable: number | null;
|
|
353
|
+
};
|
|
126
354
|
expect(row.state).toBe('pending');
|
|
127
355
|
expect(row.failure_reason).toBeNull();
|
|
128
356
|
expect(row.retryable).toBeNull();
|
|
129
357
|
});
|
|
130
358
|
});
|
|
131
359
|
|
|
132
|
-
describe('
|
|
360
|
+
describe('auditing an instance (D4, task 3.8)', () => {
|
|
361
|
+
let root: string;
|
|
362
|
+
let db: DbClient;
|
|
363
|
+
let parentPath: string;
|
|
364
|
+
let submodulePath: string;
|
|
365
|
+
let instanceId: string;
|
|
366
|
+
let instancePath: string;
|
|
367
|
+
|
|
368
|
+
/** Baseline the parent's own tree, so its audit is genuinely clean. */
|
|
369
|
+
async function baselineParent() {
|
|
370
|
+
const checksums: Record<string, string> = {};
|
|
371
|
+
for (const rel of ['manifest.yml', 'submodules/runner/manifest.yml']) {
|
|
372
|
+
checksums[rel] = await computeFileChecksum(join(parentPath, rel));
|
|
373
|
+
}
|
|
374
|
+
db.$client.run(
|
|
375
|
+
"INSERT INTO module_integrity (module_id, checksums, version) VALUES ('forgejo', ?, '1.0.0')",
|
|
376
|
+
[JSON.stringify(checksums)],
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
beforeEach(async () => {
|
|
381
|
+
root = await mkdtemp(join(tmpdir(), 'celilo-instance-audit-'));
|
|
382
|
+
db = createDbClient({ path: join(root, 'celilo.db') });
|
|
383
|
+
|
|
384
|
+
parentPath = join(root, 'modules', 'forgejo');
|
|
385
|
+
submodulePath = submoduleSourcePath(parentPath, 'runner');
|
|
386
|
+
await mkdir(submodulePath, { recursive: true });
|
|
387
|
+
await writeFile(join(parentPath, 'manifest.yml'), 'id: forgejo\n');
|
|
388
|
+
await writeFile(join(submodulePath, 'manifest.yml'), 'id: runner\n');
|
|
389
|
+
|
|
390
|
+
instanceId = deriveInstanceModuleId('forgejo', 'runner', 'proj-1');
|
|
391
|
+
instancePath = join(root, 'modules', instanceId);
|
|
392
|
+
await buildInstanceLinkFarm({ instancePath, submodulePath });
|
|
393
|
+
|
|
394
|
+
for (const [id, path] of [
|
|
395
|
+
['forgejo', parentPath],
|
|
396
|
+
[instanceId, instancePath],
|
|
397
|
+
] as const) {
|
|
398
|
+
db.$client.run(
|
|
399
|
+
"INSERT INTO modules (id,name,version,manifest_data,source_path) VALUES (?,?,'1.0.0','{}',?)",
|
|
400
|
+
[id, id, path],
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
db.$client.run(
|
|
404
|
+
'INSERT INTO module_instances (module_id,parent_id,submodule,instance_key) VALUES (?,?,?,?)',
|
|
405
|
+
[instanceId, 'forgejo', 'runner', 'proj-1'],
|
|
406
|
+
);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
afterEach(async () => {
|
|
410
|
+
db.$client.close();
|
|
411
|
+
await rm(root, { recursive: true, force: true });
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// The failure this exists to prevent: forty instances each reporting
|
|
415
|
+
// "No integrity data found" on a fleet that is entirely healthy.
|
|
416
|
+
test('an instance does not report a missing baseline', async () => {
|
|
417
|
+
await baselineParent();
|
|
418
|
+
const result = await auditModule(instanceId, db);
|
|
419
|
+
|
|
420
|
+
expect(result.error).toBeUndefined();
|
|
421
|
+
expect(result.violations).toEqual([]);
|
|
422
|
+
expect(result.success).toBe(true);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// The one integrity property unique to an instance. Nothing else checks it,
|
|
426
|
+
// and every checksum in the fleet still reconciles while it is wrong.
|
|
427
|
+
test('reports a link repointed at another module source', async () => {
|
|
428
|
+
await baselineParent();
|
|
429
|
+
const elsewhere = join(root, 'modules', 'somebody-else');
|
|
430
|
+
await mkdir(elsewhere, { recursive: true });
|
|
431
|
+
await rm(join(instancePath, 'manifest.yml'), { force: true });
|
|
432
|
+
await symlink(elsewhere, join(instancePath, 'manifest.yml'));
|
|
433
|
+
|
|
434
|
+
const result = await auditModule(instanceId, db);
|
|
435
|
+
|
|
436
|
+
expect(result.success).toBe(false);
|
|
437
|
+
expect(
|
|
438
|
+
result.violations.some((v) => v.message.includes('running source it does not own')),
|
|
439
|
+
).toBe(true);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// An instance runs its parent's bytes, so a clean instance row must not read
|
|
443
|
+
// as evidence when the parent itself is unverified.
|
|
444
|
+
test("carries the parent's verdict rather than reporting clean beneath it", async () => {
|
|
445
|
+
// No baseline for the parent at all: its own audit cannot pass.
|
|
446
|
+
const result = await auditModule(instanceId, db);
|
|
447
|
+
|
|
448
|
+
expect(result.success).toBe(false);
|
|
449
|
+
expect(result.violations.some((v) => v.message.includes("belong to 'forgejo'"))).toBe(true);
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
describe('ownedSystemModuleIds (allow-list for hook-process-boundary D12)', () => {
|
|
133
454
|
let root: string;
|
|
134
455
|
let db: DbClient;
|
|
135
456
|
|
|
@@ -160,12 +481,8 @@ describe('ownedSystemModuleIds', () => {
|
|
|
160
481
|
await rm(root, { recursive: true, force: true });
|
|
161
482
|
});
|
|
162
483
|
|
|
163
|
-
|
|
164
|
-
// correct on its own rather than merely inert: an empty table gives every
|
|
165
|
-
// module itself, so a caller reaches only what it provisioned.
|
|
166
|
-
test('with no instances in existence, a module owns exactly itself', () => {
|
|
484
|
+
test('a module owning no instances gets only itself', () => {
|
|
167
485
|
expect(ownedSystemModuleIds('caddy', db)).toEqual(['caddy']);
|
|
168
|
-
expect(ownedSystemModuleIds('forgejo', db)).toEqual(['forgejo']);
|
|
169
486
|
});
|
|
170
487
|
|
|
171
488
|
test('a parent gets itself and every instance it owns', () => {
|
|
@@ -185,8 +502,8 @@ describe('ownedSystemModuleIds', () => {
|
|
|
185
502
|
expect(ownedSystemModuleIds('caddy', db)).toEqual(['caddy']);
|
|
186
503
|
});
|
|
187
504
|
|
|
188
|
-
//
|
|
189
|
-
//
|
|
505
|
+
// The whole point: nesting is refused at import, so ownership is one level
|
|
506
|
+
// deep and this stays one indexed lookup rather than a walk.
|
|
190
507
|
test('an instance owns nobody, so the answer is one level and terminates', () => {
|
|
191
508
|
const instance = addInstance('forgejo', 'runner', 'proj-1');
|
|
192
509
|
expect(ownedSystemModuleIds(instance, db)).toEqual([instance]);
|
|
@@ -196,3 +513,72 @@ describe('ownedSystemModuleIds', () => {
|
|
|
196
513
|
expect(ownedSystemModuleIds('never-installed', db)).toEqual(['never-installed']);
|
|
197
514
|
});
|
|
198
515
|
});
|
|
516
|
+
|
|
517
|
+
describe('a hook writing into its module root stays in its own instance (task 3.6)', () => {
|
|
518
|
+
let root: string;
|
|
519
|
+
let submodulePath: string;
|
|
520
|
+
let first: string;
|
|
521
|
+
let second: string;
|
|
522
|
+
|
|
523
|
+
beforeEach(async () => {
|
|
524
|
+
root = await mkdtemp(join(tmpdir(), 'celilo-hook-writes-'));
|
|
525
|
+
submodulePath = submoduleSourcePath(join(root, 'forgejo'), 'runner');
|
|
526
|
+
await mkdir(join(submodulePath, 'scripts'), { recursive: true });
|
|
527
|
+
await writeFile(join(submodulePath, 'manifest.yml'), 'id: runner\n');
|
|
528
|
+
|
|
529
|
+
first = join(root, deriveInstanceModuleId('forgejo', 'runner', 'proj-1'));
|
|
530
|
+
second = join(root, deriveInstanceModuleId('forgejo', 'runner', 'proj-2'));
|
|
531
|
+
await buildInstanceLinkFarm({ instancePath: first, submodulePath });
|
|
532
|
+
await buildInstanceLinkFarm({ instancePath: second, submodulePath });
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
afterEach(async () => {
|
|
536
|
+
await rm(root, { recursive: true, force: true });
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// This is the failure the symlink farm exists to prevent, and the reason
|
|
540
|
+
// `sourcePath` was NOT pointed at the shared submodule directory. Hooks run
|
|
541
|
+
// with the module directory as their root and write into it: browser.ts does
|
|
542
|
+
// join(modulePath, 'screenshots', key), and `module build` uses it as cwd.
|
|
543
|
+
test('two instances writing screenshots do not land on top of each other', async () => {
|
|
544
|
+
for (const [instance, content] of [
|
|
545
|
+
[first, 'first'],
|
|
546
|
+
[second, 'second'],
|
|
547
|
+
] as const) {
|
|
548
|
+
await mkdir(join(instance, 'screenshots'), { recursive: true });
|
|
549
|
+
await writeFile(join(instance, 'screenshots', 'failure.png'), content);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
expect(await readFile(join(first, 'screenshots', 'failure.png'), 'utf-8')).toBe('first');
|
|
553
|
+
expect(await readFile(join(second, 'screenshots', 'failure.png'), 'utf-8')).toBe('second');
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test('cookies.json is per instance, not shared', async () => {
|
|
557
|
+
await writeFile(join(first, 'cookies.json'), '["first"]');
|
|
558
|
+
await writeFile(join(second, 'cookies.json'), '["second"]');
|
|
559
|
+
|
|
560
|
+
expect(await readFile(join(first, 'cookies.json'), 'utf-8')).toBe('["first"]');
|
|
561
|
+
expect(await readFile(join(second, 'cookies.json'), 'utf-8')).toBe('["second"]');
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test('neither write reaches the shared submodule source', async () => {
|
|
565
|
+
await mkdir(join(first, 'screenshots'), { recursive: true });
|
|
566
|
+
await writeFile(join(first, 'screenshots', 'failure.png'), 'first');
|
|
567
|
+
await writeFile(join(first, 'cookies.json'), '["first"]');
|
|
568
|
+
|
|
569
|
+
expect(existsSync(join(submodulePath, 'screenshots'))).toBe(false);
|
|
570
|
+
expect(existsSync(join(submodulePath, 'cookies.json'))).toBe(false);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
// A converge must not sweep away what a hook wrote. `generated/` and the
|
|
574
|
+
// derived outputs are the instance's, not the farm's to manage.
|
|
575
|
+
test('rebuilding the farm leaves what the hooks wrote alone', async () => {
|
|
576
|
+
await writeFile(join(first, 'cookies.json'), '["first"]');
|
|
577
|
+
await writeFile(join(first, 'generated', 'terraform.tfstate'), '{}');
|
|
578
|
+
|
|
579
|
+
await buildInstanceLinkFarm({ instancePath: first, submodulePath });
|
|
580
|
+
|
|
581
|
+
expect(await readFile(join(first, 'cookies.json'), 'utf-8')).toBe('["first"]');
|
|
582
|
+
expect(existsSync(join(first, 'generated', 'terraform.tfstate'))).toBe(true);
|
|
583
|
+
});
|
|
584
|
+
});
|