@celilo/cli 3.1.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_SUBSYSTEMS.md +1 -0
- package/package.json +5 -4
- package/src/cli/command-tree-parser.test.ts +18 -0
- package/src/cli/command-tree-parser.ts +12 -0
- package/src/cli/commands/api.ts +67 -14
- package/src/cli/commands/events.ts +5 -2
- package/src/cli/commands/machine-reclassify.ts +79 -0
- package/src/cli/commands/publish/workspace.ts +9 -0
- package/src/cli/commands/system-audit.ts +22 -0
- package/src/cli/commands/system-reboot.ts +126 -0
- package/src/cli/commands/system-update.ts +5 -0
- package/src/cli/completion.ts +3 -2
- package/src/cli/index.ts +19 -0
- package/src/cli/tui/audit-state.ts +4 -0
- package/src/hooks/broker.ts +31 -0
- package/src/hooks/capability-loader.ts +87 -44
- package/src/hooks/executor.ts +20 -2
- package/src/hooks/test-fixtures/slow-capability-hook.ts +28 -0
- package/src/hooks/timeout-names-provider.test.ts +87 -0
- package/src/policy/module-business-baseline.ts +0 -6
- package/src/services/api-access.test.ts +44 -0
- package/src/services/audit/index.test.ts +7 -0
- package/src/services/audit/index.ts +15 -0
- package/src/services/audit/machine-interface-zones.test.ts +30 -0
- package/src/services/audit/machine-interface-zones.ts +43 -0
- package/src/services/audit/reboot-pending.test.ts +63 -0
- package/src/services/audit/reboot-pending.ts +85 -0
- package/src/services/audit/recurrence-gate.test.ts +21 -0
- package/src/services/audit/server-bun-pin.test.ts +44 -0
- package/src/services/audit/server-bun-pin.ts +95 -0
- package/src/services/audit/types.ts +2 -0
- package/src/services/capability-compat.test.ts +22 -0
- package/src/services/capability-compat.ts +15 -2
- package/src/services/celilo-events.test.ts +39 -0
- package/src/services/celilo-events.ts +41 -4
- package/src/services/deploy-preflight.ts +12 -3
- package/src/services/deploy-validation.test.ts +81 -2
- package/src/services/deploy-validation.ts +19 -0
- package/src/services/fleet-checks.ts +40 -10
- package/src/services/machine-interface-zones.test.ts +73 -0
- package/src/services/machine-interface-zones.ts +101 -0
- package/src/services/module-deploy.stale-provider-guard.test.ts +98 -0
- package/src/services/module-deploy.ts +48 -13
- package/src/services/provider-arrival.test.ts +62 -46
- package/src/services/provider-arrival.ts +41 -10
- package/src/services/provider-converge.test.ts +16 -0
- package/src/services/provider-converge.ts +106 -4
- package/src/services/system-reboot.test.ts +231 -0
- package/src/services/system-reboot.ts +357 -0
- package/src/services/update/orchestrator.test.ts +164 -0
- package/src/services/update/orchestrator.ts +43 -12
- package/src/services/update/types.ts +10 -1
- package/src/templates/generator.test.ts +15 -4
- package/src/templates/generator.ts +12 -4
- package/src/variables/context.ts +14 -1
- package/src/services/static-content-converge.test.ts +0 -516
- package/src/services/static-content-converge.ts +0 -390
|
@@ -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
|
-
}
|