@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
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provider converge (openspec/changes/providers-converge-declared-state).
|
|
3
|
+
*
|
|
4
|
+
* A provider renders its own config from the rows it declares, and celilo makes
|
|
5
|
+
* the host match by running that provider's own Ansible role. One writer on the
|
|
6
|
+
* box, one path from declared state to a running config.
|
|
7
|
+
*
|
|
8
|
+
* Generalised from `static-content-converge.ts`, which already does this for
|
|
9
|
+
* static site content: desired state into the provider's generated inventory,
|
|
10
|
+
* then a tag-scoped `executeAnsible`. The difference is who decides what the
|
|
11
|
+
* desired state is. There, core walks every route row in the fleet and throws
|
|
12
|
+
* when any one module's site is missing (celilo#1383 — two out-of-tree modules
|
|
13
|
+
* with no `site/dist` made the public ingress undeployable). Here the provider
|
|
14
|
+
* hands over what it rendered, and a consumer it could not render is recorded
|
|
15
|
+
* against that consumer.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
19
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { and, eq } from 'drizzle-orm';
|
|
22
|
+
import { stringify as stringifyYaml } from 'yaml';
|
|
23
|
+
import type { DbClient } from '../db/client';
|
|
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;
|
|
34
|
+
|
|
35
|
+
/** One file the provider wants on its host, rendered by the provider. */
|
|
36
|
+
export interface ProviderConfigFile {
|
|
37
|
+
/** Absolute path on the provider host. */
|
|
38
|
+
path: string;
|
|
39
|
+
/** The rendered contents. */
|
|
40
|
+
content: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A consumer's site the provider host must carry. */
|
|
44
|
+
export interface ProviderSite {
|
|
45
|
+
slug: string;
|
|
46
|
+
moduleId: string;
|
|
47
|
+
contentHash: string;
|
|
48
|
+
hostnames: string[];
|
|
49
|
+
sourceDir: string;
|
|
50
|
+
overlayDir?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A consumer the provider could not render, and why. */
|
|
54
|
+
export interface UnresolvedConsumer {
|
|
55
|
+
moduleId: string;
|
|
56
|
+
reason: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Everything the provider rendered for one converge. */
|
|
60
|
+
export interface ProviderArtifacts {
|
|
61
|
+
files: ProviderConfigFile[];
|
|
62
|
+
sites: ProviderSite[];
|
|
63
|
+
unresolved: UnresolvedConsumer[];
|
|
64
|
+
retention: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Where the provider's desired state lands inside its generated project.
|
|
69
|
+
*
|
|
70
|
+
* `group_vars/all/` is the existing auto-load directory — the same one
|
|
71
|
+
* `static_content.yml` uses — so no playbook change is needed to read it.
|
|
72
|
+
*/
|
|
73
|
+
export function providerConfigVarsPath(generatedPath: string): string {
|
|
74
|
+
return join(generatedPath, 'ansible', 'inventory', 'group_vars', 'all', 'provider_config.yml');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Render the vars file. Presentation function (Rule 10.1) — deterministic YAML,
|
|
79
|
+
* so an unchanged desired state writes a byte-identical file and the role's
|
|
80
|
+
* config task reports no change.
|
|
81
|
+
*
|
|
82
|
+
* `static_releases` keeps its name: the role tasks that converge `/srv/www`
|
|
83
|
+
* already read it, and renaming a working variable to match a new writer would
|
|
84
|
+
* be churn with a live-site failure mode.
|
|
85
|
+
*/
|
|
86
|
+
export function providerConfigVarsYaml(artifacts: ProviderArtifacts): string {
|
|
87
|
+
const vars = {
|
|
88
|
+
provider_config_files: artifacts.files.map((file) => ({
|
|
89
|
+
path: file.path,
|
|
90
|
+
content: file.content,
|
|
91
|
+
})),
|
|
92
|
+
static_release_retention: artifacts.retention,
|
|
93
|
+
static_releases: artifacts.sites.map((site) => ({
|
|
94
|
+
slug: site.slug,
|
|
95
|
+
content_hash: site.contentHash,
|
|
96
|
+
hostnames: site.hostnames,
|
|
97
|
+
source_dir: site.sourceDir,
|
|
98
|
+
// Absent when there is no overlay, so the role's `when:` guard reads a
|
|
99
|
+
// clean variable rather than an empty string a copy task would choke on.
|
|
100
|
+
...(site.overlayDir ? { overlay_dir: site.overlayDir } : {}),
|
|
101
|
+
})),
|
|
102
|
+
};
|
|
103
|
+
const header =
|
|
104
|
+
'# Desired provider state, generated by celilo (providers-converge-declared-state).\n' +
|
|
105
|
+
'# Hand edits are overwritten by the next converge.\n';
|
|
106
|
+
return `${header}${stringifyYaml(vars, { lineWidth: 0, sortMapEntries: true })}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Record each consumer the provider could not render, against that consumer.
|
|
111
|
+
*
|
|
112
|
+
* The provider is not at fault for a consumer that ships no built site, and the
|
|
113
|
+
* fleet is not at fault either: before this, one such module threw in core's
|
|
114
|
+
* plan and no site converged at all (celilo#1383). The state has to be honest
|
|
115
|
+
* about which module is broken (celilo#1363), so the row says ERROR and carries
|
|
116
|
+
* the reason the provider gave.
|
|
117
|
+
*
|
|
118
|
+
* A module that is not installed is skipped rather than inserted: celilo cannot
|
|
119
|
+
* record a state for something it does not have.
|
|
120
|
+
*/
|
|
121
|
+
export function recordUnresolvedConsumers(
|
|
122
|
+
db: DbClient,
|
|
123
|
+
providerModuleId: string,
|
|
124
|
+
unresolved: UnresolvedConsumer[],
|
|
125
|
+
): string[] {
|
|
126
|
+
const recorded: string[] = [];
|
|
127
|
+
|
|
128
|
+
for (const consumer of unresolved) {
|
|
129
|
+
const existing = db.select().from(modules).where(eq(modules.id, consumer.moduleId)).get();
|
|
130
|
+
if (!existing) continue;
|
|
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
|
+
|
|
140
|
+
db.update(modules)
|
|
141
|
+
.set({
|
|
142
|
+
state: 'ERROR',
|
|
143
|
+
errorMessage: `'${providerModuleId}' could not serve this module: ${consumer.reason}`,
|
|
144
|
+
updatedAt: new Date(),
|
|
145
|
+
})
|
|
146
|
+
.where(eq(modules.id, consumer.moduleId))
|
|
147
|
+
.run();
|
|
148
|
+
recorded.push(consumer.moduleId);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return recorded;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** What one converge did, for the caller to report. */
|
|
155
|
+
export interface ProviderConvergeResult {
|
|
156
|
+
success: boolean;
|
|
157
|
+
error?: string;
|
|
158
|
+
/** The written vars file, absolute — for logs and tests. */
|
|
159
|
+
varsPath?: string;
|
|
160
|
+
/** Consumers recorded in ERROR because the provider could not render them. */
|
|
161
|
+
unresolved: string[];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The tags a converge runs. The provider's whole playbook re-runs package
|
|
166
|
+
* installation and service enablement, which a route change has no business
|
|
167
|
+
* touching; these two cover the file placement and `/srv/www`.
|
|
168
|
+
*/
|
|
169
|
+
const CONVERGE_TAGS = ['provider_config', 'static_content'];
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Plan, write, converge: make the provider's host match what the provider
|
|
173
|
+
* rendered.
|
|
174
|
+
*
|
|
175
|
+
* `generatedPath` is rendered on demand when it is missing, because a
|
|
176
|
+
* successful deploy deletes the generated tree by design (D4 of
|
|
177
|
+
* control-plane-stops-building-modules) and generation is fast and local.
|
|
178
|
+
*/
|
|
179
|
+
export async function convergeProviderConfig(
|
|
180
|
+
db: DbClient,
|
|
181
|
+
providerModuleId: string,
|
|
182
|
+
artifacts: ProviderArtifacts,
|
|
183
|
+
options?: { tags?: string[] },
|
|
184
|
+
): Promise<ProviderConvergeResult> {
|
|
185
|
+
const unresolved = recordUnresolvedConsumers(db, providerModuleId, artifacts.unresolved);
|
|
186
|
+
|
|
187
|
+
const module = db.select().from(modules).where(eq(modules.id, providerModuleId)).get();
|
|
188
|
+
if (!module) {
|
|
189
|
+
return {
|
|
190
|
+
success: false,
|
|
191
|
+
error: `Provider module '${providerModuleId}' is not installed`,
|
|
192
|
+
unresolved,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const generatedPath = join(module.sourcePath, 'generated');
|
|
197
|
+
if (!existsSync(generatedPath)) {
|
|
198
|
+
const { generateTemplates } = await import('../templates/generator');
|
|
199
|
+
const rendered = await generateTemplates({
|
|
200
|
+
moduleId: providerModuleId,
|
|
201
|
+
modulePath: module.sourcePath,
|
|
202
|
+
outputPath: generatedPath,
|
|
203
|
+
db,
|
|
204
|
+
skipVariableValidation: false,
|
|
205
|
+
});
|
|
206
|
+
if (!rendered.success) {
|
|
207
|
+
return {
|
|
208
|
+
success: false,
|
|
209
|
+
error:
|
|
210
|
+
`Provider '${providerModuleId}' has no generated deploy project at ${generatedPath} ` +
|
|
211
|
+
`and rendering one failed: ${rendered.error ?? 'unknown error'}. ` +
|
|
212
|
+
`Run \`celilo module deploy ${providerModuleId}\` once, then retry.`,
|
|
213
|
+
unresolved,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A generated project that predates the converge role tasks would read the
|
|
219
|
+
// new vars file and place nothing, so the desired state would be written and
|
|
220
|
+
// silently never applied. The variable appearing nowhere in the tree is the
|
|
221
|
+
// tell. Generic string check — core names no module.
|
|
222
|
+
//
|
|
223
|
+
// CHECKED BEFORE THE VARS ARE WRITTEN, and the order is the whole point: the
|
|
224
|
+
// vars file lands inside the tree being scanned and contains the variable
|
|
225
|
+
// name, so a guard that runs after the write finds its own output and can
|
|
226
|
+
// never fail. That is celilo#1248's bug — a check that cannot reach its
|
|
227
|
+
// subject — reintroduced by write order rather than by path.
|
|
228
|
+
const ansiblePath = join(generatedPath, 'ansible');
|
|
229
|
+
if (existsSync(ansiblePath) && !ansibleTreeMentions(ansiblePath, 'provider_config_files')) {
|
|
230
|
+
return {
|
|
231
|
+
success: false,
|
|
232
|
+
error: `The generated project for '${providerModuleId}' predates the provider converge (its role never reads provider_config_files). Redeploy the provider (\`celilo module deploy ${providerModuleId}\`) to regenerate it, then retry.`,
|
|
233
|
+
unresolved,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const varsPath = providerConfigVarsPath(generatedPath);
|
|
238
|
+
await mkdir(join(varsPath, '..'), { recursive: true });
|
|
239
|
+
await writeFile(varsPath, providerConfigVarsYaml(artifacts), 'utf-8');
|
|
240
|
+
|
|
241
|
+
const { executeAnsible } = await import('./deploy-ansible');
|
|
242
|
+
const result = await executeAnsible(generatedPath, { tags: options?.tags ?? CONVERGE_TAGS });
|
|
243
|
+
if (!result.success) {
|
|
244
|
+
return {
|
|
245
|
+
success: false,
|
|
246
|
+
error: `Provider converge failed on ${providerModuleId}: ${result.error ?? 'unknown error'}`,
|
|
247
|
+
varsPath,
|
|
248
|
+
unresolved,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// The converge rendered the tree, used it, and is done with it: desired state
|
|
253
|
+
// is rebuilt from the declared rows every time and never kept as a record
|
|
254
|
+
// (design D5). On the failure paths above the tree stays for inspection.
|
|
255
|
+
rmSync(generatedPath, { recursive: true, force: true });
|
|
256
|
+
return { success: true, varsPath, unresolved };
|
|
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
|
+
}
|