import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { eq } from 'drizzle-orm'; import { parse as parseYaml } from 'yaml'; import type { DbClient } from '../db/client'; import { modules } from '../db/schema'; import { setupTestDatabaseAt } from '../test-utils/database'; import { resetTestDbPath } from '../test-utils/db-path'; import { type ProviderArtifacts, convergeProviderConfig, providerConfigVarsPath, providerConfigVarsYaml, recordUnresolvedConsumers, } from './provider-converge'; const HASH = 'a'.repeat(64); function artifacts(overrides: Partial = {}): ProviderArtifacts { return { files: [{ path: '/etc/caddy/Caddyfile', content: 'peterbanka.org {\n\ttls internal\n}\n' }], sites: [ { slug: 'peterbanka-org', moduleId: 'peterbanka-org', contentHash: HASH, hostnames: ['peterbanka.org'], sourceDir: '/var/celilo/modules/peterbanka-org/site/dist', }, ], unresolved: [], retention: 5, ...overrides, }; } describe('providerConfigVarsYaml — the provider renders, celilo carries', () => { it('carries each rendered file so the role can place it verbatim', () => { const vars = parseYaml(providerConfigVarsYaml(artifacts())); expect(vars.provider_config_files).toEqual([ { path: '/etc/caddy/Caddyfile', content: 'peterbanka.org {\n\ttls internal\n}\n' }, ]); }); it('carries the sites under the variable the static-content role already reads', () => { const vars = parseYaml(providerConfigVarsYaml(artifacts())); expect(vars.static_release_retention).toBe(5); expect(vars.static_releases).toEqual([ { slug: 'peterbanka-org', content_hash: HASH, hostnames: ['peterbanka.org'], source_dir: '/var/celilo/modules/peterbanka-org/site/dist', }, ]); }); it('omits overlay_dir when there is none, so the role guard reads a clean variable', () => { const withOverlay = artifacts({ sites: [ { ...artifacts().sites[0], overlayDir: '/var/celilo/modules/peterbanka-org/state/site' }, ], }); expect(parseYaml(providerConfigVarsYaml(artifacts())).static_releases[0].overlay_dir).toBe( undefined, ); expect(parseYaml(providerConfigVarsYaml(withOverlay)).static_releases[0].overlay_dir).toBe( '/var/celilo/modules/peterbanka-org/state/site', ); }); it("one consumer the provider could not render does not remove another's site", () => { const partial = artifacts({ unresolved: [ { moduleId: 'byoi', reason: 'no built site at /var/celilo/modules/byoi/site/dist' }, ], }); const vars = parseYaml(providerConfigVarsYaml(partial)); expect(vars.static_releases.map((r: { slug: string }) => r.slug)).toEqual(['peterbanka-org']); }); it('is deterministic, so an unchanged desired state writes an unchanged file', () => { expect(providerConfigVarsYaml(artifacts())).toBe(providerConfigVarsYaml(artifacts())); }); }); describe('providerConfigVarsPath — lands in the generated inventory', () => { it('writes into the auto-loaded group_vars directory', () => { expect(providerConfigVarsPath('/tmp/generated')).toBe( '/tmp/generated/ansible/inventory/group_vars/all/provider_config.yml', ); }); }); describe('recordUnresolvedConsumers — the failure belongs to the consumer', () => { let dir: string; let db: DbClient; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'provider-converge-')); const dbPath = join(dir, 'celilo.db'); process.env.CELILO_DB_PATH = dbPath; db = await setupTestDatabaseAt(dbPath); for (const id of ['caddy', 'byoi', 'peterbanka-org']) { db.insert(modules) .values({ id, name: id, version: '1.0.0', manifestData: {}, sourcePath: join(dir, 'modules', id), state: 'VERIFIED', }) .onConflictDoNothing() .run(); } }); afterEach(() => { db.$client.close(); resetTestDbPath(); try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); function stateOf(id: string) { return db.select().from(modules).where(eq(modules.id, id)).get(); } it('leaves a PAUSED consumer paused — a provider cannot render it by design', () => { db.update(modules).set({ state: 'PAUSED' }).where(eq(modules.id, 'byoi')).run(); const recorded = recordUnresolvedConsumers(db, 'caddy', [ { moduleId: 'byoi', reason: "module 'byoi' is paused — its site is left as it is" }, ]); // Pause preserves state, and that includes the module's own recorded // state. A paused module is unresolvable on EVERY converge, so marking it // ERROR here would mean an operator could not pause a module without the // next publish reporting it failed. expect(recorded).toEqual([]); expect(stateOf('byoi')?.state).toBe('PAUSED'); expect(stateOf('byoi')?.errorMessage ?? null).toBeNull(); }); it('records the consumer in ERROR with the reason it could not be rendered', () => { recordUnresolvedConsumers(db, 'caddy', [ { moduleId: 'byoi', reason: 'no built site at /var/celilo/modules/byoi/site/dist' }, ]); const byoi = stateOf('byoi'); expect(byoi?.state).toBe('ERROR'); expect(byoi?.errorMessage).toContain('no built site'); }); it('leaves the provider and every other consumer alone', () => { recordUnresolvedConsumers(db, 'caddy', [{ moduleId: 'byoi', reason: 'no built site' }]); expect(stateOf('caddy')?.state).toBe('VERIFIED'); expect(stateOf('peterbanka-org')?.state).toBe('VERIFIED'); }); it('returns the modules it recorded, so the converge can report them', () => { const recorded = recordUnresolvedConsumers(db, 'caddy', [ { moduleId: 'byoi', reason: 'no built site' }, ]); expect(recorded).toEqual(['byoi']); }); it('records the consumer even when the converge cannot run at all', async () => { const result = await convergeProviderConfig(db, 'not-installed-provider', { files: [], sites: [], unresolved: [{ moduleId: 'byoi', reason: 'no built site' }], retention: 5, }); expect(result.success).toBe(false); expect(result.error).toContain('not installed'); // The consumer's fault is recorded whatever happens to the provider run: // the two failures are unrelated and the operator needs both. expect(result.unresolved).toEqual(['byoi']); expect(stateOf('byoi')?.state).toBe('ERROR'); }); it('refuses a generated project that predates the converge, naming the redeploy', async () => { const generated = join(dir, 'modules', 'caddy', 'generated', 'ansible', 'roles'); mkdirSync(generated, { recursive: true }); writeFileSync(join(generated, 'main.yml'), '- name: does not read the new vars\n', 'utf-8'); const result = await convergeProviderConfig(db, 'caddy', artifacts()); expect(result.success).toBe(false); expect(result.error).toContain('predates the provider converge'); expect(result.error).toContain('celilo module deploy caddy'); // Nothing was written: the guard scans the tree for the variable name, and // the vars file would land inside that tree carrying it. Writing first // makes the guard find its own output and pass forever (celilo#1248). expect(result.varsPath).toBe(undefined); expect(existsSync(providerConfigVarsPath(join(dir, 'modules', 'caddy', 'generated')))).toBe( false, ); }); it('ignores a module that is not installed rather than inventing a row', () => { const recorded = recordUnresolvedConsumers(db, 'caddy', [ { moduleId: 'never-installed', reason: 'no built site' }, ]); expect(recorded).toEqual([]); expect(stateOf('never-installed')).toBe(undefined); }); });