@celilo/cli 1.13.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +31 -5
  3. package/README.md +0 -2
  4. package/drizzle/0030_drop_module_builds_environment.sql +8 -0
  5. package/drizzle/meta/_journal.json +8 -1
  6. package/package.json +3 -3
  7. package/src/capabilities/public-web-helpers.test.ts +12 -6
  8. package/src/capabilities/public-web-publish.test.ts +42 -13
  9. package/src/capabilities/validation.test.ts +31 -0
  10. package/src/cli/commands/alerts-sweep.ts +3 -0
  11. package/src/cli/commands/console-get-chain.test.ts +96 -0
  12. package/src/cli/commands/console.ts +13 -5
  13. package/src/cli/commands/monitor.ts +15 -2
  14. package/src/cli/commands/notify-config.test.ts +79 -0
  15. package/src/cli/commands/notify-config.ts +13 -2
  16. package/src/cli/commands/system-doctor.test.ts +121 -1
  17. package/src/cli/commands/system-doctor.ts +151 -1
  18. package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
  19. package/src/cli/completion.ts +10 -2
  20. package/src/cli/index.ts +7 -1
  21. package/src/console/closure.test.ts +76 -0
  22. package/src/console/closure.ts +87 -1
  23. package/src/console/control-plane-boundary.test.ts +82 -4
  24. package/src/console/projection.test.ts +63 -1
  25. package/src/console/projection.ts +39 -2
  26. package/src/db/schema.ts +0 -1
  27. package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
  28. package/src/hooks/capability-loader.ts +81 -10
  29. package/src/hooks/executor.ts +110 -17
  30. package/src/hooks/hook-jail-toolchain-reach.test.ts +224 -0
  31. package/src/hooks/hook-jail-unreachability.test.ts +28 -2
  32. package/src/hooks/hook-protocol.ts +44 -0
  33. package/src/hooks/hook-runner-entry.ts +23 -0
  34. package/src/hooks/hook-runner.ts +10 -0
  35. package/src/hooks/hook-trespass.test.ts +9 -3
  36. package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
  37. package/src/hooks/jail.test.ts +92 -0
  38. package/src/hooks/jail.ts +128 -11
  39. package/src/hooks/mount-set.test.ts +28 -6
  40. package/src/hooks/mount-set.ts +34 -20
  41. package/src/hooks/remote-broker.test.ts +350 -0
  42. package/src/hooks/remote-broker.ts +404 -0
  43. package/src/hooks/run-named-hook.ts +2 -0
  44. package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
  45. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
  46. package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
  47. package/src/hooks/unjailed-lint.test.ts +251 -0
  48. package/src/hooks/unjailed-lint.ts +395 -0
  49. package/src/manifest/contracts/v1.ts +22 -1
  50. package/src/manifest/validate.ts +25 -4
  51. package/src/module/web-root.ts +35 -0
  52. package/src/policy/module-business-baseline.ts +27 -3
  53. package/src/policy/module-script-scan.test.ts +22 -0
  54. package/src/policy/module-script-scan.ts +92 -1
  55. package/src/policy/no-hand-built-ssh.test.ts +39 -1
  56. package/src/policy/no-module-business-in-core.test.ts +1 -1
  57. package/src/services/alerting/hook-jail.test.ts +66 -0
  58. package/src/services/alerting/hook-jail.ts +70 -0
  59. package/src/services/alerting/run-monitor.test.ts +62 -0
  60. package/src/services/alerting/run-monitor.ts +12 -0
  61. package/src/services/alerting/sweep-runner.test.ts +1 -0
  62. package/src/services/api-principal-enrolment.test.ts +73 -0
  63. package/src/services/api-principal-enrolment.ts +55 -0
  64. package/src/services/backup-create.ts +36 -7
  65. package/src/services/backup-restore.ts +2 -0
  66. package/src/services/celilo-mgmt-hooks.test.ts +38 -79
  67. package/src/services/deploy-ansible.ts +9 -1
  68. package/src/services/fleet-key.test.ts +47 -0
  69. package/src/services/fleet-key.ts +75 -0
  70. package/src/services/health-runner.ts +2 -0
  71. package/src/services/module-build.test.ts +1 -64
  72. package/src/services/module-build.ts +10 -86
  73. package/src/services/module-deploy.ts +20 -0
  74. package/src/services/remote-access.test.ts +139 -0
  75. package/src/services/remote-access.ts +98 -0
  76. package/src/services/restore-from-file.ts +12 -6
  77. package/src/services/static-content-converge.test.ts +338 -0
  78. package/src/services/static-content-converge.ts +299 -0
  79. package/src/services/system-state-stage.test.ts +165 -0
  80. package/src/services/system-state-stage.ts +196 -0
@@ -46,6 +46,7 @@ import {
46
46
  runProviderBackfill,
47
47
  } from './provider-arrival';
48
48
  import { checkProxmoxReachable, formatProxmoxUnreachableError } from './proxmox-preflight';
49
+ import { remoteAccessPolicy } from './remote-access';
49
50
  import { LOCAL_MACHINE_IP, deleteTemporarySshKey, writeTemporarySshKey } from './ssh-key-manager';
50
51
  import { buildTerraformEnvForService } from './terraform-env';
51
52
 
@@ -720,6 +721,7 @@ async function deployModuleImpl(
720
721
  capabilities: capabilityFunctions,
721
722
  requiredCapabilities: manifest.requires.capabilities.map((c) => c.name),
722
723
  systems: getModuleSystems(moduleId, db),
724
+ remoteAccess: remoteAccessPolicy(moduleId, db),
723
725
  },
724
726
  );
725
727
 
@@ -858,6 +860,7 @@ async function deployModuleImpl(
858
860
  capabilities: capFns,
859
861
  requiredCapabilities: (manifest.requires?.capabilities ?? []).map((c) => c.name),
860
862
  systems: getModuleSystems(moduleId, db),
863
+ remoteAccess: remoteAccessPolicy(moduleId, db),
861
864
  },
862
865
  };
863
866
  },
@@ -1159,6 +1162,7 @@ async function deployModuleImpl(
1159
1162
  capabilities: capFunctions,
1160
1163
  requiredCapabilities: providerManifest.requires.capabilities.map((c) => c.name),
1161
1164
  systems: getModuleSystems(capRecord.moduleId, db),
1165
+ remoteAccess: remoteAccessPolicy(capRecord.moduleId, db),
1162
1166
  },
1163
1167
  );
1164
1168
 
@@ -1308,6 +1312,21 @@ async function deployModuleImpl(
1308
1312
  }
1309
1313
  }
1310
1314
 
1315
+ // The provider's own deploy runs the static-content converge alongside
1316
+ // everything else (design D10, task 4.5): the release set is written into
1317
+ // the generated inventory, the playbook's static_content-tagged tasks
1318
+ // converge /srv/www, and a rebuilt host recovers with no consumer
1319
+ // involvement. Same plan + writer the publish-time converge uses — one
1320
+ // code path, two callers.
1321
+ if (manifest.provides?.capabilities?.some((cap) => cap.name === 'public_web')) {
1322
+ const { writeStaticContentVars } = await import('./static-content-converge');
1323
+ const staticVars = await writeStaticContentVars(db, moduleId, generatedPath);
1324
+ if (!staticVars.success) {
1325
+ return { success: false, error: staticVars.error, phases };
1326
+ }
1327
+ log.success('Static-content release set written to the inventory');
1328
+ }
1329
+
1311
1330
  try {
1312
1331
  const ansibleResult = await executeAnsible(generatedPath, {
1313
1332
  noInteractive: !process.stdout.isTTY,
@@ -1412,6 +1431,7 @@ async function deployModuleImpl(
1412
1431
  capabilities: capFns,
1413
1432
  requiredCapabilities: manifest.requires.capabilities.map((c) => c.name),
1414
1433
  systems: getModuleSystems(moduleId, db),
1434
+ remoteAccess: remoteAccessPolicy(moduleId, db),
1415
1435
  },
1416
1436
  };
1417
1437
  },
@@ -0,0 +1,139 @@
1
+ /**
2
+ * D12's reachability policy (hook-process-boundary stage 3, tasks 5.1b/5.2).
3
+ *
4
+ * The rules under test, in the order they short-circuit:
5
+ * 1. a request on the module's OWN credential is allowed — the credential is
6
+ * the scope;
7
+ * 2. an explicit non-root user is allowed — the fleet key's authority is
8
+ * root on fleet systems, and a non-root account trusts it only where an
9
+ * operator installed it (the cPanel bootstrap);
10
+ * 3. otherwise the target must be a system the module or one of its
11
+ * instances provisioned, and a refusal names the module, the target, and
12
+ * the capability route.
13
+ */
14
+
15
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
16
+ import { existsSync } from 'node:fs';
17
+ import { rm } from 'node:fs/promises';
18
+ import { type DbClient, createDbClient } from '../db/client';
19
+ import { capabilities, moduleInstances, moduleSystems, modules } from '../db/schema';
20
+ import { remoteAccessPolicy } from './remote-access';
21
+
22
+ const TEST_DB_PATH = './test-remote-access.db';
23
+
24
+ function seedModule(db: DbClient, id: string): void {
25
+ db.insert(modules)
26
+ .values({
27
+ id,
28
+ name: id,
29
+ version: '1.0.0',
30
+ manifestData: {},
31
+ sourcePath: `/tmp/${id}`,
32
+ state: 'VERIFIED',
33
+ })
34
+ .run();
35
+ }
36
+
37
+ function seedSystem(db: DbClient, moduleId: string, ipv4Address: string, hostname: string): void {
38
+ db.insert(moduleSystems)
39
+ .values({
40
+ moduleId,
41
+ name: 'main',
42
+ hostname,
43
+ ipv4Address,
44
+ zone: 'dmz',
45
+ infraType: 'machine',
46
+ updatedAt: new Date(),
47
+ })
48
+ .run();
49
+ }
50
+
51
+ describe('remoteAccessPolicy', () => {
52
+ let db: DbClient;
53
+
54
+ beforeEach(() => {
55
+ db = createDbClient({ path: TEST_DB_PATH });
56
+ seedModule(db, 'caddy');
57
+ seedSystem(db, 'caddy', '10.0.10.10', 'www');
58
+ seedModule(db, 'homebridge');
59
+ seedSystem(db, 'homebridge', '10.0.30.7', 'homebridge');
60
+ db.insert(capabilities)
61
+ .values({ moduleId: 'caddy', capabilityName: 'public_web', version: '1.0.0', data: {} })
62
+ .run();
63
+ });
64
+
65
+ afterEach(async () => {
66
+ db.$client.close();
67
+ for (const suffix of ['', '-shm', '-wal']) {
68
+ const p = `${TEST_DB_PATH}${suffix}`;
69
+ if (existsSync(p)) await rm(p);
70
+ }
71
+ });
72
+
73
+ test("allows the module's own system", () => {
74
+ const policy = remoteAccessPolicy('caddy', db);
75
+ expect(policy.checkTarget({ ipv4_address: '10.0.10.10' }, false)).toEqual({ allowed: true });
76
+ });
77
+
78
+ test("allows an instance's system, one level deep", () => {
79
+ seedModule(db, 'forgejo-runner-abc123def456');
80
+ db.insert(moduleInstances)
81
+ .values({
82
+ moduleId: 'forgejo-runner-abc123def456',
83
+ parentId: 'homebridge',
84
+ submodule: 'runner',
85
+ instanceKey: 'lab',
86
+ state: 'ready',
87
+ })
88
+ .run();
89
+ seedSystem(db, 'forgejo-runner-abc123def456', '10.0.30.9', 'runner-lab');
90
+
91
+ const policy = remoteAccessPolicy('homebridge', db);
92
+ expect(policy.checkTarget({ ipv4_address: '10.0.30.9' }, false)).toEqual({ allowed: true });
93
+ });
94
+
95
+ test("refuses another module's system, naming module, target and the capability route", () => {
96
+ const verdict = remoteAccessPolicy('homebridge', db).checkTarget(
97
+ { ipv4_address: '10.0.10.10' },
98
+ false,
99
+ );
100
+ expect(verdict.allowed).toBe(false);
101
+ if (verdict.allowed) throw new Error('unreachable');
102
+ expect(verdict.message).toContain("Module 'homebridge'");
103
+ expect(verdict.message).toContain('10.0.10.10');
104
+ expect(verdict.message).toContain("belongs to module 'caddy'");
105
+ expect(verdict.message).toContain('public_web');
106
+ });
107
+
108
+ test('refuses an address no deployed system has, with the generic guidance', () => {
109
+ const verdict = remoteAccessPolicy('homebridge', db).checkTarget(
110
+ { ipv4_address: '203.0.113.50' },
111
+ false,
112
+ );
113
+ expect(verdict.allowed).toBe(false);
114
+ if (verdict.allowed) throw new Error('unreachable');
115
+ expect(verdict.message).toContain("Module 'homebridge'");
116
+ expect(verdict.message).toContain('203.0.113.50');
117
+ expect(verdict.message).toContain('capability');
118
+ });
119
+
120
+ test('allows any target when the request carries its own credential', () => {
121
+ const policy = remoteAccessPolicy('homebridge', db);
122
+ expect(policy.checkTarget({ ipv4_address: '10.0.10.10' }, true)).toEqual({ allowed: true });
123
+ });
124
+
125
+ test('allows an explicit non-root user (the off-fleet account case)', () => {
126
+ const policy = remoteAccessPolicy('generic-cpanel-hosting-provider', db);
127
+ expect(
128
+ policy.checkTarget({ ipv4_address: '198.51.100.7', user: 'peba-hosting' }, false),
129
+ ).toEqual({ allowed: true });
130
+ });
131
+
132
+ test("an explicit 'root' user gets no such pass", () => {
133
+ const verdict = remoteAccessPolicy('homebridge', db).checkTarget(
134
+ { ipv4_address: '10.0.10.10', user: 'root' },
135
+ false,
136
+ );
137
+ expect(verdict.allowed).toBe(false);
138
+ });
139
+ });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * D12's reachability policy: which remote targets a module may reach with the
3
+ * FLEET credential (openspec/changes/hook-process-boundary, stage 3).
4
+ *
5
+ * The rule: a module may reach the systems it provisioned — its own and its
6
+ * instances' (`ownedSystemModuleIds`, one indexed query because ownership is
7
+ * one level deep) — and reaches everything else through capabilities. The
8
+ * mount set is derived from the hook context; the reachable set is derived
9
+ * from the same identity.
10
+ *
11
+ * Two carve-outs, both following from "scoped by the CREDENTIAL":
12
+ *
13
+ * - A request carrying the module's OWN credential (an identity key, or
14
+ * `installAuthorizedKey`'s password) is not riding the fleet key at all.
15
+ * The credential is the scope: it works exactly where its holder was
16
+ * given access, which celilo neither grants nor needs to police.
17
+ * - An explicit NON-ROOT user. The fleet key's authority is root on the
18
+ * systems celilo provisions — that is the only place celilo installs it.
19
+ * A non-root account trusts it only where an operator deliberately put it
20
+ * there (the cPanel bootstrap installs it into an unprivileged hosting
21
+ * account), and refusing those would break every off-fleet provider while
22
+ * protecting nothing the key actually unlocks. The residual — an operator
23
+ * who installed the fleet key as root on an OFF-fleet box — is refused
24
+ * until that target carries its own identity, and design D12 records it.
25
+ *
26
+ * **Attribution (D12, task 5.1b).** A policy is built for the module that
27
+ * PERFORMS the operation, never the one that asked for it. For a hook that is
28
+ * the hook's module — the nine `invokeHook` call sites construct it with the
29
+ * id they are invoking. A capability provider's internal transport belongs to
30
+ * the provider's module; providers run in celilo's own process today (design
31
+ * D10), so their transport does not cross the remote-ops broker, and the one
32
+ * hand-built-ssh provider site (`public_web`'s upload) is being replaced by
33
+ * an Ansible converge under `openspec/changes/capability-owned-tables`
34
+ * (celilo#1014). If provider transport is ever brokered, it takes a policy
35
+ * built with the PROVIDER's id from this same function — neither "the calling
36
+ * module's systems" nor an exemption.
37
+ *
38
+ * Policy function (Rule 10.1) — decides; the remote-ops broker enforces.
39
+ */
40
+
41
+ import { eq } from 'drizzle-orm';
42
+ import type { DbClient } from '../db/client';
43
+ import { capabilities, moduleSystems } from '../db/schema';
44
+ import type { RemoteAccessPolicy } from '../hooks/remote-broker';
45
+ import { getModuleSystems } from './deployed-systems';
46
+ import { ownedSystemModuleIds } from './module-instances';
47
+
48
+ /** Build the policy one hook run's remote operations are checked against. */
49
+ export function remoteAccessPolicy(moduleId: string, db: DbClient): RemoteAccessPolicy {
50
+ return {
51
+ moduleId,
52
+ checkTarget(target, hasOwnCredential) {
53
+ if (hasOwnCredential) return { allowed: true };
54
+ if (target.user !== undefined && target.user !== 'root') return { allowed: true };
55
+
56
+ // Queried per call rather than snapshotted at invoke time, so a system
57
+ // recorded mid-run (a provider arrival) is visible to the next check.
58
+ const owned = ownedSystemModuleIds(moduleId, db).flatMap((id) => getModuleSystems(id, db));
59
+ if (owned.some((system) => system.ipv4_address === target.ipv4_address)) {
60
+ return { allowed: true };
61
+ }
62
+
63
+ return { allowed: false, message: refusalMessage(moduleId, target.ipv4_address, db) };
64
+ },
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Name the module, the target, and the capability route (task 5.2). The owner
70
+ * lookup is best-effort colour: the refusal stands whether or not celilo can
71
+ * say whose system it is.
72
+ */
73
+ function refusalMessage(moduleId: string, ipv4Address: string, db: DbClient): string {
74
+ const preamble = `Module '${moduleId}' may not reach ${ipv4Address}: it is not a system this module (or one of its instances) provisioned.`;
75
+ const guidance =
76
+ 'A hook reaches its own systems via ctx.systems and everything else through a capability (hook-process-boundary, design D12).';
77
+
78
+ const owner = db
79
+ .select()
80
+ .from(moduleSystems)
81
+ .where(eq(moduleSystems.ipv4Address, ipv4Address))
82
+ .get();
83
+ if (!owner) return `${preamble} ${guidance}`;
84
+
85
+ const provided = db
86
+ .select({ capabilityName: capabilities.capabilityName })
87
+ .from(capabilities)
88
+ .where(eq(capabilities.moduleId, owner.moduleId))
89
+ .all()
90
+ .map((row) => row.capabilityName);
91
+
92
+ const route =
93
+ provided.length > 0
94
+ ? `Reach it through a capability it provides ('${provided.join("', '")}').`
95
+ : "Reach it through a capability, never by SSH from another module's hook.";
96
+
97
+ return `${preamble} That system ('${owner.hostname}') belongs to module '${owner.moduleId}'. ${route}`;
98
+ }
@@ -32,7 +32,7 @@ import {
32
32
  rmSync,
33
33
  } from 'node:fs';
34
34
  import { tmpdir } from 'node:os';
35
- import { dirname, join } from 'node:path';
35
+ import { join } from 'node:path';
36
36
  import { eq } from 'drizzle-orm';
37
37
  import { getDbPath, getMasterKeyPath, getModuleStoragePath } from '../config/paths';
38
38
  import { closeDb, getDb } from '../db/client';
@@ -47,12 +47,14 @@ import { decryptFileToFile } from './backup-cipher';
47
47
  import { assertCompatibleSchema, parseManifest } from './backup-manifest';
48
48
  import { applyCrossModuleWriteRoot, moduleHasCrossModuleRead } from './cross-module-read';
49
49
  import { getModuleSystems } from './deployed-systems';
50
+ import { getFleetSshDir } from './fleet-key';
50
51
  import {
51
52
  completeOperation,
52
53
  failOperation,
53
54
  refuseIfInFlight,
54
55
  startOperation,
55
56
  } from './module-operations';
57
+ import { remoteAccessPolicy } from './remote-access';
56
58
 
57
59
  export interface RestoreFromFileResult {
58
60
  success: boolean;
@@ -215,7 +217,11 @@ export async function restoreFromArtifactFile(
215
217
  {},
216
218
  {},
217
219
  logger,
218
- { debug: false, systems: getModuleSystems(mod.id, db) },
220
+ {
221
+ debug: false,
222
+ systems: getModuleSystems(mod.id, db),
223
+ remoteAccess: remoteAccessPolicy(mod.id, db),
224
+ },
219
225
  );
220
226
  if (!hookResult.success) {
221
227
  const errMsg = hookResult.error ?? 'on_restore hook failed';
@@ -376,10 +382,10 @@ export function applyStagedSystemFiles(systemStagingDir: string): StagedSystemAp
376
382
  // managed machines; the DB only carries the public half, so without the
377
383
  // private half on disk the restored box can't authenticate to the fleet.
378
384
  if (existsSync(stagedSsh)) {
379
- // The fleet key lives next to the DB (on_install writes it to
380
- // dirname(db_path)/.ssh), so follow the DB's location — not getDataDir()
381
- // — in case CELILO_DB_PATH points somewhere custom.
382
- const liveSshDir = join(dirname(getDbPath()), '.ssh');
385
+ // One helper, shared with the minting side, so restore and mint cannot
386
+ // drift to different directories. See getFleetSshDir for why it follows
387
+ // the DB rather than getDataDir().
388
+ const liveSshDir = getFleetSshDir();
383
389
  mkdirSync(liveSshDir, { recursive: true, mode: 0o700 });
384
390
  chmodSync(liveSshDir, 0o700);
385
391
  for (const entry of readdirSync(stagedSsh)) {
@@ -0,0 +1,338 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { eq } from 'drizzle-orm';
6
+ import type { DbClient } from '../db/client';
7
+ import { moduleConfigs, modules, webRoutes } from '../db/schema';
8
+ import { setupTestDatabaseAt } from '../test-utils/database';
9
+ import {
10
+ buildStaticContentPlan,
11
+ planStaticContent,
12
+ resolveStaticContentRetention,
13
+ staticContentVarsPath,
14
+ staticContentVarsYaml,
15
+ writeStaticContentVars,
16
+ } from './static-content-converge';
17
+
18
+ const HASH1 = 'a'.repeat(64);
19
+ const HASH2 = 'b'.repeat(64);
20
+
21
+ describe('planStaticContent — the declared release set (D10 task 4.1)', () => {
22
+ let dir: string;
23
+ let db: DbClient;
24
+
25
+ beforeEach(async () => {
26
+ dir = mkdtempSync(join(tmpdir(), 'static-converge-'));
27
+ const dbPath = join(dir, 'celilo.db');
28
+ process.env.CELILO_DB_PATH = dbPath;
29
+ db = await setupTestDatabaseAt(dbPath);
30
+
31
+ // Two installed modules with real web roots on disk.
32
+ writeSite('lunacycle', '<html>luna</html>');
33
+ writeSite('hello-foo', '<html>foo</html>');
34
+ db.insert(modules)
35
+ .values({
36
+ id: 'lunacycle',
37
+ name: 'Lunacycle',
38
+ version: '1.0.0',
39
+ manifestData: {},
40
+ sourcePath: join(dir, 'modules', 'lunacycle'),
41
+ })
42
+ .onConflictDoNothing()
43
+ .run();
44
+ db.insert(modules)
45
+ .values({
46
+ id: 'hello-foo',
47
+ name: 'Hello Foo',
48
+ version: '0.1.2',
49
+ manifestData: {},
50
+ sourcePath: join(dir, 'modules', 'hello-foo'),
51
+ })
52
+ .onConflictDoNothing()
53
+ .run();
54
+ });
55
+
56
+ afterEach(() => {
57
+ db.$client.close();
58
+ process.env.CELILO_DB_PATH = undefined;
59
+ try {
60
+ rmSync(dir, { recursive: true, force: true });
61
+ } catch {
62
+ /* ignore */
63
+ }
64
+ });
65
+
66
+ function writeSite(moduleId: string, html: string): string {
67
+ const webRoot = join(dir, 'modules', moduleId, 'site', 'dist');
68
+ mkdirSync(webRoot, { recursive: true });
69
+ writeFileSync(join(webRoot, 'index.html'), html, 'utf-8');
70
+ return webRoot;
71
+ }
72
+
73
+ function insertRoute(route: {
74
+ slug: string;
75
+ moduleId: string;
76
+ hostname: string;
77
+ path?: string;
78
+ type?: 'static' | 'reverse_proxy';
79
+ contentHash?: string | null;
80
+ }) {
81
+ db.insert(webRoutes)
82
+ .values({
83
+ slug: route.slug,
84
+ moduleId: route.moduleId,
85
+ type: route.type ?? 'static',
86
+ path: route.path ?? '/',
87
+ hostname: route.hostname,
88
+ websocket: false,
89
+ contentHash: route.contentHash ?? HASH1,
90
+ })
91
+ .run();
92
+ }
93
+
94
+ it('groups by slug across hostnames and derives the source dir', () => {
95
+ insertRoute({ slug: 'lunacycle', moduleId: 'lunacycle', hostname: 'lunacycle.net' });
96
+ insertRoute({
97
+ slug: 'lunacycle',
98
+ moduleId: 'lunacycle',
99
+ hostname: 'www.example.com',
100
+ path: '/x',
101
+ });
102
+ insertRoute({
103
+ slug: 'hello-foo',
104
+ moduleId: 'hello-foo',
105
+ hostname: 'www.example.com',
106
+ path: '/foo',
107
+ });
108
+
109
+ const releases = planStaticContent(db);
110
+
111
+ // Reverse-proxy rows and rows without a hash are not releases.
112
+ expect(releases).toHaveLength(2);
113
+ const luna = releases.find((r) => r.slug === 'lunacycle');
114
+ expect(luna?.hostnames).toEqual(['lunacycle.net', 'www.example.com']);
115
+ expect(luna?.sourceDir).toBe(join(dir, 'modules', 'lunacycle', 'site', 'dist'));
116
+ expect(luna?.contentHash).toBe(HASH1);
117
+ });
118
+
119
+ it('a slug claimed by two modules is a contradiction, not a merge', () => {
120
+ insertRoute({ slug: 'about', moduleId: 'lunacycle', hostname: 'a.example.com' });
121
+ insertRoute({ slug: 'about', moduleId: 'hello-foo', hostname: 'b.example.com' });
122
+
123
+ expect(() => planStaticContent(db)).toThrow(/claimed by 2 modules/);
124
+ });
125
+
126
+ it('rows sharing a slug must share a hash', () => {
127
+ insertRoute({ slug: 'lunacycle', moduleId: 'lunacycle', hostname: 'lunacycle.net' });
128
+ insertRoute({
129
+ slug: 'lunacycle',
130
+ moduleId: 'lunacycle',
131
+ hostname: 'www.example.com',
132
+ path: '/x',
133
+ contentHash: HASH2,
134
+ });
135
+
136
+ expect(() => planStaticContent(db)).toThrow(/content hashes/);
137
+ });
138
+
139
+ it('a missing web root fails loudly instead of converging an empty release', () => {
140
+ insertRoute({ slug: 'lunacycle', moduleId: 'lunacycle', hostname: 'lunacycle.net' });
141
+ rmSync(join(dir, 'modules', 'lunacycle', 'site', 'dist'), { recursive: true, force: true });
142
+
143
+ expect(() => planStaticContent(db)).toThrow(/no built site/);
144
+ });
145
+ });
146
+
147
+ describe('resolveStaticContentRetention — operator config with a default', () => {
148
+ let dir: string;
149
+ let db: DbClient;
150
+
151
+ beforeEach(async () => {
152
+ dir = mkdtempSync(join(tmpdir(), 'static-retention-'));
153
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
154
+ db = await setupTestDatabaseAt(join(dir, 'celilo.db'));
155
+ db.insert(modules)
156
+ .values({
157
+ id: 'caddy',
158
+ name: 'Caddy',
159
+ version: '2.3.8',
160
+ manifestData: {
161
+ variables: { owns: [{ name: 'static_release_retention', default: 5 }] },
162
+ },
163
+ sourcePath: join(dir, 'modules', 'caddy'),
164
+ })
165
+ .onConflictDoNothing()
166
+ .run();
167
+ });
168
+
169
+ afterEach(() => {
170
+ db.$client.close();
171
+ process.env.CELILO_DB_PATH = undefined;
172
+ try {
173
+ rmSync(dir, { recursive: true, force: true });
174
+ } catch {
175
+ /* ignore */
176
+ }
177
+ });
178
+
179
+ it('operator config wins over the manifest default', () => {
180
+ db.insert(moduleConfigs)
181
+ .values({ moduleId: 'caddy', key: 'static_release_retention', value: '2', valueJson: '2' })
182
+ .run();
183
+ expect(resolveStaticContentRetention(db, 'caddy')).toBe(2);
184
+ });
185
+
186
+ it('falls back to the manifest default, then the constant', () => {
187
+ expect(resolveStaticContentRetention(db, 'caddy')).toBe(5);
188
+
189
+ db.update(modules).set({ manifestData: {} }).where(eq(modules.id, 'caddy')).run();
190
+ expect(resolveStaticContentRetention(db, 'caddy')).toBe(5);
191
+ });
192
+
193
+ it('rejects a retention that cannot keep the current release', () => {
194
+ db.insert(moduleConfigs)
195
+ .values({ moduleId: 'caddy', key: 'static_release_retention', value: '0', valueJson: '0' })
196
+ .run();
197
+ expect(() => resolveStaticContentRetention(db, 'caddy')).toThrow(/positive integer/);
198
+ });
199
+ });
200
+
201
+ describe('staticContentVarsYaml — deterministic desired state', () => {
202
+ it('carries slug, content_hash, hostnames and the derived source dir, keyed by slug', () => {
203
+ const yaml = staticContentVarsYaml({
204
+ retention: 3,
205
+ releases: [
206
+ {
207
+ slug: 'hello-foo',
208
+ contentHash: HASH2,
209
+ hostnames: ['www.example.com'],
210
+ sourceDir: '/data/modules/hello-foo/site/dist',
211
+ },
212
+ {
213
+ slug: 'lunacycle',
214
+ contentHash: HASH1,
215
+ hostnames: ['lunacycle.net', 'www.example.com'],
216
+ sourceDir: '/data/modules/lunacycle/site/dist',
217
+ },
218
+ ],
219
+ });
220
+
221
+ expect(yaml).toContain('static_release_retention: 3');
222
+ expect(yaml).toContain('slug: lunacycle');
223
+ expect(yaml).toContain(`content_hash: ${HASH1}`);
224
+ expect(yaml).toContain('source_dir: /data/modules/lunacycle/site/dist');
225
+ // Deterministic: same input, same bytes — the idempotence of the WRITE
226
+ // depends on it.
227
+ expect(yaml).toBe(
228
+ staticContentVarsYaml({
229
+ retention: 3,
230
+ releases: [
231
+ {
232
+ slug: 'hello-foo',
233
+ contentHash: HASH2,
234
+ hostnames: ['www.example.com'],
235
+ sourceDir: '/data/modules/hello-foo/site/dist',
236
+ },
237
+ {
238
+ slug: 'lunacycle',
239
+ contentHash: HASH1,
240
+ hostnames: ['lunacycle.net', 'www.example.com'],
241
+ sourceDir: '/data/modules/lunacycle/site/dist',
242
+ },
243
+ ],
244
+ }),
245
+ );
246
+ });
247
+ });
248
+
249
+ describe('writeStaticContentVars — lands in the generated inventory', () => {
250
+ let dir: string;
251
+ let db: DbClient;
252
+
253
+ beforeEach(async () => {
254
+ dir = mkdtempSync(join(tmpdir(), 'static-vars-'));
255
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
256
+ db = await setupTestDatabaseAt(join(dir, 'celilo.db'));
257
+ mkdirSync(join(dir, 'modules', 'caddy', 'site', 'dist'), { recursive: true });
258
+ writeFileSync(
259
+ join(dir, 'modules', 'caddy', 'site', 'dist', 'index.html'),
260
+ '<html>caddy</html>',
261
+ 'utf-8',
262
+ );
263
+ db.insert(modules)
264
+ .values({
265
+ id: 'caddy',
266
+ name: 'Caddy',
267
+ version: '2.3.8',
268
+ manifestData: {},
269
+ sourcePath: join(dir, 'modules', 'caddy'),
270
+ })
271
+ .onConflictDoNothing()
272
+ .run();
273
+ });
274
+
275
+ afterEach(() => {
276
+ db.$client.close();
277
+ process.env.CELILO_DB_PATH = undefined;
278
+ try {
279
+ rmSync(dir, { recursive: true, force: true });
280
+ } catch {
281
+ /* ignore */
282
+ }
283
+ });
284
+
285
+ it('writes the vars file and never writes into a module tree (task 4.7)', async () => {
286
+ db.insert(webRoutes)
287
+ .values({
288
+ slug: 'caddy',
289
+ moduleId: 'caddy',
290
+ type: 'static',
291
+ path: '/',
292
+ hostname: 'www.example.com',
293
+ websocket: false,
294
+ contentHash: HASH1,
295
+ })
296
+ .run();
297
+
298
+ const moduleTreeBefore = readFileSync(
299
+ join(dir, 'modules', 'caddy', 'site', 'dist', 'index.html'),
300
+ 'utf-8',
301
+ );
302
+ const result = await writeStaticContentVars(
303
+ db,
304
+ 'caddy',
305
+ join(dir, 'modules', 'caddy', 'generated'),
306
+ );
307
+
308
+ expect(result.success).toBe(true);
309
+ const varsPath = staticContentVarsPath(join(dir, 'modules', 'caddy', 'generated'));
310
+ expect(existsSync(varsPath)).toBe(true);
311
+ expect(readFileSync(varsPath, 'utf-8')).toContain('slug: caddy');
312
+
313
+ // Task 4.7: the converge path is read-only over the module tree. The
314
+ // clientConfig write into it is celilo#1018 and belongs to that thread —
315
+ // THIS path must not add a second writer.
316
+ expect(readFileSync(join(dir, 'modules', 'caddy', 'site', 'dist', 'index.html'), 'utf-8')).toBe(
317
+ moduleTreeBefore,
318
+ );
319
+ });
320
+
321
+ it('buildStaticContentPlan carries the resolved retention alongside the releases', async () => {
322
+ db.insert(webRoutes)
323
+ .values({
324
+ slug: 'caddy',
325
+ moduleId: 'caddy',
326
+ type: 'static',
327
+ path: '/',
328
+ hostname: 'www.example.com',
329
+ websocket: false,
330
+ contentHash: HASH1,
331
+ })
332
+ .run();
333
+
334
+ const plan = buildStaticContentPlan(db, 'caddy');
335
+ expect(plan.retention).toBe(5);
336
+ expect(plan.releases).toHaveLength(1);
337
+ });
338
+ });