@celilo/cli 1.14.0 → 2.1.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 (67) hide show
  1. package/CELILO_CORE_MODULES.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +26 -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 +15 -2
  17. package/src/hooks/executor.ts +116 -17
  18. package/src/hooks/hook-jail-toolchain-reach.test.ts +273 -0
  19. package/src/hooks/hook-jail-unreachability.test.ts +77 -26
  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 +74 -12
  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 +304 -32
  27. package/src/hooks/mount-set.test.ts +116 -7
  28. package/src/hooks/mount-set.ts +189 -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 +254 -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/coverage-source.test.ts +86 -0
  42. package/src/services/alerting/coverage-source.ts +11 -1
  43. package/src/services/alerting/hook-jail.test.ts +66 -0
  44. package/src/services/alerting/hook-jail.ts +70 -0
  45. package/src/services/alerting/run-monitor.test.ts +62 -0
  46. package/src/services/alerting/run-monitor.ts +12 -0
  47. package/src/services/alerting/sweep-runner.test.ts +1 -0
  48. package/src/services/backup-create.ts +3 -0
  49. package/src/services/backup-restore.ts +2 -0
  50. package/src/services/control-plane-bootstrap.test.ts +177 -0
  51. package/src/services/control-plane-bootstrap.ts +176 -0
  52. package/src/services/control-plane-health.test.ts +66 -0
  53. package/src/services/control-plane-health.ts +67 -0
  54. package/src/services/deploy-ansible.ts +9 -1
  55. package/src/services/deployed-systems.ts +12 -0
  56. package/src/services/dns-discovery.test.ts +93 -0
  57. package/src/services/dns-discovery.ts +92 -0
  58. package/src/services/fleet-checks.ts +6 -2
  59. package/src/services/health-runner.ts +36 -3
  60. package/src/services/module-build.test.ts +1 -64
  61. package/src/services/module-build.ts +10 -86
  62. package/src/services/module-deploy.ts +71 -0
  63. package/src/services/remote-access.test.ts +223 -0
  64. package/src/services/remote-access.ts +149 -0
  65. package/src/services/restore-from-file.ts +6 -1
  66. package/src/services/static-content-converge.test.ts +338 -0
  67. package/src/services/static-content-converge.ts +299 -0
@@ -0,0 +1,149 @@
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, machines, moduleConfigs, 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
+ if (managesPoolMachine(moduleId, target.ipv4_address, db)) {
64
+ return { allowed: true };
65
+ }
66
+
67
+ return { allowed: false, message: refusalMessage(moduleId, target.ipv4_address, db) };
68
+ },
69
+ };
70
+ }
71
+
72
+ /**
73
+ * A machine in celilo's pool that this module's own config names.
74
+ *
75
+ * ## The case this exists for
76
+ *
77
+ * A module that MANAGES a pre-existing box rather than being deployed onto one
78
+ * — `iptables` configuring the firewall is the archetype — provisions no system
79
+ * and therefore never owns one. `module_systems` records nothing for a
80
+ * config-only module at any point in its lifecycle, so the owned-systems branch
81
+ * above cannot ever pass for it. Without this, such a module has no sanctioned
82
+ * path to the one box it exists to configure, and no amount of waiting fixes it
83
+ * (celilo#1225).
84
+ *
85
+ * ## Why this is not a module granting itself reach
86
+ *
87
+ * Both halves are the OPERATOR's acts, and a module can supply neither:
88
+ *
89
+ * - The address is in the machine pool. `celilo machine add <ip> --ssh-key…`
90
+ * is the operator saying celilo may use its credential on that box. The
91
+ * fleet key's authority is exactly the set of boxes celilo was given, and
92
+ * this is that set — the same reasoning as the non-root carve-out above.
93
+ * - The module's own config names that address. `firewall_ip` is
94
+ * `source: user, required: true`; an operator typed it.
95
+ *
96
+ * A module declares which config KEYS exist, never their values, so it cannot
97
+ * point itself at a box the operator did not both admit to the pool and assign
98
+ * to it. Two independent operator decisions have to agree.
99
+ *
100
+ * ## What it deliberately does not do
101
+ *
102
+ * It does not allow every pool machine. That would let any module reach any box
103
+ * celilo knows, which is far wider than the rule this file states. The
104
+ * intersection is the point: pool membership alone is not authorization, and
105
+ * config alone is not either.
106
+ */
107
+ function managesPoolMachine(moduleId: string, ipv4Address: string, db: DbClient): boolean {
108
+ const inPool = db.select().from(machines).where(eq(machines.ipAddress, ipv4Address)).get();
109
+ if (!inPool) return false;
110
+
111
+ return db
112
+ .select({ value: moduleConfigs.value })
113
+ .from(moduleConfigs)
114
+ .where(eq(moduleConfigs.moduleId, moduleId))
115
+ .all()
116
+ .some((row) => row.value === ipv4Address);
117
+ }
118
+
119
+ /**
120
+ * Name the module, the target, and the capability route (task 5.2). The owner
121
+ * lookup is best-effort colour: the refusal stands whether or not celilo can
122
+ * say whose system it is.
123
+ */
124
+ function refusalMessage(moduleId: string, ipv4Address: string, db: DbClient): string {
125
+ const preamble = `Module '${moduleId}' may not reach ${ipv4Address}: it is not a system this module (or one of its instances) provisioned.`;
126
+ const guidance =
127
+ 'A hook reaches its own systems via ctx.systems and everything else through a capability (hook-process-boundary, design D12).';
128
+
129
+ const owner = db
130
+ .select()
131
+ .from(moduleSystems)
132
+ .where(eq(moduleSystems.ipv4Address, ipv4Address))
133
+ .get();
134
+ if (!owner) return `${preamble} ${guidance}`;
135
+
136
+ const provided = db
137
+ .select({ capabilityName: capabilities.capabilityName })
138
+ .from(capabilities)
139
+ .where(eq(capabilities.moduleId, owner.moduleId))
140
+ .all()
141
+ .map((row) => row.capabilityName);
142
+
143
+ const route =
144
+ provided.length > 0
145
+ ? `Reach it through a capability it provides ('${provided.join("', '")}').`
146
+ : "Reach it through a capability, never by SSH from another module's hook.";
147
+
148
+ return `${preamble} That system ('${owner.hostname}') belongs to module '${owner.moduleId}'. ${route}`;
149
+ }
@@ -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
+ });