/** * The provider converge (openspec/changes/providers-converge-declared-state). * * A provider renders its own config from the rows it declares, and celilo makes * the host match by running that provider's own Ansible role. One writer on the * box, one path from declared state to a running config. * * Generalised from `static-content-converge.ts`, which already does this for * static site content: desired state into the provider's generated inventory, * then a tag-scoped `executeAnsible`. The difference is who decides what the * desired state is. There, core walks every route row in the fleet and throws * when any one module's site is missing (celilo#1383 — two out-of-tree modules * with no `site/dist` made the public ingress undeployable). Here the provider * hands over what it rendered, and a consumer it could not render is recorded * against that consumer. */ import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { and, eq } from 'drizzle-orm'; import { stringify as stringifyYaml } from 'yaml'; import type { DbClient } from '../db/client'; import { moduleConfigs, modules } from '../db/schema'; import { parseStoredConfigValue } from './module-config'; /** * Retention when the operator has not set one. Mirrors the manifest default * declared on the provider module (`static_release_retention`); the manifest * is the operator-facing source of truth, this only covers a provider whose * manifest predates the variable. */ const DEFAULT_STATIC_RELEASE_RETENTION = 5; /** One file the provider wants on its host, rendered by the provider. */ export interface ProviderConfigFile { /** Absolute path on the provider host. */ path: string; /** The rendered contents. */ content: string; } /** A consumer's site the provider host must carry. */ export interface ProviderSite { slug: string; moduleId: string; contentHash: string; hostnames: string[]; sourceDir: string; overlayDir?: string; } /** A consumer the provider could not render, and why. */ export interface UnresolvedConsumer { moduleId: string; reason: string; } /** Everything the provider rendered for one converge. */ export interface ProviderArtifacts { files: ProviderConfigFile[]; sites: ProviderSite[]; unresolved: UnresolvedConsumer[]; retention: number; } /** * Where the provider's desired state lands inside its generated project. * * `group_vars/all/` is the existing auto-load directory — the same one * `static_content.yml` uses — so no playbook change is needed to read it. */ export function providerConfigVarsPath(generatedPath: string): string { return join(generatedPath, 'ansible', 'inventory', 'group_vars', 'all', 'provider_config.yml'); } /** * Render the vars file. Presentation function (Rule 10.1) — deterministic YAML, * so an unchanged desired state writes a byte-identical file and the role's * config task reports no change. * * `static_releases` keeps its name: the role tasks that converge `/srv/www` * already read it, and renaming a working variable to match a new writer would * be churn with a live-site failure mode. */ export function providerConfigVarsYaml(artifacts: ProviderArtifacts): string { const vars = { provider_config_files: artifacts.files.map((file) => ({ path: file.path, content: file.content, })), static_release_retention: artifacts.retention, static_releases: artifacts.sites.map((site) => ({ slug: site.slug, content_hash: site.contentHash, hostnames: site.hostnames, source_dir: site.sourceDir, // Absent when there is no overlay, so the role's `when:` guard reads a // clean variable rather than an empty string a copy task would choke on. ...(site.overlayDir ? { overlay_dir: site.overlayDir } : {}), })), }; const header = '# Desired provider state, generated by celilo (providers-converge-declared-state).\n' + '# Hand edits are overwritten by the next converge.\n'; return `${header}${stringifyYaml(vars, { lineWidth: 0, sortMapEntries: true })}`; } /** * Record each consumer the provider could not render, against that consumer. * * The provider is not at fault for a consumer that ships no built site, and the * fleet is not at fault either: before this, one such module threw in core's * plan and no site converged at all (celilo#1383). The state has to be honest * about which module is broken (celilo#1363), so the row says ERROR and carries * the reason the provider gave. * * A module that is not installed is skipped rather than inserted: celilo cannot * record a state for something it does not have. */ export function recordUnresolvedConsumers( db: DbClient, providerModuleId: string, unresolved: UnresolvedConsumer[], ): string[] { const recorded: string[] = []; for (const consumer of unresolved) { const existing = db.select().from(modules).where(eq(modules.id, consumer.moduleId)).get(); if (!existing) continue; // A PAUSED module is not broken, and pause preserves state — including the // module's own recorded state. A provider legitimately cannot render a // paused consumer (its site is deliberately left alone), so this path is // reached on every converge while any module is paused; writing ERROR // there would mean an operator could not pause a module without the next // publish marking it failed. if (existing.state === 'PAUSED') continue; db.update(modules) .set({ state: 'ERROR', errorMessage: `'${providerModuleId}' could not serve this module: ${consumer.reason}`, updatedAt: new Date(), }) .where(eq(modules.id, consumer.moduleId)) .run(); recorded.push(consumer.moduleId); } return recorded; } /** What one converge did, for the caller to report. */ export interface ProviderConvergeResult { success: boolean; error?: string; /** The written vars file, absolute — for logs and tests. */ varsPath?: string; /** Consumers recorded in ERROR because the provider could not render them. */ unresolved: string[]; } /** * The tags a converge runs. The provider's whole playbook re-runs package * installation and service enablement, which a route change has no business * touching; these two cover the file placement and `/srv/www`. */ const CONVERGE_TAGS = ['provider_config', 'static_content']; /** * Plan, write, converge: make the provider's host match what the provider * rendered. * * `generatedPath` is rendered on demand when it is missing, because a * successful deploy deletes the generated tree by design (D4 of * control-plane-stops-building-modules) and generation is fast and local. */ export async function convergeProviderConfig( db: DbClient, providerModuleId: string, artifacts: ProviderArtifacts, options?: { tags?: string[] }, ): Promise { const unresolved = recordUnresolvedConsumers(db, providerModuleId, artifacts.unresolved); const module = db.select().from(modules).where(eq(modules.id, providerModuleId)).get(); if (!module) { return { success: false, error: `Provider module '${providerModuleId}' is not installed`, unresolved, }; } const generatedPath = join(module.sourcePath, 'generated'); if (!existsSync(generatedPath)) { const { generateTemplates } = await import('../templates/generator'); const rendered = await generateTemplates({ moduleId: providerModuleId, modulePath: module.sourcePath, outputPath: generatedPath, db, skipVariableValidation: false, }); if (!rendered.success) { return { success: false, error: `Provider '${providerModuleId}' has no generated deploy project at ${generatedPath} ` + `and rendering one failed: ${rendered.error ?? 'unknown error'}. ` + `Run \`celilo module deploy ${providerModuleId}\` once, then retry.`, unresolved, }; } } // A generated project that predates the converge role tasks would read the // new vars file and place nothing, so the desired state would be written and // silently never applied. The variable appearing nowhere in the tree is the // tell. Generic string check — core names no module. // // CHECKED BEFORE THE VARS ARE WRITTEN, and the order is the whole point: the // vars file lands inside the tree being scanned and contains the variable // name, so a guard that runs after the write finds its own output and can // never fail. That is celilo#1248's bug — a check that cannot reach its // subject — reintroduced by write order rather than by path. const ansiblePath = join(generatedPath, 'ansible'); if (existsSync(ansiblePath) && !ansibleTreeMentions(ansiblePath, 'provider_config_files')) { return { success: false, 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.`, unresolved, }; } const varsPath = providerConfigVarsPath(generatedPath); await mkdir(join(varsPath, '..'), { recursive: true }); await writeFile(varsPath, providerConfigVarsYaml(artifacts), 'utf-8'); const { executeAnsible } = await import('./deploy-ansible'); const result = await executeAnsible(generatedPath, { tags: options?.tags ?? CONVERGE_TAGS }); if (!result.success) { return { success: false, error: `Provider converge failed on ${providerModuleId}: ${result.error ?? 'unknown error'}`, varsPath, unresolved, }; } // The converge rendered the tree, used it, and is done with it: desired state // is rebuilt from the declared rows every time and never kept as a record // (design D5). On the failure paths above the tree stays for inspection. rmSync(generatedPath, { recursive: true, force: true }); return { success: true, varsPath, unresolved }; } /** * Resolve the retention count for the provider module. * * Operator config first (`static_release_retention` module config), then the * manifest's declared default, then the constant above. ONE resolution — the * role reads the variable bare and never carries a literal. */ export function resolveStaticContentRetention(db: DbClient, providerModuleId: string): number { const configRow = db .select() .from(moduleConfigs) .where( and( eq(moduleConfigs.moduleId, providerModuleId), eq(moduleConfigs.key, 'static_release_retention'), ), ) .get(); if (configRow) { const parsed = parseStoredConfigValue(configRow); const count = typeof parsed === 'number' ? parsed : Number.parseInt(String(parsed), 10); if (Number.isInteger(count) && count >= 1) return count; throw new Error( `static_release_retention for ${providerModuleId} must be a positive integer, got: ${String(parsed)}`, ); } const module = db.select().from(modules).where(eq(modules.id, providerModuleId)).get(); if (module) { const manifest = module.manifestData as { variables?: { owns?: Array<{ name?: string; default?: unknown }> }; } | null; const declared = manifest?.variables?.owns?.find((v) => v?.name === 'static_release_retention'); const fallback = typeof declared?.default === 'number' ? declared.default : undefined; if (typeof fallback === 'number' && Number.isInteger(fallback) && fallback >= 1) return fallback; } return DEFAULT_STATIC_RELEASE_RETENTION; } /** * Build the release set from the declared `web_routes` rows. * * Pure: reads the DB, decides nothing on the host (Rule 10.4). Grouping is by * slug, NOT by route — one slug is one release directory, and a slug can span * hostnames (lunacycle's two routes, both at `/`). A group that spans modules * or disagrees with itself about the hash is a contradiction the converge * cannot express, so it fails here with both rows named rather than silently * converging one of them. */ /** * Whether any text file in a generated Ansible tree mentions `needle`. * * Scans the TREE rather than `playbook.yml`, because a provider's converge * belongs in one of its role's task files and a generated `playbook.yml` is a * thin hosts/vars/roles stanza that names no variable at all. caddy is the * worked example: `static_releases` appears in * `roles/caddy/tasks/static-content.yml`, `.../main.yml.tpl` and * `.../converge-release.yml`, and zero times in its playbook. * * @psbanka - 2026-09: this used to read `ansible/playbook.yml` alone, which * could not reach the string it was looking for. The guard below therefore * refused EVERY provider that had the converge support, and its remedy told the * operator to redeploy — which regenerates the same thin playbook and cannot * help. Guard and role tasks landed in the same commit (1fd3595e), so it had * never once passed, and it held three e2e suites red where nobody was looking. * celilo#1248. */ export function ansibleTreeMentions(dir: string, needle: string): boolean { if (!existsSync(dir)) return false; for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name); if (entry.isDirectory()) { if (ansibleTreeMentions(full, needle)) return true; continue; } // Text only. A generated tree can carry static assets, and reading those // as utf-8 to look for a variable name is waste at best. if (!/\.(ya?ml|j2|tpl|cfg|ini|conf)$/.test(entry.name)) continue; if (readFileSync(full, 'utf-8').includes(needle)) return true; } return false; }