@celilo/cli 2.0.0 → 2.2.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/CELILO_SUBSYSTEMS.md +1 -0
- package/drizzle/0031_module_config_source.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +2 -2
- package/schemas/system_config.json +2 -1
- package/src/capabilities/public-web-publish.test.ts +61 -0
- package/src/cli/commands/firewall-interface-list.test.ts +156 -7
- package/src/cli/commands/firewall-interface-list.ts +73 -7
- package/src/cli/commands/machine-add.ts +12 -55
- package/src/cli/commands/module-config.test.ts +20 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-update.test.ts +82 -0
- package/src/cli/commands/module-update.ts +14 -4
- package/src/cli/commands/monitor.ts +2 -10
- package/src/cli/commands/restore.ts +16 -6
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +4 -3
- package/src/cli/restore-migration-failure.test.ts +159 -0
- package/src/db/client.ts +5 -0
- package/src/db/migrate.test.ts +61 -135
- package/src/db/migrate.ts +7 -2
- package/src/db/schema.ts +10 -0
- package/src/hooks/broker.test.ts +106 -2
- package/src/hooks/broker.ts +91 -1
- package/src/hooks/capability-loader-firewall.test.ts +37 -0
- package/src/hooks/capability-loader.ts +16 -3
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +19 -18
- package/src/hooks/executor.ts +88 -11
- package/src/hooks/hook-jail-toolchain-reach.test.ts +79 -29
- package/src/hooks/hook-jail-unreachability.test.ts +55 -29
- package/src/hooks/hook-protocol.ts +46 -1
- package/src/hooks/hook-runner.ts +36 -0
- package/src/hooks/hook-store-proxy.test.ts +109 -0
- package/src/hooks/hook-store-proxy.ts +85 -0
- package/src/hooks/hook-store.test.ts +162 -0
- package/src/hooks/hook-store.ts +290 -0
- package/src/hooks/hook-timeout.test.ts +3 -2
- package/src/hooks/hook-trespass.test.ts +94 -14
- package/src/hooks/jail.test.ts +1 -1
- package/src/hooks/jail.ts +194 -32
- package/src/hooks/mount-set.test.ts +296 -1
- package/src/hooks/mount-set.ts +216 -13
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
- package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
- package/src/hooks/test-fixtures/store-backed.ts +47 -0
- package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
- package/src/hooks/unjailed-lint.test.ts +27 -8
- package/src/manifest/schema.ts +1 -0
- package/src/module/packaging/build.ts +70 -2
- package/src/module/web-root.ts +17 -1
- package/src/policy/fixture-capability-coverage.test.ts +322 -0
- package/src/policy/module-script-scan.test.ts +42 -1
- package/src/policy/module-script-scan.ts +275 -5
- package/src/policy/no-hand-built-ssh.test.ts +34 -1
- package/src/policy/no-swallowed-refusal.test.ts +265 -0
- package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
- package/src/registry/client.test.ts +149 -0
- package/src/registry/client.ts +203 -11
- package/src/services/alerting/coverage-source.test.ts +86 -0
- package/src/services/alerting/coverage-source.ts +11 -1
- package/src/services/alerting/format.test.ts +57 -0
- package/src/services/alerting/format.ts +24 -0
- package/src/services/alerting/run-monitor.ts +2 -2
- package/src/services/backup-create.ts +7 -7
- package/src/services/backup-envelope-roundtrip.test.ts +45 -2
- package/src/services/backup-restore.ts +8 -4
- package/src/services/bus-interview.ts +37 -14
- package/src/services/config-provenance.ts +4 -0
- package/src/services/control-plane-bootstrap.test.ts +297 -0
- package/src/services/control-plane-bootstrap.ts +223 -0
- package/src/services/control-plane-health.test.ts +66 -0
- package/src/services/control-plane-health.ts +67 -0
- package/src/services/deploy-preflight.ts +8 -2
- package/src/services/deploy-validation.test.ts +22 -0
- package/src/services/deploy-validation.ts +8 -0
- package/src/services/deployed-systems.ts +12 -0
- package/src/services/dns-discovery.test.ts +147 -0
- package/src/services/dns-discovery.ts +134 -0
- package/src/services/fleet-checks.ts +6 -2
- package/src/services/fleet-key.test.ts +66 -2
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +36 -3
- package/src/services/module-config.ts +20 -2
- package/src/services/module-deploy.dns-repoint.test.ts +187 -0
- package/src/services/module-deploy.ts +214 -1
- package/src/services/module-validator/git-hygiene.test.ts +122 -3
- package/src/services/module-validator/git-hygiene.ts +83 -14
- package/src/services/remote-access.test.ts +88 -4
- package/src/services/remote-access.ts +52 -1
- package/src/services/restore-from-file.test.ts +20 -0
- package/src/services/restore-from-file.ts +21 -6
- package/src/services/static-content-converge.test.ts +140 -2
- package/src/services/static-content-converge.ts +55 -8
- package/src/services/system-config-schema-types.ts +1 -1
- package/src/services/system-config-validator.test.ts +36 -0
- package/src/services/system-config-validator.ts +11 -0
- package/src/services/trusted-sources.test.ts +30 -0
- package/src/services/trusted-sources.ts +47 -10
- package/src/templates/generator.ts +9 -2
- package/src/variables/context.ts +16 -5
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { HookStore, HookStoreBackedMap } from '@celilo/capabilities';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory hook-owned-state stores for tests that build a HookContext
|
|
5
|
+
* without a broker. The four methods mutate the record directly; the
|
|
6
|
+
* manifest validation a real store applies lives broker-side
|
|
7
|
+
* (hook-store.ts) and has its own tests.
|
|
8
|
+
*
|
|
9
|
+
* Methods are defined non-enumerably, so the result spreads, serializes and
|
|
10
|
+
* `toEqual`s as the plain record the test wrote — the same shape a real
|
|
11
|
+
* broker-side context carries when executeHookScript frames it.
|
|
12
|
+
*/
|
|
13
|
+
export function configStore(
|
|
14
|
+
values: Record<string, unknown> = {},
|
|
15
|
+
): Record<string, unknown> & HookStore {
|
|
16
|
+
return attach(values);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function secretStore(values: Record<string, string> = {}): HookStoreBackedMap {
|
|
20
|
+
// Downcast: the caller passed Record<string, string>; attach only adds
|
|
21
|
+
// methods and never writes, so the value type is unchanged.
|
|
22
|
+
return attach(values) as HookStoreBackedMap;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function attach(values: Record<string, unknown>): Record<string, unknown> & HookStore {
|
|
26
|
+
const store: HookStore = {
|
|
27
|
+
get: async (name) => values[name] as string | undefined,
|
|
28
|
+
set: async (name, value) => {
|
|
29
|
+
values[name] = value;
|
|
30
|
+
},
|
|
31
|
+
delete: async (name) => {
|
|
32
|
+
delete values[name];
|
|
33
|
+
},
|
|
34
|
+
transaction: async (fn) => {
|
|
35
|
+
await fn(store);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
for (const [name, method] of Object.entries(store)) {
|
|
39
|
+
Object.defineProperty(values, name, {
|
|
40
|
+
value: method,
|
|
41
|
+
enumerable: false,
|
|
42
|
+
writable: true,
|
|
43
|
+
configurable: true,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return Object.assign(values, store);
|
|
47
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test fixture: a hook that exercises the hook-owned-state accessor across
|
|
3
|
+
* the process boundary — set/get round trip on both stores, delete, a
|
|
4
|
+
* committed transaction, a discarded transaction, and the undeclared-name
|
|
5
|
+
* throw the whole design exists to produce.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { defineHook } from '@celilo/capabilities';
|
|
9
|
+
|
|
10
|
+
export default defineHook({
|
|
11
|
+
hook: 'on_install',
|
|
12
|
+
requires: [],
|
|
13
|
+
handler: async (ctx) => {
|
|
14
|
+
const outputs: Record<string, unknown> = {};
|
|
15
|
+
|
|
16
|
+
// Round trip on each store.
|
|
17
|
+
await ctx.secrets.set('bot_token', 'token-value');
|
|
18
|
+
outputs.secretRoundTrip = await ctx.secrets.get('bot_token');
|
|
19
|
+
|
|
20
|
+
await ctx.config.set('public_ip', '203.0.113.7');
|
|
21
|
+
outputs.configRoundTrip = await ctx.config.get('public_ip');
|
|
22
|
+
|
|
23
|
+
// The map surface still answers from the values the context carried.
|
|
24
|
+
outputs.mapRead = ctx.config.mapOnlyValue;
|
|
25
|
+
|
|
26
|
+
// Delete removes; deleting a declared-but-never-written name is a no-op.
|
|
27
|
+
// (An UNDECLARED name on delete throws — that is 3.4, tested below.
|
|
28
|
+
// Declared-and-absent is the no-op case 3.7 pins.)
|
|
29
|
+
await ctx.secrets.delete('bot_token');
|
|
30
|
+
await ctx.secrets.set('api_key', 'to-delete');
|
|
31
|
+
await ctx.secrets.delete('api_key');
|
|
32
|
+
outputs.deletedIsGone = await ctx.secrets.get('api_key');
|
|
33
|
+
|
|
34
|
+
// Committed transaction: both writes land.
|
|
35
|
+
await ctx.secrets.transaction((s) => {
|
|
36
|
+
s.set('bot_token', 'committed-a');
|
|
37
|
+
s.set('api_key', 'committed-b');
|
|
38
|
+
});
|
|
39
|
+
outputs.transactionA = await ctx.secrets.get('bot_token');
|
|
40
|
+
outputs.transactionB = await ctx.secrets.get('api_key');
|
|
41
|
+
|
|
42
|
+
// Discarded transaction: nothing lands, including the overwrite of the
|
|
43
|
+
// committed value above.
|
|
44
|
+
try {
|
|
45
|
+
await ctx.secrets.transaction(async (s) => {
|
|
46
|
+
s.set('bot_token', 'discarded');
|
|
47
|
+
throw new Error('hook failed midway');
|
|
48
|
+
});
|
|
49
|
+
} catch (error) {
|
|
50
|
+
outputs.discarded = (error as Error).message;
|
|
51
|
+
}
|
|
52
|
+
outputs.afterDiscard = await ctx.secrets.get('bot_token');
|
|
53
|
+
|
|
54
|
+
// An undeclared name throws, naming the module and the declared set.
|
|
55
|
+
try {
|
|
56
|
+
await ctx.secrets.set('not_declared', 'x');
|
|
57
|
+
} catch (error) {
|
|
58
|
+
outputs.undeclaredSecret = (error as Error).message;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return outputs;
|
|
62
|
+
},
|
|
63
|
+
});
|
|
@@ -26,6 +26,7 @@ import { executeHookScript, hookChildEnv } from './executor';
|
|
|
26
26
|
import type { MountSetWire } from './hook-protocol';
|
|
27
27
|
import { createCapturingLogger } from './logger';
|
|
28
28
|
import { deriveMountSet } from './mount-set';
|
|
29
|
+
import { configStore, secretStore } from './test-fixtures/store-backed';
|
|
29
30
|
import type { HookContext } from './types';
|
|
30
31
|
import { classifyAccess, mountSetEnvValue, parseLintMountSet } from './unjailed-lint';
|
|
31
32
|
|
|
@@ -54,10 +55,25 @@ function scratchModule(script: string): string {
|
|
|
54
55
|
|
|
55
56
|
const describeSet = (overrides: Partial<MountSetWire>): MountSetWire => ({
|
|
56
57
|
entries: [
|
|
57
|
-
{ path: '/tmp', mode: 'tmpfs', reason: 'private scratch, per run' },
|
|
58
|
-
{
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
{ path: '/tmp', mode: 'tmpfs', reason: 'private scratch, per run', absence: 'runtime' },
|
|
59
|
+
{
|
|
60
|
+
path: '/srv/celilo/mod',
|
|
61
|
+
mode: 'ro',
|
|
62
|
+
reason: "the module's own tree",
|
|
63
|
+
absence: 'required',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
path: '/srv/celilo/mod/state',
|
|
67
|
+
mode: 'rw',
|
|
68
|
+
reason: 'the sanctioned writable directory',
|
|
69
|
+
absence: 'required',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
path: '/srv/staged',
|
|
73
|
+
mode: 'rw',
|
|
74
|
+
reason: "contract input 'backup_dir' (write)",
|
|
75
|
+
absence: 'required',
|
|
76
|
+
},
|
|
61
77
|
],
|
|
62
78
|
chdir: '/srv/celilo/mod',
|
|
63
79
|
...overrides,
|
|
@@ -159,8 +175,8 @@ describe('the lint inside a real hook run', () => {
|
|
|
159
175
|
mkdirSync(join(root, 'state'), { recursive: true });
|
|
160
176
|
const { logger, messages } = createCapturingLogger();
|
|
161
177
|
const context: HookContext = {
|
|
162
|
-
config:
|
|
163
|
-
secrets:
|
|
178
|
+
config: configStore(),
|
|
179
|
+
secrets: secretStore(),
|
|
164
180
|
systems: [],
|
|
165
181
|
logger,
|
|
166
182
|
debug: false,
|
|
@@ -188,7 +204,10 @@ describe('the lint inside a real hook run', () => {
|
|
|
188
204
|
export default defineHook({
|
|
189
205
|
requires: [] as const,
|
|
190
206
|
handler: async () => {
|
|
191
|
-
|
|
207
|
+
// A path outside the mount set. Not /etc/hosts — the resolver
|
|
208
|
+
// binding (celilo#1225) mounts that read-only on purpose, and the
|
|
209
|
+
// lint is right to stay silent about it.
|
|
210
|
+
try { readFileSync('/etc/passwd', 'utf-8'); } catch { /* the read is the point, not the bytes */ }
|
|
192
211
|
return {};
|
|
193
212
|
},
|
|
194
213
|
});
|
|
@@ -196,7 +215,7 @@ describe('the lint inside a real hook run', () => {
|
|
|
196
215
|
|
|
197
216
|
const warnings = advisories(messages);
|
|
198
217
|
expect(warnings.length).toBe(1);
|
|
199
|
-
expect(warnings[0]).toContain('/etc/
|
|
218
|
+
expect(warnings[0]).toContain('/etc/passwd');
|
|
200
219
|
expect(warnings[0]).toContain('on a jailed host');
|
|
201
220
|
// The one sentence the task forbids losing: what it is, and what it is not.
|
|
202
221
|
expect(warnings[0]).toContain('Advisory lint, not a security boundary');
|
package/src/manifest/schema.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { execFileSync, execSync } from 'node:child_process';
|
|
2
|
-
import { cpSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
3
3
|
import { readFile, readdir, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { basename, join, relative } from 'node:path';
|
|
6
|
+
import { gunzipSync } from 'node:zlib';
|
|
6
7
|
import { create as tarCreate } from 'tar';
|
|
7
8
|
import { parse as parseYaml } from 'yaml';
|
|
8
9
|
import { log } from '../../cli/prompts';
|
|
@@ -120,6 +121,33 @@ export async function computeChecksums(sourceDir: string): Promise<ChecksumsData
|
|
|
120
121
|
};
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Ceiling on the final tar+gzip write of the .netapp. Every step before it
|
|
126
|
+
* (staging, the module's own build command) already carries a timeout, but the
|
|
127
|
+
* tar stream did not — and 2026-09-04 it stalled mid-write (all samples in
|
|
128
|
+
* kevent64, artifact truncated at 59 percent) and sat there for two hours with
|
|
129
|
+
* no error and no exit. A bounded wait turns that into a nameable failure.
|
|
130
|
+
*/
|
|
131
|
+
const PACKAGE_STREAM_TIMEOUT_MS = 300_000;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Validate that a produced .netapp is a complete gzip stream.
|
|
135
|
+
*
|
|
136
|
+
* A .netapp is a gzipped tar (tarCreate above runs with gzip: true), so a
|
|
137
|
+
* full gunzip pass is the whole check: a stream truncated mid-write fails
|
|
138
|
+
* with 'unexpected end of file' instead of surviving to the consumer, where
|
|
139
|
+
* the same zlib error reads as a truncated DOWNLOAD from an unrelated cause.
|
|
140
|
+
* Returns null when valid, otherwise the reason.
|
|
141
|
+
*/
|
|
142
|
+
export function verifyNetappIntegrity(packagePath: string): string | null {
|
|
143
|
+
try {
|
|
144
|
+
gunzipSync(readFileSync(packagePath));
|
|
145
|
+
return null;
|
|
146
|
+
} catch (err) {
|
|
147
|
+
return err instanceof Error ? err.message : String(err);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
123
151
|
/**
|
|
124
152
|
* Build a module package (.netapp file)
|
|
125
153
|
*
|
|
@@ -321,7 +349,7 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
|
|
|
321
349
|
|
|
322
350
|
// Create tarball
|
|
323
351
|
const finalOutputPath = outputPath || join(process.cwd(), `${moduleId}.netapp`);
|
|
324
|
-
|
|
352
|
+
const tarPromise = tarCreate(
|
|
325
353
|
{
|
|
326
354
|
file: finalOutputPath,
|
|
327
355
|
cwd: buildDir,
|
|
@@ -329,6 +357,46 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
|
|
|
329
357
|
},
|
|
330
358
|
['checksums.json', 'signature.sig', ...Object.keys(checksumsData.files)],
|
|
331
359
|
);
|
|
360
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
361
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
362
|
+
timer = setTimeout(
|
|
363
|
+
() =>
|
|
364
|
+
reject(
|
|
365
|
+
new Error(
|
|
366
|
+
`packaging tar stream exceeded ${PACKAGE_STREAM_TIMEOUT_MS / 1000}s and was abandoned mid-write`,
|
|
367
|
+
),
|
|
368
|
+
),
|
|
369
|
+
PACKAGE_STREAM_TIMEOUT_MS,
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
timer?.unref?.();
|
|
373
|
+
try {
|
|
374
|
+
await Promise.race([tarPromise, timeout]);
|
|
375
|
+
} catch (tarError) {
|
|
376
|
+
// The losing side may still hold the output file open: swallow its
|
|
377
|
+
// eventual rejection and remove the partial artifact so nothing
|
|
378
|
+
// downstream mistakes a truncated write for a package.
|
|
379
|
+
tarPromise.catch(() => {});
|
|
380
|
+
rmSync(finalOutputPath, { force: true });
|
|
381
|
+
return {
|
|
382
|
+
success: false,
|
|
383
|
+
error: `Failed to build module: ${
|
|
384
|
+
tarError instanceof Error ? tarError.message : 'Unknown error'
|
|
385
|
+
}`,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Refuse to declare success on an artifact we cannot read back. The tar
|
|
390
|
+
// step resolved, so this only trips if the stream left the file truncated
|
|
391
|
+
// anyway — the exact corruption build-infra shipped on 2026-09-04.
|
|
392
|
+
const integrityError = verifyNetappIntegrity(finalOutputPath);
|
|
393
|
+
if (integrityError) {
|
|
394
|
+
rmSync(finalOutputPath, { force: true });
|
|
395
|
+
return {
|
|
396
|
+
success: false,
|
|
397
|
+
error: `Produced package failed gzip integrity check (${finalOutputPath}): ${integrityError}`,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
332
400
|
|
|
333
401
|
return {
|
|
334
402
|
success: true,
|
package/src/module/web-root.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { join } from 'node:path';
|
|
17
|
-
import { MODULE_WEB_ROOT } from '@celilo/capabilities';
|
|
17
|
+
import { MODULE_STATE_WEB_ROOT, MODULE_WEB_ROOT } from '@celilo/capabilities';
|
|
18
18
|
import { eq } from 'drizzle-orm';
|
|
19
19
|
import type { DbClient } from '../db/client';
|
|
20
20
|
import { modules } from '../db/schema';
|
|
@@ -33,3 +33,19 @@ export function resolveModuleWebRoot(moduleId: string, db: DbClient): string | u
|
|
|
33
33
|
if (!module) return undefined;
|
|
34
34
|
return join(module.sourcePath, MODULE_WEB_ROOT);
|
|
35
35
|
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Absolute path to `<module root>/state/site` — the state web overlay
|
|
39
|
+
* (celilo#1265) — or undefined when no such module is installed.
|
|
40
|
+
*
|
|
41
|
+
* Beside `resolveModuleWebRoot` for the same reason that function lives here:
|
|
42
|
+
* ONE application of the convention, by the one party that can resolve it for
|
|
43
|
+
* a module that is not running. Does NOT check that the directory exists — a
|
|
44
|
+
* module with no generated site content has no overlay, and deciding what an
|
|
45
|
+
* absent directory means belongs to the caller (see `requireWebRoot`).
|
|
46
|
+
*/
|
|
47
|
+
export function resolveModuleStateWebRoot(moduleId: string, db: DbClient): string | undefined {
|
|
48
|
+
const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
49
|
+
if (!module) return undefined;
|
|
50
|
+
return join(module.sourcePath, MODULE_STATE_WEB_ROOT);
|
|
51
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A module's e2e fixtures must deploy (at least import) a provider for every
|
|
3
|
+
* capability its manifest requires.
|
|
4
|
+
*
|
|
5
|
+
* The failure this exists to catch is silent in the worst way: the manifest
|
|
6
|
+
* requirement makes stage 1 of the module's own suite fail at
|
|
7
|
+
* `module import <id>` with "Required capability 'X' not found", and every
|
|
8
|
+
* later stage cascade-skips on requireStage. Nothing else goes red — the
|
|
9
|
+
* suite is quarantined, or CI never runs it, so a fixture can sit
|
|
10
|
+
* structurally incapable of passing for six weeks (celilo#1260:
|
|
11
|
+
* registry-pipeline required idp@1.2.0 from 7e2d27b9 onward and never
|
|
12
|
+
* deployed an idp provider).
|
|
13
|
+
*
|
|
14
|
+
* Both sides of the comparison are computed sets. The required set comes
|
|
15
|
+
* from each module's manifest.yml parsed by the production validator; the
|
|
16
|
+
* provided set comes from the manifests of whatever modules the module's
|
|
17
|
+
* fixtures actually import (`module import <ref>` statements, scanned in
|
|
18
|
+
* source). A hand-written list of "modules that need an idp" would rot the
|
|
19
|
+
* moment the next module declares the requirement.
|
|
20
|
+
*
|
|
21
|
+
* The check runs the real `validateCapabilityAccess` against an in-memory
|
|
22
|
+
* database seeded with the fixtures' providers — the same code path the CLI
|
|
23
|
+
* executes at import time, including the secret-allowlist gate — so the
|
|
24
|
+
* assertion is over the actual contract stage 1 exercises, not a re-derivation
|
|
25
|
+
* of it.
|
|
26
|
+
*
|
|
27
|
+
* ## What this gate does NOT prove. Read before trusting green.
|
|
28
|
+
*
|
|
29
|
+
* - Import is necessary, not sufficient. A fixture can import the provider
|
|
30
|
+
* and still deploy the consumer before it, or never deploy the provider
|
|
31
|
+
* at all (the consumer's on_install would then fail against a dead idp).
|
|
32
|
+
* Only running the suite proves deploy order.
|
|
33
|
+
* - The import scan is a source scan. An import built dynamically in a way
|
|
34
|
+
* this scanner cannot parse is invisible here (the scanner requires a
|
|
35
|
+
* literal module id, a `${CONST}` reference to a string literal in the
|
|
36
|
+
* same file, or a trailing path segment).
|
|
37
|
+
* - Fixtures living outside `modules/<id>/e2e/*.test.ts` (shared harness
|
|
38
|
+
* files that deploy on the suite's behalf) are not scanned.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { Database, type Database as DatabaseType } from 'bun:sqlite';
|
|
42
|
+
import { describe, expect, test } from 'bun:test';
|
|
43
|
+
import {
|
|
44
|
+
existsSync,
|
|
45
|
+
mkdirSync,
|
|
46
|
+
mkdtempSync,
|
|
47
|
+
readFileSync,
|
|
48
|
+
readdirSync,
|
|
49
|
+
rmSync,
|
|
50
|
+
writeFileSync,
|
|
51
|
+
} from 'node:fs';
|
|
52
|
+
import { tmpdir } from 'node:os';
|
|
53
|
+
import { join } from 'node:path';
|
|
54
|
+
import { validateCapabilityAccess } from '../capabilities/validation';
|
|
55
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
56
|
+
import { validateManifest } from '../manifest/validate';
|
|
57
|
+
import { repoRoot } from './capability-shape';
|
|
58
|
+
|
|
59
|
+
/** Every module manifest under `modulesDir`, parsed by the production validator. */
|
|
60
|
+
function loadModuleManifests(modulesDir: string): Map<string, ModuleManifest> {
|
|
61
|
+
const manifests = new Map<string, ModuleManifest>();
|
|
62
|
+
for (const id of readdirSync(modulesDir)) {
|
|
63
|
+
const path = join(modulesDir, id, 'manifest.yml');
|
|
64
|
+
if (!existsSync(path)) continue;
|
|
65
|
+
const result = validateManifest(readFileSync(path, 'utf8'));
|
|
66
|
+
if (result.success) manifests.set(id, result.data);
|
|
67
|
+
// Manifests that fail validation are another gate's job (validate.test.ts
|
|
68
|
+
// and scripts/check-modules.sh); scanning them here would double-report.
|
|
69
|
+
}
|
|
70
|
+
return manifests;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Module ids a fixture source imports, via `module import <ref>` statements.
|
|
75
|
+
*
|
|
76
|
+
* Handles the three shapes fixtures actually use: a bare literal
|
|
77
|
+
* (`'module import authentik'`), a path (`'module import /celilo/modules/authentik'`),
|
|
78
|
+
* and a `${CONST}` reference to a string literal defined in the same file
|
|
79
|
+
* (`module import ${PRIMARY}`). Trailing CLI flags (`--accept-aspects`) and
|
|
80
|
+
* timeouts are stripped. Anything that does not resolve to a plausible
|
|
81
|
+
* module id is skipped rather than guessed.
|
|
82
|
+
*/
|
|
83
|
+
function scanFixtureImports(fixtureSource: string): string[] {
|
|
84
|
+
const constants = new Map<string, string>();
|
|
85
|
+
for (const match of fixtureSource.matchAll(/const\s+(\w+)\s*=\s*'([^']+)'/g)) {
|
|
86
|
+
constants.set(match[1], match[2]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const imports: string[] = [];
|
|
90
|
+
for (const match of fixtureSource.matchAll(/module import ([^'"\n]+?)['"`]/g)) {
|
|
91
|
+
let ref = match[1].trim();
|
|
92
|
+
const constRef = ref.match(/^\$\{(\w+)\}/);
|
|
93
|
+
if (constRef) ref = constants.get(constRef[1]) ?? '';
|
|
94
|
+
const id = ref.split(/\s+/)[0]?.split('/').pop() ?? '';
|
|
95
|
+
if (id && /^[a-z0-9-]+$/.test(id)) imports.push(id);
|
|
96
|
+
}
|
|
97
|
+
return imports;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The union of modules any `*.test.ts` fixture in `moduleId`'s e2e/ imports. */
|
|
101
|
+
function fixtureImportedModules(modulesDir: string, moduleId: string): string[] {
|
|
102
|
+
const e2eDir = join(modulesDir, moduleId, 'e2e');
|
|
103
|
+
if (!existsSync(e2eDir)) return [];
|
|
104
|
+
const imported = new Set<string>();
|
|
105
|
+
for (const file of readdirSync(e2eDir)) {
|
|
106
|
+
if (!file.endsWith('.test.ts')) continue;
|
|
107
|
+
for (const id of scanFixtureImports(readFileSync(join(e2eDir, file), 'utf8'))) {
|
|
108
|
+
imported.add(id);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return [...imported];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* An in-memory database shaped like the CLI's module registry, seeded with
|
|
116
|
+
* every capability `modules` provide — what `getProviderManifest` queries.
|
|
117
|
+
*/
|
|
118
|
+
function providerDatabase(manifests: Map<string, ModuleManifest>): DatabaseType {
|
|
119
|
+
const db = new Database(':memory:');
|
|
120
|
+
db.exec('CREATE TABLE modules (id TEXT PRIMARY KEY, manifest_data TEXT)');
|
|
121
|
+
db.exec('CREATE TABLE capabilities (module_id TEXT, capability_name TEXT)');
|
|
122
|
+
const insertModule = db.prepare('INSERT INTO modules (id, manifest_data) VALUES (?, ?)');
|
|
123
|
+
const insertCapability = db.prepare(
|
|
124
|
+
'INSERT INTO capabilities (module_id, capability_name) VALUES (?, ?)',
|
|
125
|
+
);
|
|
126
|
+
for (const [id, manifest] of manifests) {
|
|
127
|
+
insertModule.run(id, JSON.stringify(manifest));
|
|
128
|
+
for (const capability of manifest.provides?.capabilities ?? []) {
|
|
129
|
+
insertCapability.run(id, capability.name);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return db;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Modules with e2e fixtures whose required capabilities no imported module
|
|
137
|
+
* provides. The provider union is seeded into a real database and each
|
|
138
|
+
* consumer is checked with the production `validateCapabilityAccess`.
|
|
139
|
+
*/
|
|
140
|
+
async function capabilityCoverageViolations(modulesDir: string): Promise<string[]> {
|
|
141
|
+
const manifests = loadModuleManifests(modulesDir);
|
|
142
|
+
const violations: string[] = [];
|
|
143
|
+
for (const [id, manifest] of manifests) {
|
|
144
|
+
if (fixtureImportedModules(modulesDir, id).length === 0) continue;
|
|
145
|
+
const imported = fixtureImportedModules(modulesDir, id)
|
|
146
|
+
.map((dep) => manifests.get(dep))
|
|
147
|
+
.filter((m): m is ModuleManifest => m !== undefined);
|
|
148
|
+
const db = providerDatabase(new Map(imported.map((m) => [m.id, m])));
|
|
149
|
+
try {
|
|
150
|
+
const result = await validateCapabilityAccess(manifest, db);
|
|
151
|
+
if (!result.success) {
|
|
152
|
+
violations.push(`${id}: ${result.error ?? 'unknown error'}`);
|
|
153
|
+
}
|
|
154
|
+
} finally {
|
|
155
|
+
db.close();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return violations;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
describe('fixture capability coverage', () => {
|
|
162
|
+
test('every module with e2e fixtures imports a provider for each required capability', async () => {
|
|
163
|
+
const violations = await capabilityCoverageViolations(join(repoRoot(), 'modules'));
|
|
164
|
+
expect(violations).toEqual([]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Reach measurement (Rule 2 of the lane plan): the real scanner and checker
|
|
168
|
+
// run against a mirrored module tree with planted markers, so the pass and
|
|
169
|
+
// fail cases are both proven to fire — not reasoned about. This is the
|
|
170
|
+
// mutation test for the gate itself: the real-tree test above can only go
|
|
171
|
+
// green, so the mirror is where the gate is shown to fail.
|
|
172
|
+
describe('reach: the gate fires on a mirrored tree (synthetic modules)', () => {
|
|
173
|
+
function mirrorTree(
|
|
174
|
+
dir: string,
|
|
175
|
+
modules: Record<string, { manifest: string; fixtures?: string[] }>,
|
|
176
|
+
): string {
|
|
177
|
+
mkdirSync(dir, { recursive: true });
|
|
178
|
+
for (const [id, spec] of Object.entries(modules)) {
|
|
179
|
+
const moduleDir = join(dir, id);
|
|
180
|
+
mkdirSync(join(moduleDir, 'e2e'), { recursive: true });
|
|
181
|
+
writeFileSync(join(moduleDir, 'manifest.yml'), spec.manifest);
|
|
182
|
+
for (const [i, fixture] of (spec.fixtures ?? []).entries()) {
|
|
183
|
+
writeFileSync(join(moduleDir, 'e2e', `fixture-${i}.test.ts`), fixture);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return dir;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const providerManifest = [
|
|
190
|
+
'celilo_contract: "1.0"',
|
|
191
|
+
'id: marker-provider',
|
|
192
|
+
'name: Marker Provider',
|
|
193
|
+
'version: 1.0.0',
|
|
194
|
+
'description: synthetic provider',
|
|
195
|
+
'provides:',
|
|
196
|
+
' capabilities:',
|
|
197
|
+
' - name: marker_cap',
|
|
198
|
+
' version: 1.0.0',
|
|
199
|
+
'',
|
|
200
|
+
].join('\n');
|
|
201
|
+
|
|
202
|
+
const consumerFixture = "await net.celilo('module import marker-provider');\n";
|
|
203
|
+
|
|
204
|
+
test('a fixture importing the provider satisfies the requirement', async () => {
|
|
205
|
+
const dir = mkdtempSync(join(tmpdir(), 'cap-coverage-ok-'));
|
|
206
|
+
try {
|
|
207
|
+
mirrorTree(dir, {
|
|
208
|
+
'marker-provider': { manifest: providerManifest },
|
|
209
|
+
'marker-consumer': {
|
|
210
|
+
manifest: [
|
|
211
|
+
'celilo_contract: "1.0"',
|
|
212
|
+
'id: marker-consumer',
|
|
213
|
+
'name: Marker Consumer',
|
|
214
|
+
'version: 1.0.0',
|
|
215
|
+
'description: synthetic consumer',
|
|
216
|
+
'requires:',
|
|
217
|
+
' capabilities:',
|
|
218
|
+
' - name: marker_cap',
|
|
219
|
+
' version: 1.0.0',
|
|
220
|
+
'',
|
|
221
|
+
].join('\n'),
|
|
222
|
+
fixtures: [consumerFixture],
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
expect(await capabilityCoverageViolations(dir)).toEqual([]);
|
|
226
|
+
} finally {
|
|
227
|
+
rmSync(dir, { recursive: true, force: true });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('a fixture missing the provider fails, naming the module and capability', async () => {
|
|
232
|
+
const dir = mkdtempSync(join(tmpdir(), 'cap-coverage-fail-'));
|
|
233
|
+
try {
|
|
234
|
+
mirrorTree(dir, {
|
|
235
|
+
'marker-provider': { manifest: providerManifest },
|
|
236
|
+
'marker-consumer': {
|
|
237
|
+
manifest: [
|
|
238
|
+
'celilo_contract: "1.0"',
|
|
239
|
+
'id: marker-consumer',
|
|
240
|
+
'name: Marker Consumer',
|
|
241
|
+
'version: 1.0.0',
|
|
242
|
+
'description: synthetic consumer',
|
|
243
|
+
'requires:',
|
|
244
|
+
' capabilities:',
|
|
245
|
+
' - name: marker_cap',
|
|
246
|
+
' version: 1.0.0',
|
|
247
|
+
' - name: marker_other',
|
|
248
|
+
' version: 1.0.0',
|
|
249
|
+
'',
|
|
250
|
+
].join('\n'),
|
|
251
|
+
// provider-b covers marker_cap; nothing covers marker_other
|
|
252
|
+
fixtures: [consumerFixture],
|
|
253
|
+
},
|
|
254
|
+
'marker-nonprovider': {
|
|
255
|
+
manifest: providerManifest.replace('marker_cap', 'marker_other'),
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
const violations = await capabilityCoverageViolations(dir);
|
|
259
|
+
expect(violations).toEqual([
|
|
260
|
+
expect.stringContaining("marker-consumer: Required capability 'marker_other' not found"),
|
|
261
|
+
]);
|
|
262
|
+
} finally {
|
|
263
|
+
rmSync(dir, { recursive: true, force: true });
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('a privilege the framework grants needs no provider', async () => {
|
|
268
|
+
const dir = mkdtempSync(join(tmpdir(), 'cap-coverage-priv-'));
|
|
269
|
+
try {
|
|
270
|
+
mirrorTree(dir, {
|
|
271
|
+
'marker-consumer': {
|
|
272
|
+
manifest: [
|
|
273
|
+
'celilo_contract: "1.0"',
|
|
274
|
+
'id: marker-consumer',
|
|
275
|
+
'name: Marker Consumer',
|
|
276
|
+
'version: 1.0.0',
|
|
277
|
+
'description: synthetic consumer',
|
|
278
|
+
'requires:',
|
|
279
|
+
' capabilities:',
|
|
280
|
+
' - name: cross_module_read',
|
|
281
|
+
' version: 1.0.0',
|
|
282
|
+
'',
|
|
283
|
+
].join('\n'),
|
|
284
|
+
fixtures: ["await net.celilo('module import nothing-here');\n"],
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
expect(await capabilityCoverageViolations(dir)).toEqual([]);
|
|
288
|
+
} finally {
|
|
289
|
+
rmSync(dir, { recursive: true, force: true });
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('a ${CONST} import resolves through the same-file string literal', async () => {
|
|
294
|
+
const dir = mkdtempSync(join(tmpdir(), 'cap-coverage-const-'));
|
|
295
|
+
try {
|
|
296
|
+
mirrorTree(dir, {
|
|
297
|
+
'marker-provider': { manifest: providerManifest },
|
|
298
|
+
'marker-consumer': {
|
|
299
|
+
manifest: [
|
|
300
|
+
'celilo_contract: "1.0"',
|
|
301
|
+
'id: marker-consumer',
|
|
302
|
+
'name: Marker Consumer',
|
|
303
|
+
'version: 1.0.0',
|
|
304
|
+
'description: synthetic consumer',
|
|
305
|
+
'requires:',
|
|
306
|
+
' capabilities:',
|
|
307
|
+
' - name: marker_cap',
|
|
308
|
+
' version: 1.0.0',
|
|
309
|
+
'',
|
|
310
|
+
].join('\n'),
|
|
311
|
+
fixtures: [
|
|
312
|
+
"const PROVIDER = 'marker-provider';\nawait net.celilo(`module import ${PROVIDER} --accept-aspects`);\n",
|
|
313
|
+
],
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
expect(await capabilityCoverageViolations(dir)).toEqual([]);
|
|
317
|
+
} finally {
|
|
318
|
+
rmSync(dir, { recursive: true, force: true });
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
});
|