@celilo/cli 3.1.0 → 4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "3.1.0",
3
+ "version": "4.0.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -405,9 +405,18 @@ export async function executeWorkspace(input: ExecuteWorkspaceInput): Promise<Pu
405
405
  // order, so this should be a no-op — but it catches operator typos
406
406
  // and any external `bun publish` invocation that bypasses the
407
407
  // ordered loop.
408
+ // Hand the guard the receipts for everything THIS RUN already
409
+ // published. npm's registry is eventually-consistent (measured 90s
410
+ // and 300s for two packages published two seconds apart), so without
411
+ // this the guard refuses to publish a package whose dependency the
412
+ // same run published successfully seconds earlier — celilo#1377.
408
413
  const checkResult = spawnSync('bun', [join(REPO_ROOT, 'scripts/check-publishable.ts'), pkg], {
409
414
  cwd: REPO_ROOT,
410
415
  stdio: 'inherit',
416
+ env: {
417
+ ...process.env,
418
+ CELILO_PUBLISHED_THIS_RUN: published.map((p) => `${p.name}@${p.version}`).join(','),
419
+ },
411
420
  });
412
421
  if (checkResult.status !== 0) {
413
422
  restorePackageJson(pkg, pkgJsonOriginal);
@@ -80,6 +80,51 @@ import { loadHookConfigMap } from './load-hook-config';
80
80
  * upstream-chain wiring takes a second factory argument (`upstreamFirewall`)
81
81
  * that doesn't fit the `defineCapabilityFunction` shape.
82
82
  */
83
+ /**
84
+ * Make the public_web provider re-render and converge, and insist it happened.
85
+ *
86
+ * Both triggers that change what the provider must serve come through here: a
87
+ * route registered or withdrawn, and a static site published. They used to be
88
+ * two different mechanisms — an event for routes, a direct core-side content
89
+ * converge for publishes — which is how a publish could converge content
90
+ * against a config the provider had not re-rendered. One path now
91
+ * (providers-converge-declared-state, design D6).
92
+ *
93
+ * Authoritative (ISS-0081): a change the provider never reconciled is a
94
+ * FAILURE, not a warning. These used to be logger.warn while the call returned
95
+ * success anyway, so a consumer could report "ready" while caddy never learned
96
+ * the hostname — no site block, no cert, TLS internal_error for clients.
97
+ */
98
+ async function requireProviderReconcile(consumingModuleId: string, what: string): Promise<void> {
99
+ const reconcile = await emitWebRoutesChangedAndWait(consumingModuleId);
100
+
101
+ if (reconcile.noDispatcher) {
102
+ throw new Error(
103
+ `public_web ${what} for ${consumingModuleId} was persisted but NOT delivered to the provider (caddy): no event dispatcher is running, so caddy never reconciled and the hostname has no site block or cert. Run this through \`celilo module deploy\` (which runs the dispatcher) rather than a bare hook.`,
104
+ );
105
+ }
106
+ if (reconcile.timedOut) {
107
+ throw new Error(
108
+ `public_web reconcile for ${consumingModuleId} (${what}) did not finish within the deadline (${reconcile.succeeded} ok, ${reconcile.failed} failed of ${reconcile.events} change event(s)) — caddy did not confirm the change is live.`,
109
+ );
110
+ }
111
+ if (reconcile.failed > 0) {
112
+ throw new Error(
113
+ `public_web reconcile for ${consumingModuleId} (${what}): ${reconcile.failed} delivery(ies) failed — caddy could not apply the change, so the hostname is not served as declared.`,
114
+ );
115
+ }
116
+ // ISS-0087: a change WAS persisted and the dispatcher IS alive, yet ZERO
117
+ // providers reconciled it (no subscriber consumed routes_changed). That
118
+ // would otherwise report success for something nobody applied. `events === 0`
119
+ // means nothing changed (benign); `events > 0 && succeeded === 0` means it
120
+ // changed and nobody served it — a failure.
121
+ if (reconcile.events > 0 && reconcile.succeeded === 0) {
122
+ throw new Error(
123
+ `public_web ${what} for ${consumingModuleId} changed (${reconcile.events} event(s)) but NO provider reconciled it — caddy has no reconcile_routes subscription on the bus, so the change is persisted yet never served. Ensure a public_web provider (caddy) is deployed and subscribed.`,
124
+ );
125
+ }
126
+ }
127
+
83
128
  export const CAPABILITY_MODULE_MAP: Record<string, { script: string; legacyFactoryName: string }> =
84
129
  {
85
130
  dns_registrar: {
@@ -646,19 +691,26 @@ export async function loadCapabilityFunctions(
646
691
  caddyModuleId: provider.moduleId,
647
692
  dnsManagedDomains,
648
693
  dnsRegistrarModuleId,
649
- // Design D10: the bytes move through the provider's Ansible converge,
650
- // not a hand-built ssh tar pipe. Core implements it over
651
- // executeAnsible against the provider's generated project (task 4.3);
652
- // awaited with everything else, so a publish returns only once the
653
- // host matches. A failure here fails the deploy loudly — a publish
694
+ // The bytes move through the provider's Ansible converge, not a
695
+ // hand-built ssh tar pipe (design D10). What changed in
696
+ // providers-converge-declared-state is WHO decides what to converge:
697
+ // core used to plan the whole fleet's release set here and throw when
698
+ // any one module had no built site (celilo#1383). Now the publish
699
+ // makes the PROVIDER re-render from the declared rows — the same path
700
+ // a route change takes — so content and config converge together and a
701
+ // broken consumer costs only its own site.
702
+ //
703
+ // The field keeps its name: it is passed into `createPublicWeb` inside
704
+ // the CONSUMER's bundled copy of @celilo/capabilities, and a module
705
+ // running an older bundle calls `deps.convergeStaticContent()` by that
706
+ // name. Renaming it here would leave that call undefined on every
707
+ // module that has not reinstalled.
708
+ //
709
+ // Awaited, so a publish returns only once the host matches: a publish
654
710
  // that reports ready while the host never received the bytes is the
655
711
  // exact "served but silently unreachable" anti-pattern.
656
712
  convergeStaticContent: async () => {
657
- const { convergeStaticContent } = await import('../services/static-content-converge');
658
- const result = await convergeStaticContent(db, provider.moduleId, consumingModuleId);
659
- if (!result.success) {
660
- throw new Error(result.error ?? 'static-content converge failed');
661
- }
713
+ await requireProviderReconcile(consumingModuleId, 'static publish');
662
714
  },
663
715
  // ISS-0035: register_route/unregister_routes emit this coarse signal
664
716
  // instead of SSHing caddy; the caddy provider's reconcile_routes
@@ -667,37 +719,7 @@ export async function loadCapabilityFunctions(
667
719
  // the consuming module's health_check runs right after and would
668
720
  // otherwise race the async reconcile.
669
721
  onRoutesChanged: async () => {
670
- const reconcile = await emitWebRoutesChangedAndWait(consumingModuleId);
671
- // Authoritative (ISS-0081): a route the provider (caddy) never
672
- // reconciled is a deploy FAILURE, not a warning. These used to be
673
- // logger.warn while register_route returned success anyway, so a
674
- // consumer could report "ready" while caddy never learned the
675
- // hostname — no site block, no cert, TLS internal_error for clients.
676
- if (reconcile.noDispatcher) {
677
- throw new Error(
678
- `public_web route for ${consumingModuleId} was persisted but NOT delivered to the provider (caddy): no event dispatcher is running, so caddy never reconciled and the hostname has no site block or cert. Run this through \`celilo module deploy\` (which runs the dispatcher) rather than a bare hook.`,
679
- );
680
- }
681
- if (reconcile.timedOut) {
682
- throw new Error(
683
- `public_web reconcile for ${consumingModuleId} did not finish within the deadline (${reconcile.succeeded} ok, ${reconcile.failed} failed of ${reconcile.events} change event(s)) — caddy did not confirm the route is live.`,
684
- );
685
- }
686
- if (reconcile.failed > 0) {
687
- throw new Error(
688
- `public_web reconcile for ${consumingModuleId}: ${reconcile.failed} delivery(ies) failed — caddy could not apply the route, so the hostname is not served.`,
689
- );
690
- }
691
- // ISS-0087: a route WAS registered and the dispatcher IS alive, yet ZERO
692
- // providers reconciled it (no subscriber consumed routes_changed). That
693
- // registers a route nobody applied and would otherwise report success.
694
- // `events === 0` means no routes changed (benign); `events > 0 &&
695
- // succeeded === 0` means the route changed but nobody served it — a failure.
696
- if (reconcile.events > 0 && reconcile.succeeded === 0) {
697
- throw new Error(
698
- `public_web route for ${consumingModuleId} changed (${reconcile.events} event(s)) but NO provider reconciled it — caddy has no reconcile_routes subscription on the bus, so the route is persisted yet never served (no site block, no cert). Ensure a public_web provider (caddy) is deployed and subscribed.`,
699
- );
700
- }
722
+ await requireProviderReconcile(consumingModuleId, 'route change');
701
723
  },
702
724
  });
703
725
  debugLog(`public_web: loaded via framework implementation for ${consumingModuleId}`);
@@ -734,6 +756,22 @@ export async function loadCapabilityFunctions(
734
756
  const { resolveModuleStateWebRoot, resolveModuleWebRoot } = await import(
735
757
  '../module/web-root'
736
758
  );
759
+
760
+ // Pause preserves state (module-pause spec), so a paused module's
761
+ // site is not re-converged. Leaving it out of the provider's site
762
+ // list is exactly that: the role converges only the slugs it is
763
+ // given and prunes nothing else, so the release already on the box
764
+ // stays, and the module's routes still render into the config.
765
+ //
766
+ // This is where d04a2bb7's paused filter went when core's fleet-wide
767
+ // plan was deleted. It moved rather than vanished, and it moved to
768
+ // the per-module lookup, which is the only place that can answer for
769
+ // one module without walking the rest.
770
+ const { isModulePaused } = await import('../services/module-pause');
771
+ if (isModulePaused(db, moduleId)) {
772
+ return { unavailable: `module '${moduleId}' is paused — its site is left as it is` };
773
+ }
774
+
737
775
  const sourceDir = resolveModuleWebRoot(moduleId, db);
738
776
  if (!sourceDir) {
739
777
  return { unavailable: `module '${moduleId}' is not installed` };
@@ -749,9 +787,8 @@ export async function loadCapabilityFunctions(
749
787
  return { sourceDir, ...overlayDir };
750
788
  },
751
789
  converge: async (artifacts) => {
752
- const { convergeProviderConfig } = await import('../services/provider-converge');
753
- const { resolveStaticContentRetention } = await import(
754
- '../services/static-content-converge'
790
+ const { convergeProviderConfig, resolveStaticContentRetention } = await import(
791
+ '../services/provider-converge'
755
792
  );
756
793
  const result = await convergeProviderConfig(db, providerModuleId, {
757
794
  ...artifacts,
@@ -514,10 +514,4 @@ export const PROVIDER_LITERAL_BASELINE: readonly ProviderLiteralRow[] = [
514
514
  count: 3,
515
515
  why: "X8 — the Caddyfile generator knows caddy's on-disk asset layout (#940)",
516
516
  },
517
- {
518
- file: 'apps/celilo/src/services/static-content-converge.ts',
519
- literal: '/srv/www',
520
- count: 1,
521
- why: "D10 (capability-owned-tables stage 4): the converge's slug-collision error names the on-disk release path an operator must fix. The role under modules/caddy owns the real path handling; this is message text, not path logic.",
522
- },
523
517
  ];
@@ -1474,19 +1474,33 @@ async function deployModuleImpl(
1474
1474
  }
1475
1475
  }
1476
1476
 
1477
- // The provider's own deploy runs the static-content converge alongside
1478
- // everything else (design D10, task 4.5): the release set is written into
1479
- // the generated inventory, the playbook's static_content-tagged tasks
1480
- // converge /srv/www, and a rebuilt host recovers with no consumer
1481
- // involvement. Same plan + writer the publish-time converge uses — one
1482
- // code path, two callers.
1477
+ // A public_web provider's deploy no longer plans the fleet's static content
1478
+ // here. Core used to walk every declared route row, resolve every module's
1479
+ // built site, and throw for the whole deploy when any one of them was
1480
+ // missing — celilo#1383, where two out-of-tree modules made the public
1481
+ // ingress undeployable. The provider now renders its own desired state in
1482
+ // its hook and hands it to the converge, which runs the same role tasks
1483
+ // (providers-converge-declared-state, design D4/D6). on_install runs
1484
+ // immediately after this play, so /srv/www still converges on a rebuilt
1485
+ // host — through one path instead of two.
1486
+ //
1487
+ // A provider whose role predates that converge would deploy cleanly and
1488
+ // then never converge its static content again: core no longer writes the
1489
+ // release set, and the old role has no task that reads what the provider
1490
+ // rendered. Nothing would report it — the deploy succeeds, the publish
1491
+ // succeeds, and /srv/www silently stops tracking what is declared. So the
1492
+ // version skew is refused here, by the same tree scan the converge itself
1493
+ // uses, and the message names the upgrade that fixes it.
1483
1494
  if (manifest.provides?.capabilities?.some((cap) => cap.name === 'public_web')) {
1484
- const { writeStaticContentVars } = await import('./static-content-converge');
1485
- const staticVars = await writeStaticContentVars(db, moduleId, generatedPath);
1486
- if (!staticVars.success) {
1487
- return { success: false, error: staticVars.error, phases };
1495
+ const { ansibleTreeMentions } = await import('./provider-converge');
1496
+ const ansiblePath = join(generatedPath, 'ansible');
1497
+ if (existsSync(ansiblePath) && !ansibleTreeMentions(ansiblePath, 'provider_config_files')) {
1498
+ return {
1499
+ success: false,
1500
+ error: `The installed version of '${moduleId}' predates the provider converge: its role never reads provider_config_files, so celilo would deploy it and then have no way to place the config or the sites it renders. Upgrade it first (\`celilo module upgrade ${moduleId}\`), then deploy.`,
1501
+ phases,
1502
+ };
1488
1503
  }
1489
- log.success('Static-content release set written to the inventory');
1490
1504
  }
1491
1505
 
1492
1506
  try {
@@ -138,6 +138,22 @@ describe('recordUnresolvedConsumers — the failure belongs to the consumer', ()
138
138
  return db.select().from(modules).where(eq(modules.id, id)).get();
139
139
  }
140
140
 
141
+ it('leaves a PAUSED consumer paused — a provider cannot render it by design', () => {
142
+ db.update(modules).set({ state: 'PAUSED' }).where(eq(modules.id, 'byoi')).run();
143
+
144
+ const recorded = recordUnresolvedConsumers(db, 'caddy', [
145
+ { moduleId: 'byoi', reason: "module 'byoi' is paused — its site is left as it is" },
146
+ ]);
147
+
148
+ // Pause preserves state, and that includes the module's own recorded
149
+ // state. A paused module is unresolvable on EVERY converge, so marking it
150
+ // ERROR here would mean an operator could not pause a module without the
151
+ // next publish reporting it failed.
152
+ expect(recorded).toEqual([]);
153
+ expect(stateOf('byoi')?.state).toBe('PAUSED');
154
+ expect(stateOf('byoi')?.errorMessage ?? null).toBeNull();
155
+ });
156
+
141
157
  it('records the consumer in ERROR with the reason it could not be rendered', () => {
142
158
  recordUnresolvedConsumers(db, 'caddy', [
143
159
  { moduleId: 'byoi', reason: 'no built site at /var/celilo/modules/byoi/site/dist' },
@@ -15,14 +15,22 @@
15
15
  * against that consumer.
16
16
  */
17
17
 
18
- import { existsSync, rmSync } from 'node:fs';
18
+ import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
19
19
  import { mkdir, writeFile } from 'node:fs/promises';
20
20
  import { join } from 'node:path';
21
- import { eq } from 'drizzle-orm';
21
+ import { and, eq } from 'drizzle-orm';
22
22
  import { stringify as stringifyYaml } from 'yaml';
23
23
  import type { DbClient } from '../db/client';
24
- import { modules } from '../db/schema';
25
- import { ansibleTreeMentions } from './static-content-converge';
24
+ import { moduleConfigs, modules } from '../db/schema';
25
+ import { parseStoredConfigValue } from './module-config';
26
+
27
+ /**
28
+ * Retention when the operator has not set one. Mirrors the manifest default
29
+ * declared on the provider module (`static_release_retention`); the manifest
30
+ * is the operator-facing source of truth, this only covers a provider whose
31
+ * manifest predates the variable.
32
+ */
33
+ const DEFAULT_STATIC_RELEASE_RETENTION = 5;
26
34
 
27
35
  /** One file the provider wants on its host, rendered by the provider. */
28
36
  export interface ProviderConfigFile {
@@ -121,6 +129,14 @@ export function recordUnresolvedConsumers(
121
129
  const existing = db.select().from(modules).where(eq(modules.id, consumer.moduleId)).get();
122
130
  if (!existing) continue;
123
131
 
132
+ // A PAUSED module is not broken, and pause preserves state — including the
133
+ // module's own recorded state. A provider legitimately cannot render a
134
+ // paused consumer (its site is deliberately left alone), so this path is
135
+ // reached on every converge while any module is paused; writing ERROR
136
+ // there would mean an operator could not pause a module without the next
137
+ // publish marking it failed.
138
+ if (existing.state === 'PAUSED') continue;
139
+
124
140
  db.update(modules)
125
141
  .set({
126
142
  state: 'ERROR',
@@ -239,3 +255,89 @@ export async function convergeProviderConfig(
239
255
  rmSync(generatedPath, { recursive: true, force: true });
240
256
  return { success: true, varsPath, unresolved };
241
257
  }
258
+
259
+ /**
260
+ * Resolve the retention count for the provider module.
261
+ *
262
+ * Operator config first (`static_release_retention` module config), then the
263
+ * manifest's declared default, then the constant above. ONE resolution — the
264
+ * role reads the variable bare and never carries a literal.
265
+ */
266
+ export function resolveStaticContentRetention(db: DbClient, providerModuleId: string): number {
267
+ const configRow = db
268
+ .select()
269
+ .from(moduleConfigs)
270
+ .where(
271
+ and(
272
+ eq(moduleConfigs.moduleId, providerModuleId),
273
+ eq(moduleConfigs.key, 'static_release_retention'),
274
+ ),
275
+ )
276
+ .get();
277
+ if (configRow) {
278
+ const parsed = parseStoredConfigValue(configRow);
279
+ const count = typeof parsed === 'number' ? parsed : Number.parseInt(String(parsed), 10);
280
+ if (Number.isInteger(count) && count >= 1) return count;
281
+ throw new Error(
282
+ `static_release_retention for ${providerModuleId} must be a positive integer, got: ${String(parsed)}`,
283
+ );
284
+ }
285
+
286
+ const module = db.select().from(modules).where(eq(modules.id, providerModuleId)).get();
287
+ if (module) {
288
+ const manifest = module.manifestData as {
289
+ variables?: { owns?: Array<{ name?: string; default?: unknown }> };
290
+ } | null;
291
+ const declared = manifest?.variables?.owns?.find((v) => v?.name === 'static_release_retention');
292
+ const fallback = typeof declared?.default === 'number' ? declared.default : undefined;
293
+ if (typeof fallback === 'number' && Number.isInteger(fallback) && fallback >= 1)
294
+ return fallback;
295
+ }
296
+
297
+ return DEFAULT_STATIC_RELEASE_RETENTION;
298
+ }
299
+
300
+ /**
301
+ * Build the release set from the declared `web_routes` rows.
302
+ *
303
+ * Pure: reads the DB, decides nothing on the host (Rule 10.4). Grouping is by
304
+ * slug, NOT by route — one slug is one release directory, and a slug can span
305
+ * hostnames (lunacycle's two routes, both at `/`). A group that spans modules
306
+ * or disagrees with itself about the hash is a contradiction the converge
307
+ * cannot express, so it fails here with both rows named rather than silently
308
+ * converging one of them.
309
+ */
310
+
311
+ /**
312
+ * Whether any text file in a generated Ansible tree mentions `needle`.
313
+ *
314
+ * Scans the TREE rather than `playbook.yml`, because a provider's converge
315
+ * belongs in one of its role's task files and a generated `playbook.yml` is a
316
+ * thin hosts/vars/roles stanza that names no variable at all. caddy is the
317
+ * worked example: `static_releases` appears in
318
+ * `roles/caddy/tasks/static-content.yml`, `.../main.yml.tpl` and
319
+ * `.../converge-release.yml`, and zero times in its playbook.
320
+ *
321
+ * @psbanka - 2026-09: this used to read `ansible/playbook.yml` alone, which
322
+ * could not reach the string it was looking for. The guard below therefore
323
+ * refused EVERY provider that had the converge support, and its remedy told the
324
+ * operator to redeploy — which regenerates the same thin playbook and cannot
325
+ * help. Guard and role tasks landed in the same commit (1fd3595e), so it had
326
+ * never once passed, and it held three e2e suites red where nobody was looking.
327
+ * celilo#1248.
328
+ */
329
+ export function ansibleTreeMentions(dir: string, needle: string): boolean {
330
+ if (!existsSync(dir)) return false;
331
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
332
+ const full = join(dir, entry.name);
333
+ if (entry.isDirectory()) {
334
+ if (ansibleTreeMentions(full, needle)) return true;
335
+ continue;
336
+ }
337
+ // Text only. A generated tree can carry static assets, and reading those
338
+ // as utf-8 to look for a variable name is waste at best.
339
+ if (!/\.(ya?ml|j2|tpl|cfg|ini|conf)$/.test(entry.name)) continue;
340
+ if (readFileSync(full, 'utf-8').includes(needle)) return true;
341
+ }
342
+ return false;
343
+ }
@@ -1,516 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
- import {
3
- cpSync,
4
- existsSync,
5
- mkdirSync,
6
- mkdtempSync,
7
- readFileSync,
8
- rmSync,
9
- writeFileSync,
10
- } from 'node:fs';
11
- import { tmpdir } from 'node:os';
12
- import { join, resolve } from 'node:path';
13
- import { eq } from 'drizzle-orm';
14
- import type { DbClient } from '../db/client';
15
- import { moduleConfigs, modules, webRoutes } from '../db/schema';
16
- import { setupTestDatabaseAt } from '../test-utils/database';
17
- import { resetTestDbPath } from '../test-utils/db-path';
18
- import {
19
- ansibleTreeMentions,
20
- buildStaticContentPlan,
21
- planStaticContent,
22
- resolveStaticContentRetention,
23
- staticContentVarsPath,
24
- staticContentVarsYaml,
25
- writeStaticContentVars,
26
- } from './static-content-converge';
27
-
28
- const HASH1 = 'a'.repeat(64);
29
- const HASH2 = 'b'.repeat(64);
30
-
31
- describe('planStaticContent — the declared release set (D10 task 4.1)', () => {
32
- let dir: string;
33
- let db: DbClient;
34
-
35
- beforeEach(async () => {
36
- dir = mkdtempSync(join(tmpdir(), 'static-converge-'));
37
- const dbPath = join(dir, 'celilo.db');
38
- process.env.CELILO_DB_PATH = dbPath;
39
- db = await setupTestDatabaseAt(dbPath);
40
-
41
- // Two installed modules with real web roots on disk.
42
- writeSite('lunacycle', '<html>luna</html>');
43
- writeSite('hello-foo', '<html>foo</html>');
44
- db.insert(modules)
45
- .values({
46
- id: 'lunacycle',
47
- name: 'Lunacycle',
48
- version: '1.0.0',
49
- manifestData: {},
50
- sourcePath: join(dir, 'modules', 'lunacycle'),
51
- })
52
- .onConflictDoNothing()
53
- .run();
54
- db.insert(modules)
55
- .values({
56
- id: 'hello-foo',
57
- name: 'Hello Foo',
58
- version: '0.1.2',
59
- manifestData: {},
60
- sourcePath: join(dir, 'modules', 'hello-foo'),
61
- })
62
- .onConflictDoNothing()
63
- .run();
64
- });
65
-
66
- afterEach(() => {
67
- db.$client.close();
68
- resetTestDbPath();
69
- try {
70
- rmSync(dir, { recursive: true, force: true });
71
- } catch {
72
- /* ignore */
73
- }
74
- });
75
-
76
- function writeSite(moduleId: string, html: string): string {
77
- const webRoot = join(dir, 'modules', moduleId, 'site', 'dist');
78
- mkdirSync(webRoot, { recursive: true });
79
- writeFileSync(join(webRoot, 'index.html'), html, 'utf-8');
80
- return webRoot;
81
- }
82
-
83
- function insertRoute(route: {
84
- slug: string;
85
- moduleId: string;
86
- hostname: string;
87
- path?: string;
88
- type?: 'static' | 'reverse_proxy';
89
- contentHash?: string | null;
90
- }) {
91
- db.insert(webRoutes)
92
- .values({
93
- slug: route.slug,
94
- moduleId: route.moduleId,
95
- type: route.type ?? 'static',
96
- path: route.path ?? '/',
97
- hostname: route.hostname,
98
- websocket: false,
99
- contentHash: route.contentHash ?? HASH1,
100
- })
101
- .run();
102
- }
103
-
104
- it('groups by slug across hostnames and derives the source dir', () => {
105
- insertRoute({ slug: 'lunacycle', moduleId: 'lunacycle', hostname: 'lunacycle.net' });
106
- insertRoute({
107
- slug: 'lunacycle',
108
- moduleId: 'lunacycle',
109
- hostname: 'www.example.com',
110
- path: '/x',
111
- });
112
- insertRoute({
113
- slug: 'hello-foo',
114
- moduleId: 'hello-foo',
115
- hostname: 'www.example.com',
116
- path: '/foo',
117
- });
118
-
119
- const releases = planStaticContent(db);
120
-
121
- // Reverse-proxy rows and rows without a hash are not releases.
122
- expect(releases).toHaveLength(2);
123
- const luna = releases.find((r) => r.slug === 'lunacycle');
124
- expect(luna?.hostnames).toEqual(['lunacycle.net', 'www.example.com']);
125
- expect(luna?.sourceDir).toBe(join(dir, 'modules', 'lunacycle', 'site', 'dist'));
126
- expect(luna?.contentHash).toBe(HASH1);
127
- });
128
-
129
- it('a slug claimed by two modules is a contradiction, not a merge', () => {
130
- insertRoute({ slug: 'about', moduleId: 'lunacycle', hostname: 'a.example.com' });
131
- insertRoute({ slug: 'about', moduleId: 'hello-foo', hostname: 'b.example.com' });
132
-
133
- expect(() => planStaticContent(db)).toThrow(/claimed by 2 modules/);
134
- });
135
-
136
- it('rows sharing a slug must share a hash', () => {
137
- insertRoute({ slug: 'lunacycle', moduleId: 'lunacycle', hostname: 'lunacycle.net' });
138
- insertRoute({
139
- slug: 'lunacycle',
140
- moduleId: 'lunacycle',
141
- hostname: 'www.example.com',
142
- path: '/x',
143
- contentHash: HASH2,
144
- });
145
-
146
- expect(() => planStaticContent(db)).toThrow(/content hashes/);
147
- });
148
-
149
- it('a missing web root fails loudly instead of converging an empty release', () => {
150
- insertRoute({ slug: 'lunacycle', moduleId: 'lunacycle', hostname: 'lunacycle.net' });
151
- rmSync(join(dir, 'modules', 'lunacycle', 'site', 'dist'), { recursive: true, force: true });
152
-
153
- expect(() => planStaticContent(db)).toThrow(/no built site/);
154
- });
155
-
156
- function pause(moduleId: string) {
157
- db.update(modules).set({ state: 'PAUSED' }).where(eq(modules.id, moduleId)).run();
158
- }
159
-
160
- it("a paused module's slug is left alone, even one that would throw, and the rest converge (celilo#1383)", () => {
161
- insertRoute({ slug: 'lunacycle', moduleId: 'lunacycle', hostname: 'lunacycle.net' });
162
- insertRoute({
163
- slug: 'lunacycle',
164
- moduleId: 'lunacycle',
165
- hostname: 'www.lunacycle.net',
166
- contentHash: HASH2,
167
- });
168
- rmSync(join(dir, 'modules', 'lunacycle', 'site', 'dist'), { recursive: true, force: true });
169
- insertRoute({
170
- slug: 'hello-foo',
171
- moduleId: 'hello-foo',
172
- hostname: 'www.example.com',
173
- path: '/foo',
174
- });
175
- pause('lunacycle');
176
-
177
- expect(planStaticContent(db).map((r) => r.slug)).toEqual(['hello-foo']);
178
- });
179
-
180
- it('a paused module that is publishing (an unpause redeploy) still converges its own slug', () => {
181
- insertRoute({ slug: 'lunacycle', moduleId: 'lunacycle', hostname: 'lunacycle.net' });
182
- pause('lunacycle');
183
-
184
- expect(planStaticContent(db, 'lunacycle').map((r) => r.slug)).toEqual(['lunacycle']);
185
- });
186
-
187
- it('a slug shared by a paused and an active module is still a contradiction', () => {
188
- insertRoute({ slug: 'about', moduleId: 'lunacycle', hostname: 'a.example.com' });
189
- insertRoute({ slug: 'about', moduleId: 'hello-foo', hostname: 'b.example.com' });
190
- pause('lunacycle');
191
-
192
- expect(() => planStaticContent(db)).toThrow(/claimed by 2 modules/);
193
- });
194
- });
195
-
196
- describe('resolveStaticContentRetention — operator config with a default', () => {
197
- let dir: string;
198
- let db: DbClient;
199
-
200
- beforeEach(async () => {
201
- dir = mkdtempSync(join(tmpdir(), 'static-retention-'));
202
- process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
203
- db = await setupTestDatabaseAt(join(dir, 'celilo.db'));
204
- db.insert(modules)
205
- .values({
206
- id: 'caddy',
207
- name: 'Caddy',
208
- version: '2.3.8',
209
- manifestData: {
210
- variables: { owns: [{ name: 'static_release_retention', default: 5 }] },
211
- },
212
- sourcePath: join(dir, 'modules', 'caddy'),
213
- })
214
- .onConflictDoNothing()
215
- .run();
216
- });
217
-
218
- afterEach(() => {
219
- db.$client.close();
220
- resetTestDbPath();
221
- try {
222
- rmSync(dir, { recursive: true, force: true });
223
- } catch {
224
- /* ignore */
225
- }
226
- });
227
-
228
- it('operator config wins over the manifest default', () => {
229
- db.insert(moduleConfigs)
230
- .values({ moduleId: 'caddy', key: 'static_release_retention', value: '2', valueJson: '2' })
231
- .run();
232
- expect(resolveStaticContentRetention(db, 'caddy')).toBe(2);
233
- });
234
-
235
- it('falls back to the manifest default, then the constant', () => {
236
- expect(resolveStaticContentRetention(db, 'caddy')).toBe(5);
237
-
238
- db.update(modules).set({ manifestData: {} }).where(eq(modules.id, 'caddy')).run();
239
- expect(resolveStaticContentRetention(db, 'caddy')).toBe(5);
240
- });
241
-
242
- it('rejects a retention that cannot keep the current release', () => {
243
- db.insert(moduleConfigs)
244
- .values({ moduleId: 'caddy', key: 'static_release_retention', value: '0', valueJson: '0' })
245
- .run();
246
- expect(() => resolveStaticContentRetention(db, 'caddy')).toThrow(/positive integer/);
247
- });
248
- });
249
-
250
- describe('staticContentVarsYaml — deterministic desired state', () => {
251
- it('carries slug, content_hash, hostnames and the derived source dir, keyed by slug', () => {
252
- const yaml = staticContentVarsYaml({
253
- retention: 3,
254
- releases: [
255
- {
256
- slug: 'hello-foo',
257
- contentHash: HASH2,
258
- hostnames: ['www.example.com'],
259
- sourceDir: '/data/modules/hello-foo/site/dist',
260
- },
261
- {
262
- slug: 'lunacycle',
263
- contentHash: HASH1,
264
- hostnames: ['lunacycle.net', 'www.example.com'],
265
- sourceDir: '/data/modules/lunacycle/site/dist',
266
- },
267
- ],
268
- });
269
-
270
- expect(yaml).toContain('static_release_retention: 3');
271
- expect(yaml).toContain('slug: lunacycle');
272
- expect(yaml).toContain(`content_hash: ${HASH1}`);
273
- expect(yaml).toContain('source_dir: /data/modules/lunacycle/site/dist');
274
- // Deterministic: same input, same bytes — the idempotence of the WRITE
275
- // depends on it.
276
- expect(yaml).toBe(
277
- staticContentVarsYaml({
278
- retention: 3,
279
- releases: [
280
- {
281
- slug: 'hello-foo',
282
- contentHash: HASH2,
283
- hostnames: ['www.example.com'],
284
- sourceDir: '/data/modules/hello-foo/site/dist',
285
- },
286
- {
287
- slug: 'lunacycle',
288
- contentHash: HASH1,
289
- hostnames: ['lunacycle.net', 'www.example.com'],
290
- sourceDir: '/data/modules/lunacycle/site/dist',
291
- },
292
- ],
293
- }),
294
- );
295
- });
296
- });
297
-
298
- describe('the state web overlay (celilo#1265)', () => {
299
- let dir: string;
300
- let db: DbClient;
301
-
302
- beforeEach(async () => {
303
- dir = mkdtempSync(join(tmpdir(), 'static-converge-'));
304
- const dbPath = join(dir, 'celilo.db');
305
- process.env.CELILO_DB_PATH = dbPath;
306
- db = await setupTestDatabaseAt(dbPath);
307
- writeSite('lunacycle', '<html>luna</html>');
308
- db.insert(modules)
309
- .values({
310
- id: 'lunacycle',
311
- name: 'Lunacycle',
312
- version: '1.0.0',
313
- manifestData: {},
314
- sourcePath: join(dir, 'modules', 'lunacycle'),
315
- })
316
- .onConflictDoNothing()
317
- .run();
318
- });
319
-
320
- afterEach(() => {
321
- db.$client.close();
322
- resetTestDbPath();
323
- try {
324
- rmSync(dir, { recursive: true, force: true });
325
- } catch {
326
- /* ignore */
327
- }
328
- });
329
-
330
- function writeSite(moduleId: string, html: string): string {
331
- const webRoot = join(dir, 'modules', moduleId, 'site', 'dist');
332
- mkdirSync(webRoot, { recursive: true });
333
- writeFileSync(join(webRoot, 'index.html'), html, 'utf-8');
334
- return webRoot;
335
- }
336
-
337
- function insertRoute(route: { slug: string; moduleId: string; hostname: string }) {
338
- db.insert(webRoutes)
339
- .values({
340
- slug: route.slug,
341
- moduleId: route.moduleId,
342
- type: 'static',
343
- path: '/',
344
- hostname: route.hostname,
345
- websocket: false,
346
- contentHash: HASH1,
347
- })
348
- .run();
349
- }
350
-
351
- it('a release with a state overlay carries its directory; one without does not', () => {
352
- insertRoute({ slug: 'lunacycle', moduleId: 'lunacycle', hostname: 'lunacycle.net' });
353
-
354
- // No overlay in state: the release names no overlay_dir, and the converge
355
- // role has nothing second to copy.
356
- const bare = planStaticContent(db);
357
- expect(bare).toHaveLength(1);
358
- expect(bare[0]?.overlayDir).toBeUndefined();
359
- expect(staticContentVarsYaml({ retention: 1, releases: bare })).not.toContain('overlay_dir');
360
-
361
- // The hook wrote generated files into `<module>/state/site`: the converge
362
- // must copy them over the release, or a rebuilt host loses the file.
363
- const overlay = join(dir, 'modules', 'lunacycle', 'state', 'site');
364
- mkdirSync(overlay, { recursive: true });
365
- writeFileSync(join(overlay, 'ca.crt'), '---CERT---', 'utf-8');
366
-
367
- const overlaid = planStaticContent(db);
368
- expect(overlaid[0]?.overlayDir).toBe(overlay);
369
- const yaml = staticContentVarsYaml({ retention: 1, releases: overlaid });
370
- expect(yaml).toContain('overlay_dir:');
371
- });
372
- });
373
-
374
- describe('writeStaticContentVars — lands in the generated inventory', () => {
375
- let dir: string;
376
- let db: DbClient;
377
-
378
- beforeEach(async () => {
379
- dir = mkdtempSync(join(tmpdir(), 'static-vars-'));
380
- process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
381
- db = await setupTestDatabaseAt(join(dir, 'celilo.db'));
382
- mkdirSync(join(dir, 'modules', 'caddy', 'site', 'dist'), { recursive: true });
383
- writeFileSync(
384
- join(dir, 'modules', 'caddy', 'site', 'dist', 'index.html'),
385
- '<html>caddy</html>',
386
- 'utf-8',
387
- );
388
- db.insert(modules)
389
- .values({
390
- id: 'caddy',
391
- name: 'Caddy',
392
- version: '2.3.8',
393
- manifestData: {},
394
- sourcePath: join(dir, 'modules', 'caddy'),
395
- })
396
- .onConflictDoNothing()
397
- .run();
398
- });
399
-
400
- afterEach(() => {
401
- db.$client.close();
402
- resetTestDbPath();
403
- try {
404
- rmSync(dir, { recursive: true, force: true });
405
- } catch {
406
- /* ignore */
407
- }
408
- });
409
-
410
- it('writes the vars file and never writes into a module tree (task 4.7)', async () => {
411
- db.insert(webRoutes)
412
- .values({
413
- slug: 'caddy',
414
- moduleId: 'caddy',
415
- type: 'static',
416
- path: '/',
417
- hostname: 'www.example.com',
418
- websocket: false,
419
- contentHash: HASH1,
420
- })
421
- .run();
422
-
423
- const moduleTreeBefore = readFileSync(
424
- join(dir, 'modules', 'caddy', 'site', 'dist', 'index.html'),
425
- 'utf-8',
426
- );
427
- const result = await writeStaticContentVars(
428
- db,
429
- 'caddy',
430
- join(dir, 'modules', 'caddy', 'generated'),
431
- );
432
-
433
- expect(result.success).toBe(true);
434
- const varsPath = staticContentVarsPath(join(dir, 'modules', 'caddy', 'generated'));
435
- expect(existsSync(varsPath)).toBe(true);
436
- expect(readFileSync(varsPath, 'utf-8')).toContain('slug: caddy');
437
-
438
- // Task 4.7: the converge path is read-only over the module tree. The
439
- // clientConfig write into it is celilo#1018 and belongs to that thread —
440
- // THIS path must not add a second writer.
441
- expect(readFileSync(join(dir, 'modules', 'caddy', 'site', 'dist', 'index.html'), 'utf-8')).toBe(
442
- moduleTreeBefore,
443
- );
444
- });
445
-
446
- it('buildStaticContentPlan carries the resolved retention alongside the releases', async () => {
447
- db.insert(webRoutes)
448
- .values({
449
- slug: 'caddy',
450
- moduleId: 'caddy',
451
- type: 'static',
452
- path: '/',
453
- hostname: 'www.example.com',
454
- websocket: false,
455
- contentHash: HASH1,
456
- })
457
- .run();
458
-
459
- const plan = buildStaticContentPlan(db, 'caddy');
460
- expect(plan.retention).toBe(5);
461
- expect(plan.releases).toHaveLength(1);
462
- });
463
- });
464
-
465
- describe('the stale-project guard reaches the whole generated tree (celilo#1248)', () => {
466
- let dir: string;
467
-
468
- beforeEach(() => {
469
- dir = mkdtempSync(join(tmpdir(), 'static-guard-reach-'));
470
- });
471
- afterEach(() => {
472
- rmSync(dir, { recursive: true, force: true });
473
- });
474
-
475
- it('sees a converge that lives in a role task file, not in playbook.yml', () => {
476
- // Mirror DERIVED from the real module, never hand-written: a hand-built
477
- // tree would give confident reach data about a world that does not exist,
478
- // which is the same bug one level up.
479
- const real = resolve(import.meta.dir, '../../../..', 'modules/caddy/ansible');
480
- cpSync(real, join(dir, 'ansible'), { recursive: true });
481
-
482
- // The premise this guards. caddy's playbook is a thin hosts/vars/roles
483
- // stanza; if it ever DOES name static_releases, this test stops proving
484
- // anything and should be re-derived rather than deleted.
485
- const playbook = readFileSync(join(dir, 'ansible/playbook.yml.tpl'), 'utf-8');
486
- expect(playbook).not.toContain('static_releases');
487
-
488
- expect(ansibleTreeMentions(join(dir, 'ansible'), 'static_releases')).toBe(true);
489
- });
490
-
491
- it('still refuses a project that mentions the var nowhere', () => {
492
- // A pre-converge generated project. Hand-built deliberately, and it has to
493
- // be: no deploy on today's code can produce a tree without the role tasks,
494
- // so this legacy shape is the fixture and not a shortcut.
495
- mkdirSync(join(dir, 'ansible/roles/caddy/tasks'), { recursive: true });
496
- writeFileSync(
497
- join(dir, 'ansible/playbook.yml'),
498
- '---\n- hosts: caddy\n roles:\n - caddy\n',
499
- );
500
- writeFileSync(
501
- join(dir, 'ansible/roles/caddy/tasks/main.yml'),
502
- '---\n- name: install\n debug: {}\n',
503
- );
504
-
505
- expect(ansibleTreeMentions(join(dir, 'ansible'), 'static_releases')).toBe(false);
506
- });
507
-
508
- it('ignores non-text files rather than reading assets as utf-8', () => {
509
- mkdirSync(join(dir, 'ansible/files'), { recursive: true });
510
- writeFileSync(join(dir, 'ansible/files/logo.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
511
- writeFileSync(join(dir, 'ansible/site.yml'), 'vars:\n static_releases: []\n');
512
-
513
- expect(ansibleTreeMentions(join(dir, 'ansible'), 'static_releases')).toBe(true);
514
- expect(ansibleTreeMentions(join(dir, 'ansible'), 'PNG')).toBe(false);
515
- });
516
- });
@@ -1,390 +0,0 @@
1
- /**
2
- * The static-content converge (openspec/changes/capability-owned-tables,
3
- * stage 4 / design D10).
4
- *
5
- * `/srv/www` on the public_web provider stops being whatever the last
6
- * hand-built ssh upload left there and becomes convergent state: every
7
- * declared static route's release directory exists, `/srv/www/<slug>` points
8
- * at the current one through an atomic symlink swap, and everything past the
9
- * retention count is pruned. One Ansible converge makes the host match.
10
- *
11
- * Two callers, one code path:
12
- * - the `public_web` capability's publish (`convergeStaticContent` callback
13
- * injected by capability-loader), which runs the whole converge including
14
- * `executeAnsible`, so a publish returns only once the host matches; and
15
- * - the provider's OWN deploy (module-deploy), which writes the same release
16
- * set into its generated inventory and lets the deploy's existing
17
- * `executeAnsible` run the same role tasks — that is how a rebuilt host
18
- * recovers with no consumer involvement (design D10, task 4.5).
19
- *
20
- * The desired state is the declared `web_routes` release set plus the source
21
- * directory core derived from `modules.source_path`. Nothing here writes into
22
- * a module tree: reading a module's web root is safe, writing is what breaks
23
- * `module verify` (celilo#1018, deliberately left to that thread).
24
- */
25
-
26
- import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
27
- import { mkdir, writeFile } from 'node:fs/promises';
28
- import { join } from 'node:path';
29
- import { and, eq, isNotNull } from 'drizzle-orm';
30
- import { stringify as stringifyYaml } from 'yaml';
31
- import type { DbClient } from '../db/client';
32
- import { moduleConfigs, modules, webRoutes } from '../db/schema';
33
- import { resolveModuleStateWebRoot, resolveModuleWebRoot } from '../module/web-root';
34
- import { parseStoredConfigValue } from './module-config';
35
- import { listPausedModules } from './module-pause';
36
-
37
- /** One content-hashed release the provider host must serve. */
38
- export interface StaticRelease {
39
- /** The `/srv/www/<slug>` key. Independent of hostname on purpose. */
40
- slug: string;
41
- /** Content hash of the release — the release directory's suffix. */
42
- contentHash: string;
43
- /** Every FQDN served by this slug's routes (a slug spans hostnames). */
44
- hostnames: string[];
45
- /** Absolute celilo-mgr path core derived: `<module source_path>/site/dist`. */
46
- sourceDir: string;
47
- /**
48
- * Absolute path to `<module source_path>/state/site` (celilo#1265), present
49
- * ONLY when that directory exists. The role copies it over the release
50
- * after the web root, so a hook-generated file wins over a built one. A
51
- * module with no generated site content has no overlay and no row.
52
- */
53
- overlayDir?: string;
54
- }
55
-
56
- export interface StaticContentPlan {
57
- releases: StaticRelease[];
58
- /** How many release directories to keep per slug after a converge. */
59
- retention: number;
60
- }
61
-
62
- /**
63
- * Retention when the operator has not set one. Mirrors the manifest default
64
- * declared on the provider module (`static_release_retention`); the manifest
65
- * is the operator-facing source of truth, this only covers a provider whose
66
- * manifest predates the variable.
67
- */
68
- const DEFAULT_STATIC_RELEASE_RETENTION = 5;
69
-
70
- export interface StaticContentConvergeResult {
71
- success: boolean;
72
- error?: string;
73
- /** The written vars file, repo-absolute — for logs and tests. */
74
- varsPath?: string;
75
- }
76
-
77
- /**
78
- * Where the converge's desired state lands inside the provider's generated
79
- * project. `group_vars/all/` is the existing auto-load directory (it already
80
- * holds `secrets.yml`), so no playbook change is needed to read it.
81
- */
82
- export function staticContentVarsPath(generatedPath: string): string {
83
- return join(generatedPath, 'ansible', 'inventory', 'group_vars', 'all', 'static_content.yml');
84
- }
85
-
86
- /**
87
- * Resolve the retention count for the provider module.
88
- *
89
- * Operator config first (`static_release_retention` module config), then the
90
- * manifest's declared default, then the constant above. ONE resolution — the
91
- * role reads the variable bare and never carries a literal.
92
- */
93
- export function resolveStaticContentRetention(db: DbClient, providerModuleId: string): number {
94
- const configRow = db
95
- .select()
96
- .from(moduleConfigs)
97
- .where(
98
- and(
99
- eq(moduleConfigs.moduleId, providerModuleId),
100
- eq(moduleConfigs.key, 'static_release_retention'),
101
- ),
102
- )
103
- .get();
104
- if (configRow) {
105
- const parsed = parseStoredConfigValue(configRow);
106
- const count = typeof parsed === 'number' ? parsed : Number.parseInt(String(parsed), 10);
107
- if (Number.isInteger(count) && count >= 1) return count;
108
- throw new Error(
109
- `static_release_retention for ${providerModuleId} must be a positive integer, got: ${String(parsed)}`,
110
- );
111
- }
112
-
113
- const module = db.select().from(modules).where(eq(modules.id, providerModuleId)).get();
114
- if (module) {
115
- const manifest = module.manifestData as {
116
- variables?: { owns?: Array<{ name?: string; default?: unknown }> };
117
- } | null;
118
- const declared = manifest?.variables?.owns?.find((v) => v?.name === 'static_release_retention');
119
- const fallback = typeof declared?.default === 'number' ? declared.default : undefined;
120
- if (typeof fallback === 'number' && Number.isInteger(fallback) && fallback >= 1)
121
- return fallback;
122
- }
123
-
124
- return DEFAULT_STATIC_RELEASE_RETENTION;
125
- }
126
-
127
- /**
128
- * Build the release set from the declared `web_routes` rows.
129
- *
130
- * Pure: reads the DB, decides nothing on the host (Rule 10.4). Grouping is by
131
- * slug, NOT by route — one slug is one release directory, and a slug can span
132
- * hostnames (lunacycle's two routes, both at `/`). A group that spans modules
133
- * or disagrees with itself about the hash is a contradiction the converge
134
- * cannot express, so it fails here with both rows named rather than silently
135
- * converging one of them.
136
- */
137
- export function planStaticContent(db: DbClient, publishingModuleId?: string): StaticRelease[] {
138
- const rows = db
139
- .select()
140
- .from(webRoutes)
141
- .where(and(eq(webRoutes.type, 'static'), isNotNull(webRoutes.contentHash)))
142
- .all();
143
-
144
- const bySlug = new Map<string, typeof rows>();
145
- for (const row of rows) {
146
- const group = bySlug.get(row.slug) ?? [];
147
- group.push(row);
148
- bySlug.set(row.slug, group);
149
- }
150
-
151
- // A paused module's site is left exactly as it is — pause preserves state
152
- // (module-pause spec) — so its slug is neither converged nor validated. The
153
- // exception is the module publishing right now: an unpause redeploy runs
154
- // on_install while the module is still PAUSED (executeUnpause clears the
155
- // pause only after the redeploy succeeds). celilo#1383.
156
- const paused = new Set(listPausedModules(db).map((m) => m.id));
157
- if (publishingModuleId) paused.delete(publishingModuleId);
158
-
159
- const releases: StaticRelease[] = [];
160
- for (const [slug, group] of bySlug) {
161
- const moduleIds = [...new Set(group.map((r) => r.moduleId))];
162
- if (moduleIds.length > 1) {
163
- throw new Error(
164
- `Static route slug "${slug}" is claimed by ${moduleIds.length} modules (${moduleIds.join(', ')}). The slug is the release key — /srv/www/${slug} cannot serve two release sets. Fix the colliding modules' registered paths.`,
165
- );
166
- }
167
- const moduleId = moduleIds[0];
168
- if (!moduleId) continue;
169
- if (paused.has(moduleId)) continue;
170
-
171
- const hashes = [...new Set(group.map((r) => r.contentHash))];
172
- if (hashes.length > 1) {
173
- throw new Error(
174
- `Static route slug "${slug}" (module ${moduleId}) holds ${hashes.length} content hashes (${hashes.join(', ')}) — rows sharing a slug must share a release. Re-publish the module so every row carries the current hash.`,
175
- );
176
- }
177
- const contentHash = hashes[0];
178
- if (!contentHash) continue;
179
-
180
- const sourceDir = resolveModuleWebRoot(moduleId, db);
181
- if (!sourceDir) {
182
- throw new Error(
183
- `Static route slug "${slug}" belongs to module "${moduleId}", which is not installed — its release has no source to converge from.`,
184
- );
185
- }
186
- if (!existsSync(sourceDir)) {
187
- throw new Error(
188
- `Static route slug "${slug}": no built site at ${sourceDir}. A module that serves a static site ships it at <module root>/site/dist. Redeploy the module.`,
189
- );
190
- }
191
-
192
- // Present only when the module's hook actually wrote an overlay —
193
- // otherwise the vars file would name a directory that does not exist
194
- // and the role's copy task would have nothing to copy from.
195
- const stateWebRoot = resolveModuleStateWebRoot(moduleId, db);
196
- const overlayDir = stateWebRoot && existsSync(stateWebRoot) ? stateWebRoot : undefined;
197
- releases.push({
198
- slug,
199
- contentHash,
200
- hostnames: [...new Set(group.map((r) => r.hostname))].sort(),
201
- sourceDir,
202
- ...(overlayDir ? { overlayDir } : {}),
203
- });
204
- }
205
-
206
- releases.sort((a, b) => a.slug.localeCompare(b.slug));
207
- return releases;
208
- }
209
-
210
- /**
211
- * The full desired state: the release set plus the retention count resolved
212
- * for THIS provider module. The unit the vars file and the tests speak in.
213
- */
214
- export function buildStaticContentPlan(
215
- db: DbClient,
216
- providerModuleId: string,
217
- publishingModuleId?: string,
218
- ): StaticContentPlan {
219
- return {
220
- releases: planStaticContent(db, publishingModuleId),
221
- retention: resolveStaticContentRetention(db, providerModuleId),
222
- };
223
- }
224
-
225
- /**
226
- * Render the vars file. Presentation function (Rule 10.1) — deterministic
227
- * YAML, keyed by slug, carrying what 4.1 names: slug, content_hash, the
228
- * hostnames served, and the source directory core derived.
229
- */
230
- export function staticContentVarsYaml(plan: StaticContentPlan): string {
231
- const vars = {
232
- static_release_retention: plan.retention,
233
- static_releases: plan.releases.map((r) => ({
234
- slug: r.slug,
235
- content_hash: r.contentHash,
236
- hostnames: r.hostnames,
237
- source_dir: r.sourceDir,
238
- // Absent when there is no overlay, so the role's `when:` guard reads a
239
- // clean variable — not an empty string a copy task would choke on.
240
- ...(r.overlayDir ? { overlay_dir: r.overlayDir } : {}),
241
- })),
242
- };
243
- const header =
244
- '# Desired static-content state, generated by celilo (capability-owned-tables D10).\n' +
245
- '# Hand edits are overwritten by the next converge. K = static_release_retention.\n';
246
- return `${header}${stringifyYaml(vars, { lineWidth: 0, sortMapEntries: true })}`;
247
- }
248
-
249
- /**
250
- * Plan the release set and write it into the provider's generated inventory.
251
- *
252
- * The shared half of both converge triggers: the capability's callback adds
253
- * `executeAnsible` after this, and the provider's own deploy already runs it.
254
- * Nothing inspects verbatim role assets any more — the staleness pre-flight
255
- * retired with the ephemeral generated tree (D5 of
256
- * control-plane-stops-building-modules), so an injected vars file cannot read
257
- * as a stale generation because nothing reads staleness.
258
- */
259
- export async function writeStaticContentVars(
260
- db: DbClient,
261
- providerModuleId: string,
262
- generatedPath: string,
263
- publishingModuleId?: string,
264
- ): Promise<StaticContentConvergeResult> {
265
- const varsPath = staticContentVarsPath(generatedPath);
266
-
267
- const plan = buildStaticContentPlan(db, providerModuleId, publishingModuleId);
268
-
269
- await mkdir(join(varsPath, '..'), { recursive: true });
270
- await writeFile(varsPath, staticContentVarsYaml(plan), 'utf-8');
271
- return { success: true, varsPath };
272
- }
273
-
274
- /**
275
- * The full converge: plan, write the desired state, run Ansible against the
276
- * provider's generated project.
277
- *
278
- * `generatedPath` must already exist — it is the project the provider's last
279
- * deploy generated. If the project predates the converge role tasks, the
280
- * written state would be read by nothing and the transfer would silently
281
- * never happen, so the playbook is checked for the converge's own variable
282
- * first and a stale project fails loudly with the redeploy that fixes it.
283
- */
284
- /**
285
- * Whether any text file in a generated Ansible tree mentions `needle`.
286
- *
287
- * Scans the TREE rather than `playbook.yml`, because a provider's converge
288
- * belongs in one of its role's task files and a generated `playbook.yml` is a
289
- * thin hosts/vars/roles stanza that names no variable at all. caddy is the
290
- * worked example: `static_releases` appears in
291
- * `roles/caddy/tasks/static-content.yml`, `.../main.yml.tpl` and
292
- * `.../converge-release.yml`, and zero times in its playbook.
293
- *
294
- * @psbanka - 2026-09: this used to read `ansible/playbook.yml` alone, which
295
- * could not reach the string it was looking for. The guard below therefore
296
- * refused EVERY provider that had the converge support, and its remedy told the
297
- * operator to redeploy — which regenerates the same thin playbook and cannot
298
- * help. Guard and role tasks landed in the same commit (1fd3595e), so it had
299
- * never once passed, and it held three e2e suites red where nobody was looking.
300
- * celilo#1248.
301
- */
302
- export function ansibleTreeMentions(dir: string, needle: string): boolean {
303
- if (!existsSync(dir)) return false;
304
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
305
- const full = join(dir, entry.name);
306
- if (entry.isDirectory()) {
307
- if (ansibleTreeMentions(full, needle)) return true;
308
- continue;
309
- }
310
- // Text only. A generated tree can carry static assets, and reading those
311
- // as utf-8 to look for a variable name is waste at best.
312
- if (!/\.(ya?ml|j2|tpl|cfg|ini|conf)$/.test(entry.name)) continue;
313
- if (readFileSync(full, 'utf-8').includes(needle)) return true;
314
- }
315
- return false;
316
- }
317
-
318
- export async function convergeStaticContent(
319
- db: DbClient,
320
- providerModuleId: string,
321
- publishingModuleId?: string,
322
- ): Promise<StaticContentConvergeResult> {
323
- const module = db.select().from(modules).where(eq(modules.id, providerModuleId)).get();
324
- if (!module) {
325
- return { success: false, error: `Provider module '${providerModuleId}' is not installed` };
326
- }
327
- const generatedPath = join(module.sourcePath, 'generated');
328
- // D4 (control-plane-stops-building-modules): the generated project is
329
- // ephemeral, so a successful provider deploy deletes it and nothing is
330
- // persisted for this converge to read. Render it here, on demand, from the
331
- // signed payload plus resolved config — generation is fast and local. A
332
- // render failure fails loudly with the deploy-once remedy, same as the old
333
- // missing-project refusal did; a tree that predates the converge support is
334
- // still refused by the static_releases check below.
335
- if (!existsSync(generatedPath)) {
336
- const { generateTemplates } = await import('../templates/generator');
337
- const rendered = await generateTemplates({
338
- moduleId: providerModuleId,
339
- modulePath: module.sourcePath,
340
- outputPath: generatedPath,
341
- db,
342
- skipVariableValidation: false,
343
- });
344
- if (!rendered.success) {
345
- return {
346
- success: false,
347
- error:
348
- `public_web provider '${providerModuleId}' has no generated deploy project at ${generatedPath} ` +
349
- `and rendering one failed: ${rendered.error ?? 'unknown error'}. ` +
350
- `Run \`celilo module deploy ${providerModuleId}\` once, then retry the publish.`,
351
- };
352
- }
353
- }
354
-
355
- const written = await writeStaticContentVars(
356
- db,
357
- providerModuleId,
358
- generatedPath,
359
- publishingModuleId,
360
- );
361
-
362
- // A generated project that predates the converge role tasks would read the
363
- // new vars file and converge nothing. `static_releases` appearing nowhere in
364
- // the project is the tell. Generic string check — core names no module.
365
- const ansiblePath = join(generatedPath, 'ansible');
366
- if (existsSync(ansiblePath) && !ansibleTreeMentions(ansiblePath, 'static_releases')) {
367
- return {
368
- success: false,
369
- error: `The generated project for '${providerModuleId}' predates the static-content converge (its playbook never reads static_releases). Redeploy the provider (\`celilo module deploy ${providerModuleId}\`) to regenerate it, then retry the publish.`,
370
- };
371
- }
372
-
373
- const { executeAnsible } = await import('./deploy-ansible');
374
- // Tag-scoped: the full playbook re-templates the bootstrap Caddyfile, which
375
- // would replace the real config the provider's reconcile wrote and reload
376
- // caddy into serving nothing. A publish converge touches /srv/www only.
377
- const result = await executeAnsible(generatedPath, { tags: ['static_content'] });
378
- if (!result.success) {
379
- return {
380
- success: false,
381
- error: `Static-content converge failed on ${providerModuleId}: ${result.error ?? 'unknown error'}`,
382
- varsPath: written.varsPath,
383
- };
384
- }
385
- // D4: the converge rendered the tree, used it, and is done with it. The same
386
- // no-secrets-at-rest rule the deploy follows applies here; on the failure
387
- // paths above the tree stays for inspection.
388
- rmSync(generatedPath, { recursive: true, force: true });
389
- return { success: true, varsPath: written.varsPath };
390
- }