@celilo/cli 1.14.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 (57) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +25 -3
  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-publish.test.ts +18 -0
  8. package/src/cli/commands/alerts-sweep.ts +3 -0
  9. package/src/cli/commands/monitor.ts +15 -2
  10. package/src/cli/commands/system-doctor.test.ts +121 -1
  11. package/src/cli/commands/system-doctor.ts +151 -1
  12. package/src/cli/completion.ts +9 -2
  13. package/src/cli/index.ts +1 -1
  14. package/src/console/control-plane-boundary.test.ts +82 -4
  15. package/src/db/schema.ts +0 -1
  16. package/src/hooks/capability-loader.ts +14 -0
  17. package/src/hooks/executor.ts +110 -17
  18. package/src/hooks/hook-jail-toolchain-reach.test.ts +224 -0
  19. package/src/hooks/hook-jail-unreachability.test.ts +28 -2
  20. package/src/hooks/hook-protocol.ts +44 -0
  21. package/src/hooks/hook-runner-entry.ts +23 -0
  22. package/src/hooks/hook-runner.ts +10 -0
  23. package/src/hooks/hook-trespass.test.ts +9 -3
  24. package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
  25. package/src/hooks/jail.test.ts +92 -0
  26. package/src/hooks/jail.ts +128 -11
  27. package/src/hooks/mount-set.test.ts +28 -6
  28. package/src/hooks/mount-set.ts +34 -20
  29. package/src/hooks/remote-broker.test.ts +350 -0
  30. package/src/hooks/remote-broker.ts +404 -0
  31. package/src/hooks/run-named-hook.ts +2 -0
  32. package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
  33. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
  34. package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
  35. package/src/hooks/unjailed-lint.test.ts +251 -0
  36. package/src/hooks/unjailed-lint.ts +395 -0
  37. package/src/policy/module-business-baseline.ts +13 -1
  38. package/src/policy/module-script-scan.ts +60 -1
  39. package/src/policy/no-hand-built-ssh.test.ts +39 -1
  40. package/src/policy/no-module-business-in-core.test.ts +1 -1
  41. package/src/services/alerting/hook-jail.test.ts +66 -0
  42. package/src/services/alerting/hook-jail.ts +70 -0
  43. package/src/services/alerting/run-monitor.test.ts +62 -0
  44. package/src/services/alerting/run-monitor.ts +12 -0
  45. package/src/services/alerting/sweep-runner.test.ts +1 -0
  46. package/src/services/backup-create.ts +3 -0
  47. package/src/services/backup-restore.ts +2 -0
  48. package/src/services/deploy-ansible.ts +9 -1
  49. package/src/services/health-runner.ts +2 -0
  50. package/src/services/module-build.test.ts +1 -64
  51. package/src/services/module-build.ts +10 -86
  52. package/src/services/module-deploy.ts +20 -0
  53. package/src/services/remote-access.test.ts +139 -0
  54. package/src/services/remote-access.ts +98 -0
  55. package/src/services/restore-from-file.ts +6 -1
  56. package/src/services/static-content-converge.test.ts +338 -0
  57. package/src/services/static-content-converge.ts +299 -0
@@ -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
+ }
@@ -54,6 +54,7 @@ import {
54
54
  refuseIfInFlight,
55
55
  startOperation,
56
56
  } from './module-operations';
57
+ import { remoteAccessPolicy } from './remote-access';
57
58
 
58
59
  export interface RestoreFromFileResult {
59
60
  success: boolean;
@@ -216,7 +217,11 @@ export async function restoreFromArtifactFile(
216
217
  {},
217
218
  {},
218
219
  logger,
219
- { debug: false, systems: getModuleSystems(mod.id, db) },
220
+ {
221
+ debug: false,
222
+ systems: getModuleSystems(mod.id, db),
223
+ remoteAccess: remoteAccessPolicy(mod.id, db),
224
+ },
220
225
  );
221
226
  if (!hookResult.success) {
222
227
  const errMsg = hookResult.error ?? 'on_restore hook failed';
@@ -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
+ });