@celilo/cli 3.0.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.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -58,7 +58,7 @@
58
58
  "dependencies": {
59
59
  "@aws-sdk/client-s3": "^3.1109.0",
60
60
  "@aws-sdk/lib-storage": "^3.1101.0",
61
- "@celilo/capabilities": "^5.0.0",
61
+ "@celilo/capabilities": "^5.1.0",
62
62
  "@celilo/cli-display": "^0.2.0",
63
63
  "@celilo/core": "^0.13.0",
64
64
  "@celilo/event-bus": "^0.6.0",
@@ -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);
@@ -23,6 +23,7 @@ import type {
23
23
  DnsRegistrarCapability,
24
24
  HookLogger,
25
25
  PortForwardStore,
26
+ ProviderConvergeView,
26
27
  RouteOps,
27
28
  RouteReadView,
28
29
  TrustedSourceStore,
@@ -79,6 +80,51 @@ import { loadHookConfigMap } from './load-hook-config';
79
80
  * upstream-chain wiring takes a second factory argument (`upstreamFirewall`)
80
81
  * that doesn't fit the `defineCapabilityFunction` shape.
81
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
+
82
128
  export const CAPABILITY_MODULE_MAP: Record<string, { script: string; legacyFactoryName: string }> =
83
129
  {
84
130
  dns_registrar: {
@@ -645,19 +691,26 @@ export async function loadCapabilityFunctions(
645
691
  caddyModuleId: provider.moduleId,
646
692
  dnsManagedDomains,
647
693
  dnsRegistrarModuleId,
648
- // Design D10: the bytes move through the provider's Ansible converge,
649
- // not a hand-built ssh tar pipe. Core implements it over
650
- // executeAnsible against the provider's generated project (task 4.3);
651
- // awaited with everything else, so a publish returns only once the
652
- // 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
653
710
  // that reports ready while the host never received the bytes is the
654
711
  // exact "served but silently unreachable" anti-pattern.
655
712
  convergeStaticContent: async () => {
656
- const { convergeStaticContent } = await import('../services/static-content-converge');
657
- const result = await convergeStaticContent(db, provider.moduleId);
658
- if (!result.success) {
659
- throw new Error(result.error ?? 'static-content converge failed');
660
- }
713
+ await requireProviderReconcile(consumingModuleId, 'static publish');
661
714
  },
662
715
  // ISS-0035: register_route/unregister_routes emit this coarse signal
663
716
  // instead of SSHing caddy; the caddy provider's reconcile_routes
@@ -666,37 +719,7 @@ export async function loadCapabilityFunctions(
666
719
  // the consuming module's health_check runs right after and would
667
720
  // otherwise race the async reconcile.
668
721
  onRoutesChanged: async () => {
669
- const reconcile = await emitWebRoutesChangedAndWait(consumingModuleId);
670
- // Authoritative (ISS-0081): a route the provider (caddy) never
671
- // reconciled is a deploy FAILURE, not a warning. These used to be
672
- // logger.warn while register_route returned success anyway, so a
673
- // consumer could report "ready" while caddy never learned the
674
- // hostname — no site block, no cert, TLS internal_error for clients.
675
- if (reconcile.noDispatcher) {
676
- throw new Error(
677
- `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.`,
678
- );
679
- }
680
- if (reconcile.timedOut) {
681
- throw new Error(
682
- `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.`,
683
- );
684
- }
685
- if (reconcile.failed > 0) {
686
- throw new Error(
687
- `public_web reconcile for ${consumingModuleId}: ${reconcile.failed} delivery(ies) failed — caddy could not apply the route, so the hostname is not served.`,
688
- );
689
- }
690
- // ISS-0087: a route WAS registered and the dispatcher IS alive, yet ZERO
691
- // providers reconciled it (no subscriber consumed routes_changed). That
692
- // registers a route nobody applied and would otherwise report success.
693
- // `events === 0` means no routes changed (benign); `events > 0 &&
694
- // succeeded === 0` means the route changed but nobody served it — a failure.
695
- if (reconcile.events > 0 && reconcile.succeeded === 0) {
696
- throw new Error(
697
- `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.`,
698
- );
699
- }
722
+ await requireProviderReconcile(consumingModuleId, 'route change');
700
723
  },
701
724
  });
702
725
  debugLog(`public_web: loaded via framework implementation for ${consumingModuleId}`);
@@ -721,6 +744,68 @@ export async function loadCapabilityFunctions(
721
744
  };
722
745
  result.web_routes = routeView;
723
746
  debugLog(`web_routes: read-only route view injected for provider ${consumingModuleId}`);
747
+
748
+ // The provider renders its own config; celilo makes the host match by
749
+ // running that provider's own role. The lookup is deliberately per
750
+ // module: core used to plan the whole fleet's sites and throw when one
751
+ // module had no built site, which stopped every other site converging
752
+ // (celilo#1383). See openspec/changes/providers-converge-declared-state.
753
+ const providerModuleId = provider.moduleId;
754
+ const providerConverge: ProviderConvergeView = {
755
+ resolveConsumerSite: async (moduleId: string) => {
756
+ const { resolveModuleStateWebRoot, resolveModuleWebRoot } = await import(
757
+ '../module/web-root'
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
+
775
+ const sourceDir = resolveModuleWebRoot(moduleId, db);
776
+ if (!sourceDir) {
777
+ return { unavailable: `module '${moduleId}' is not installed` };
778
+ }
779
+ if (!existsSync(sourceDir)) {
780
+ return {
781
+ unavailable: `no built site at ${sourceDir} — a module that serves a static site ships it at <module root>/site/dist`,
782
+ };
783
+ }
784
+ const stateWebRoot = resolveModuleStateWebRoot(moduleId, db);
785
+ const overlayDir =
786
+ stateWebRoot && existsSync(stateWebRoot) ? { overlayDir: stateWebRoot } : {};
787
+ return { sourceDir, ...overlayDir };
788
+ },
789
+ converge: async (artifacts) => {
790
+ const { convergeProviderConfig, resolveStaticContentRetention } = await import(
791
+ '../services/provider-converge'
792
+ );
793
+ const result = await convergeProviderConfig(db, providerModuleId, {
794
+ ...artifacts,
795
+ // Operator config, resolved here rather than in the provider: the
796
+ // module never carries the number (design D10 of
797
+ // capability-owned-tables).
798
+ retention: resolveStaticContentRetention(db, providerModuleId),
799
+ });
800
+ return {
801
+ success: result.success,
802
+ ...(result.error ? { error: result.error } : {}),
803
+ unresolved: result.unresolved,
804
+ };
805
+ },
806
+ };
807
+ result.provider_converge = providerConverge;
808
+ debugLog(`provider_converge: injected for provider ${consumingModuleId}`);
724
809
  }
725
810
  } else {
726
811
  debugLog('public_web: not registered in DB, skipping');
@@ -143,4 +143,10 @@ export const CAPABILITY_SHAPE_BASELINE: Readonly<Record<string, CapabilityShape>
143
143
  requests:
144
144
  '[{kind:"interface",members:[{name:"converge",optional:false,type:"Promise<void>"},{name:"withdrawConsumer",optional:false,type:"Promise<void>"}],name:"FirewallProviderView"}]',
145
145
  },
146
+ provider_converge: {
147
+ version: '1.0.0',
148
+ hash: '1d828d70433ce4a15a73072883abad0c2acfef92c059bf40432b792722eb7d97',
149
+ requests:
150
+ '[{kind:"alias",members:[{name:"_self",optional:false,type:"{\\n sourceDir: string;\\n overlayDir?: string;\\n} | {\\n unavailable: string;\\n}"}],name:"ConsumerSiteResolution"},{kind:"interface",members:[{name:"content",optional:false,type:"string"},{name:"path",optional:false,type:"string"}],name:"ProviderConfigFile"},{kind:"interface",members:[{name:"converge",optional:false,type:"Promise<{\\n success: boolean;\\n error?: string;\\n unresolved: string[];\\n}>"},{name:"resolveConsumerSite",optional:false,type:"Promise<ConsumerSiteResolution>"}],name:"ProviderConvergeView"},{kind:"interface",members:[{name:"contentHash",optional:false,type:"string"},{name:"hostnames",optional:false,type:"string[]"},{name:"moduleId",optional:false,type:"string"},{name:"overlayDir",optional:true,type:"string"},{name:"slug",optional:false,type:"string"},{name:"sourceDir",optional:false,type:"string"}],name:"ProviderSiteRequest"}]',
151
+ },
146
152
  };
@@ -99,6 +99,12 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
99
99
  count: 4,
100
100
  why: 'PERMANENT — a provider VIEW, not a module-provided capability. The name was registered so it stops being invisible to audits (celilo#1007); these references predate that and are framework plumbing, not core branching on a provider',
101
101
  },
102
+ {
103
+ file: 'apps/celilo/src/hooks/capability-loader.ts',
104
+ capability: 'provider_converge',
105
+ count: 2,
106
+ why: 'PERMANENT — a provider VIEW, not a module-provided capability. Core injects the generic converge every provider renders through; naming it is framework plumbing, not core branching on a provider (providers-converge-declared-state)',
107
+ },
102
108
  {
103
109
  file: 'apps/celilo/src/db/schema.ts',
104
110
  capability: 'web_routes',
@@ -129,6 +135,12 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
129
135
  count: 1,
130
136
  why: 'PERMANENT — a contract declaration with no implementation; this is the package doing its job',
131
137
  },
138
+ {
139
+ file: 'packages/capabilities/src/capability-contract.ts',
140
+ capability: 'provider_converge',
141
+ count: 1,
142
+ why: 'PERMANENT — a contract declaration with no implementation; this is the package doing its job',
143
+ },
132
144
  {
133
145
  file: 'apps/celilo/src/capabilities/well-known.ts',
134
146
  capability: 'dhcp_server',
@@ -466,6 +478,11 @@ export const SERVICE_FILENAME_BASELINE: readonly ServiceFilenameRow[] = [
466
478
  capability: 'cross_module_read',
467
479
  why: 'PERMANENT — cross_module_read is a framework privilege, not a module capability',
468
480
  },
481
+ {
482
+ file: 'apps/celilo/src/services/provider-converge.ts',
483
+ capability: 'provider_converge',
484
+ why: 'PERMANENT — provider_converge is a framework-granted view, not a module capability, and this service IS the generic mechanism it grants: it carries what a provider rendered and runs that provider own role. Naming it for one provider is what the scan exists to catch; naming it for the mechanism is the fix the scan asks for (providers-converge-declared-state)',
485
+ },
469
486
  {
470
487
  file: 'apps/celilo/src/services/dns-internal-records.ts',
471
488
  capability: 'dns_internal',
@@ -497,10 +514,4 @@ export const PROVIDER_LITERAL_BASELINE: readonly ProviderLiteralRow[] = [
497
514
  count: 3,
498
515
  why: "X8 — the Caddyfile generator knows caddy's on-disk asset layout (#940)",
499
516
  },
500
- {
501
- file: 'apps/celilo/src/services/static-content-converge.ts',
502
- literal: '/srv/www',
503
- count: 1,
504
- 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.",
505
- },
506
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 {
@@ -0,0 +1,225 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { existsSync, mkdirSync, mkdtempSync, 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 { parse as parseYaml } from 'yaml';
7
+ import type { DbClient } from '../db/client';
8
+ import { modules } from '../db/schema';
9
+ import { setupTestDatabaseAt } from '../test-utils/database';
10
+ import { resetTestDbPath } from '../test-utils/db-path';
11
+ import {
12
+ type ProviderArtifacts,
13
+ convergeProviderConfig,
14
+ providerConfigVarsPath,
15
+ providerConfigVarsYaml,
16
+ recordUnresolvedConsumers,
17
+ } from './provider-converge';
18
+
19
+ const HASH = 'a'.repeat(64);
20
+
21
+ function artifacts(overrides: Partial<ProviderArtifacts> = {}): ProviderArtifacts {
22
+ return {
23
+ files: [{ path: '/etc/caddy/Caddyfile', content: 'peterbanka.org {\n\ttls internal\n}\n' }],
24
+ sites: [
25
+ {
26
+ slug: 'peterbanka-org',
27
+ moduleId: 'peterbanka-org',
28
+ contentHash: HASH,
29
+ hostnames: ['peterbanka.org'],
30
+ sourceDir: '/var/celilo/modules/peterbanka-org/site/dist',
31
+ },
32
+ ],
33
+ unresolved: [],
34
+ retention: 5,
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ describe('providerConfigVarsYaml — the provider renders, celilo carries', () => {
40
+ it('carries each rendered file so the role can place it verbatim', () => {
41
+ const vars = parseYaml(providerConfigVarsYaml(artifacts()));
42
+
43
+ expect(vars.provider_config_files).toEqual([
44
+ { path: '/etc/caddy/Caddyfile', content: 'peterbanka.org {\n\ttls internal\n}\n' },
45
+ ]);
46
+ });
47
+
48
+ it('carries the sites under the variable the static-content role already reads', () => {
49
+ const vars = parseYaml(providerConfigVarsYaml(artifacts()));
50
+
51
+ expect(vars.static_release_retention).toBe(5);
52
+ expect(vars.static_releases).toEqual([
53
+ {
54
+ slug: 'peterbanka-org',
55
+ content_hash: HASH,
56
+ hostnames: ['peterbanka.org'],
57
+ source_dir: '/var/celilo/modules/peterbanka-org/site/dist',
58
+ },
59
+ ]);
60
+ });
61
+
62
+ it('omits overlay_dir when there is none, so the role guard reads a clean variable', () => {
63
+ const withOverlay = artifacts({
64
+ sites: [
65
+ { ...artifacts().sites[0], overlayDir: '/var/celilo/modules/peterbanka-org/state/site' },
66
+ ],
67
+ });
68
+
69
+ expect(parseYaml(providerConfigVarsYaml(artifacts())).static_releases[0].overlay_dir).toBe(
70
+ undefined,
71
+ );
72
+ expect(parseYaml(providerConfigVarsYaml(withOverlay)).static_releases[0].overlay_dir).toBe(
73
+ '/var/celilo/modules/peterbanka-org/state/site',
74
+ );
75
+ });
76
+
77
+ it("one consumer the provider could not render does not remove another's site", () => {
78
+ const partial = artifacts({
79
+ unresolved: [
80
+ { moduleId: 'byoi', reason: 'no built site at /var/celilo/modules/byoi/site/dist' },
81
+ ],
82
+ });
83
+
84
+ const vars = parseYaml(providerConfigVarsYaml(partial));
85
+
86
+ expect(vars.static_releases.map((r: { slug: string }) => r.slug)).toEqual(['peterbanka-org']);
87
+ });
88
+
89
+ it('is deterministic, so an unchanged desired state writes an unchanged file', () => {
90
+ expect(providerConfigVarsYaml(artifacts())).toBe(providerConfigVarsYaml(artifacts()));
91
+ });
92
+ });
93
+
94
+ describe('providerConfigVarsPath — lands in the generated inventory', () => {
95
+ it('writes into the auto-loaded group_vars directory', () => {
96
+ expect(providerConfigVarsPath('/tmp/generated')).toBe(
97
+ '/tmp/generated/ansible/inventory/group_vars/all/provider_config.yml',
98
+ );
99
+ });
100
+ });
101
+
102
+ describe('recordUnresolvedConsumers — the failure belongs to the consumer', () => {
103
+ let dir: string;
104
+ let db: DbClient;
105
+
106
+ beforeEach(async () => {
107
+ dir = mkdtempSync(join(tmpdir(), 'provider-converge-'));
108
+ const dbPath = join(dir, 'celilo.db');
109
+ process.env.CELILO_DB_PATH = dbPath;
110
+ db = await setupTestDatabaseAt(dbPath);
111
+
112
+ for (const id of ['caddy', 'byoi', 'peterbanka-org']) {
113
+ db.insert(modules)
114
+ .values({
115
+ id,
116
+ name: id,
117
+ version: '1.0.0',
118
+ manifestData: {},
119
+ sourcePath: join(dir, 'modules', id),
120
+ state: 'VERIFIED',
121
+ })
122
+ .onConflictDoNothing()
123
+ .run();
124
+ }
125
+ });
126
+
127
+ afterEach(() => {
128
+ db.$client.close();
129
+ resetTestDbPath();
130
+ try {
131
+ rmSync(dir, { recursive: true, force: true });
132
+ } catch {
133
+ /* ignore */
134
+ }
135
+ });
136
+
137
+ function stateOf(id: string) {
138
+ return db.select().from(modules).where(eq(modules.id, id)).get();
139
+ }
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
+
157
+ it('records the consumer in ERROR with the reason it could not be rendered', () => {
158
+ recordUnresolvedConsumers(db, 'caddy', [
159
+ { moduleId: 'byoi', reason: 'no built site at /var/celilo/modules/byoi/site/dist' },
160
+ ]);
161
+
162
+ const byoi = stateOf('byoi');
163
+ expect(byoi?.state).toBe('ERROR');
164
+ expect(byoi?.errorMessage).toContain('no built site');
165
+ });
166
+
167
+ it('leaves the provider and every other consumer alone', () => {
168
+ recordUnresolvedConsumers(db, 'caddy', [{ moduleId: 'byoi', reason: 'no built site' }]);
169
+
170
+ expect(stateOf('caddy')?.state).toBe('VERIFIED');
171
+ expect(stateOf('peterbanka-org')?.state).toBe('VERIFIED');
172
+ });
173
+
174
+ it('returns the modules it recorded, so the converge can report them', () => {
175
+ const recorded = recordUnresolvedConsumers(db, 'caddy', [
176
+ { moduleId: 'byoi', reason: 'no built site' },
177
+ ]);
178
+
179
+ expect(recorded).toEqual(['byoi']);
180
+ });
181
+
182
+ it('records the consumer even when the converge cannot run at all', async () => {
183
+ const result = await convergeProviderConfig(db, 'not-installed-provider', {
184
+ files: [],
185
+ sites: [],
186
+ unresolved: [{ moduleId: 'byoi', reason: 'no built site' }],
187
+ retention: 5,
188
+ });
189
+
190
+ expect(result.success).toBe(false);
191
+ expect(result.error).toContain('not installed');
192
+ // The consumer's fault is recorded whatever happens to the provider run:
193
+ // the two failures are unrelated and the operator needs both.
194
+ expect(result.unresolved).toEqual(['byoi']);
195
+ expect(stateOf('byoi')?.state).toBe('ERROR');
196
+ });
197
+
198
+ it('refuses a generated project that predates the converge, naming the redeploy', async () => {
199
+ const generated = join(dir, 'modules', 'caddy', 'generated', 'ansible', 'roles');
200
+ mkdirSync(generated, { recursive: true });
201
+ writeFileSync(join(generated, 'main.yml'), '- name: does not read the new vars\n', 'utf-8');
202
+
203
+ const result = await convergeProviderConfig(db, 'caddy', artifacts());
204
+
205
+ expect(result.success).toBe(false);
206
+ expect(result.error).toContain('predates the provider converge');
207
+ expect(result.error).toContain('celilo module deploy caddy');
208
+ // Nothing was written: the guard scans the tree for the variable name, and
209
+ // the vars file would land inside that tree carrying it. Writing first
210
+ // makes the guard find its own output and pass forever (celilo#1248).
211
+ expect(result.varsPath).toBe(undefined);
212
+ expect(existsSync(providerConfigVarsPath(join(dir, 'modules', 'caddy', 'generated')))).toBe(
213
+ false,
214
+ );
215
+ });
216
+
217
+ it('ignores a module that is not installed rather than inventing a row', () => {
218
+ const recorded = recordUnresolvedConsumers(db, 'caddy', [
219
+ { moduleId: 'never-installed', reason: 'no built site' },
220
+ ]);
221
+
222
+ expect(recorded).toEqual([]);
223
+ expect(stateOf('never-installed')).toBe(undefined);
224
+ });
225
+ });