@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 +2 -2
- package/src/cli/commands/publish/workspace.ts +9 -0
- package/src/hooks/capability-loader.ts +126 -41
- package/src/policy/capability-shape-baseline.ts +6 -0
- package/src/policy/module-business-baseline.ts +17 -6
- package/src/services/module-deploy.ts +25 -11
- package/src/services/provider-converge.test.ts +225 -0
- package/src/services/provider-converge.ts +343 -0
- package/src/services/static-content-converge.test.ts +0 -477
- package/src/services/static-content-converge.ts +0 -369
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "
|
|
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.
|
|
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
|
-
//
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1478
|
-
//
|
|
1479
|
-
//
|
|
1480
|
-
//
|
|
1481
|
-
//
|
|
1482
|
-
//
|
|
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 {
|
|
1485
|
-
const
|
|
1486
|
-
if (!
|
|
1487
|
-
return {
|
|
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
|
+
});
|