@celilo/cli 2.1.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.
Files changed (93) hide show
  1. package/drizzle/0031_module_config_source.sql +20 -0
  2. package/drizzle/meta/_journal.json +8 -1
  3. package/package.json +2 -2
  4. package/schemas/system_config.json +2 -1
  5. package/src/capabilities/public-web-publish.test.ts +61 -0
  6. package/src/cli/commands/firewall-interface-list.test.ts +156 -7
  7. package/src/cli/commands/firewall-interface-list.ts +73 -7
  8. package/src/cli/commands/machine-add.ts +12 -55
  9. package/src/cli/commands/module-config.test.ts +20 -1
  10. package/src/cli/commands/module-import.ts +1 -1
  11. package/src/cli/commands/module-update.test.ts +82 -0
  12. package/src/cli/commands/module-update.ts +14 -4
  13. package/src/cli/commands/monitor.ts +2 -10
  14. package/src/cli/commands/restore.ts +16 -6
  15. package/src/cli/generate-zsh-completion.ts +1 -1
  16. package/src/cli/index.ts +4 -3
  17. package/src/cli/restore-migration-failure.test.ts +159 -0
  18. package/src/db/client.ts +5 -0
  19. package/src/db/migrate.test.ts +61 -135
  20. package/src/db/migrate.ts +7 -2
  21. package/src/db/schema.ts +10 -0
  22. package/src/hooks/broker.test.ts +106 -2
  23. package/src/hooks/broker.ts +91 -1
  24. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  25. package/src/hooks/capability-loader.ts +15 -1
  26. package/src/hooks/define-hook.test.ts +4 -3
  27. package/src/hooks/executor.test.ts +19 -18
  28. package/src/hooks/executor.ts +82 -11
  29. package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
  30. package/src/hooks/hook-jail-unreachability.test.ts +4 -3
  31. package/src/hooks/hook-protocol.ts +46 -1
  32. package/src/hooks/hook-runner.ts +36 -0
  33. package/src/hooks/hook-store-proxy.test.ts +109 -0
  34. package/src/hooks/hook-store-proxy.ts +85 -0
  35. package/src/hooks/hook-store.test.ts +162 -0
  36. package/src/hooks/hook-store.ts +290 -0
  37. package/src/hooks/hook-timeout.test.ts +3 -2
  38. package/src/hooks/hook-trespass.test.ts +29 -5
  39. package/src/hooks/jail.test.ts +1 -1
  40. package/src/hooks/jail.ts +14 -7
  41. package/src/hooks/mount-set.test.ts +208 -0
  42. package/src/hooks/mount-set.ts +62 -14
  43. package/src/hooks/run-named-hook.ts +2 -0
  44. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  45. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  46. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  47. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  48. package/src/hooks/unjailed-lint.test.ts +22 -6
  49. package/src/manifest/schema.ts +1 -0
  50. package/src/module/packaging/build.ts +70 -2
  51. package/src/module/web-root.ts +17 -1
  52. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  53. package/src/policy/module-script-scan.test.ts +42 -1
  54. package/src/policy/module-script-scan.ts +275 -5
  55. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  56. package/src/policy/no-swallowed-refusal.test.ts +265 -0
  57. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  58. package/src/registry/client.test.ts +149 -0
  59. package/src/registry/client.ts +203 -11
  60. package/src/services/alerting/format.test.ts +57 -0
  61. package/src/services/alerting/format.ts +24 -0
  62. package/src/services/alerting/run-monitor.ts +2 -2
  63. package/src/services/backup-create.ts +7 -7
  64. package/src/services/backup-envelope-roundtrip.test.ts +45 -2
  65. package/src/services/backup-restore.ts +8 -4
  66. package/src/services/bus-interview.ts +37 -14
  67. package/src/services/config-provenance.ts +4 -0
  68. package/src/services/control-plane-bootstrap.test.ts +121 -1
  69. package/src/services/control-plane-bootstrap.ts +51 -4
  70. package/src/services/deploy-preflight.ts +8 -2
  71. package/src/services/deploy-validation.test.ts +22 -0
  72. package/src/services/deploy-validation.ts +8 -0
  73. package/src/services/dns-discovery.test.ts +54 -0
  74. package/src/services/dns-discovery.ts +47 -5
  75. package/src/services/fleet-key.test.ts +66 -2
  76. package/src/services/fleet-key.ts +54 -0
  77. package/src/services/health-runner.ts +2 -0
  78. package/src/services/module-config.ts +20 -2
  79. package/src/services/module-deploy.dns-repoint.test.ts +187 -0
  80. package/src/services/module-deploy.ts +163 -1
  81. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  82. package/src/services/module-validator/git-hygiene.ts +83 -14
  83. package/src/services/restore-from-file.test.ts +20 -0
  84. package/src/services/restore-from-file.ts +21 -6
  85. package/src/services/static-content-converge.test.ts +140 -2
  86. package/src/services/static-content-converge.ts +55 -8
  87. package/src/services/system-config-schema-types.ts +1 -1
  88. package/src/services/system-config-validator.test.ts +36 -0
  89. package/src/services/system-config-validator.ts +11 -0
  90. package/src/services/trusted-sources.test.ts +30 -0
  91. package/src/services/trusted-sources.ts +47 -10
  92. package/src/templates/generator.ts +9 -2
  93. package/src/variables/context.ts +16 -5
@@ -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
+ });
@@ -12,7 +12,11 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
12
12
  import { tmpdir } from 'node:os';
13
13
  import { join } from 'node:path';
14
14
  import { buildModule } from '../module/packaging/build';
15
- import { scanModuleDirectory, scanModuleScriptSource } from './module-script-scan';
15
+ import {
16
+ JAILED_CLI_SPAWN_RULE,
17
+ scanModuleDirectory,
18
+ scanModuleScriptSource,
19
+ } from './module-script-scan';
16
20
 
17
21
  const rules = (src: string) => scanModuleScriptSource('f.ts', src).map((v) => v.rule);
18
22
 
@@ -60,6 +64,43 @@ describe('module script scan — namespace tools (hygiene, not a boundary)', ()
60
64
  });
61
65
  });
62
66
 
67
+ describe('module script scan — jailed CLI spawn rule', () => {
68
+ // Each spawn spelling asserted separately. An alternation that loses a leg
69
+ // still passes a test that only exercises the first.
70
+ it.each([
71
+ [
72
+ "execFileSync('celilo', argv)",
73
+ "execFileSync('celilo', ['module', 'config', 'set', 'x', 'y']);",
74
+ ],
75
+ ['execFileSync("celilo", argv)', 'execFileSync("celilo", args);'],
76
+ ['execSync(`celilo …`)', 'execSync(`celilo module secret set x y`);'],
77
+ [
78
+ 'exec(`celilo …`) after a shell prefix',
79
+ 'exec(`cd /opt && sudo celilo system config get k`);',
80
+ ],
81
+ ["spawn('celilo', argv)", "const child = spawn('celilo', ['events', 'status']);"],
82
+ ])('catches %s', (_spelling, src) => {
83
+ expect(rules(src)).toContain(JAILED_CLI_SPAWN_RULE);
84
+ });
85
+
86
+ it('does not fire on operator prose that merely names the CLI', () => {
87
+ expect(rules('throw new Error("re-run `celilo module deploy x` to regenerate it");')).toEqual(
88
+ [],
89
+ );
90
+ });
91
+
92
+ // The `\w` after `celilo ` is what separates a CLI invocation from an
93
+ // argument that merely begins with the string, so the negative case has to
94
+ // be a hyphenated name, which would match a bare `\bcelilo\b`.
95
+ it('does not fire on a container argument named celilo-*', () => {
96
+ expect(rules('execSync(`docker exec celilo-mgr status`);')).toEqual([]);
97
+ });
98
+
99
+ it('does not fire on the celilo.db file name', () => {
100
+ expect(rules('execSync(`cp /var/lib/celilo/celilo.db /tmp/x`);')).toEqual([]);
101
+ });
102
+ });
103
+
63
104
  describe('module script scan — raw-exec escape hatch', () => {
64
105
  it('flags a runAppCommand call with no justification', () => {
65
106
  expect(rules('const r = runAppCommand(system, "rm -f /tmp/x", run);')).toContain(