import { describe, expect, it } from 'bun:test'; import type { RunNamedHookResult } from '../hooks/run-named-hook'; import type { HookLogger } from '../hooks/types'; import type { ModuleManifest } from '../manifest/schema'; import { type ConsumerCandidate, planProviderBackfill, runProviderBackfill, } from './provider-arrival'; /** * The pure half of provider arrival: which consumers get re-run, and which are * skipped and why. All of it is decided without a database, a hook runner, or a * deployed anything, which is the point of the split. */ function manifest(input: { requires?: string[]; optional?: string[] }): ModuleManifest { return { requires: { capabilities: (input.requires ?? []).map((name) => ({ name, version: '1.0.0' })) }, optional: { capabilities: (input.optional ?? []).map((name) => ({ name, version: '1.0.0' })) }, } as unknown as ModuleManifest; } const consumer = ( moduleId: string, caps: { requires?: string[]; optional?: string[] }, state = 'VERIFIED', ): ConsumerCandidate => ({ moduleId, manifest: manifest(caps), state }); describe('planProviderBackfill', () => { it('re-runs consumers of the capabilities the arriving provider actually provides', () => { const plan = planProviderBackfill( 'iptables', ['firewall'], [ consumer('caddy', { requires: ['firewall'] }), consumer('forgejo', { requires: ['public_web'] }), ], ); expect(plan.map((t) => t.consumerId)).toEqual(['caddy']); }); /** * `optional` is the edge `remove-guard.ts` already counts, and it is the one * the field was added to express — "this hook will use monitoring if it is * there" describes exactly a module with something to gain the moment the * provider appears. Counting only `requires` would leave it unregistered. */ it('includes an OPTIONAL dependent, not only a required one', () => { const plan = planProviderBackfill( 'technitium', ['dns_internal'], [consumer('caddy-internal', { optional: ['dns_internal'] })], ); expect(plan.map((t) => t.consumerId)).toEqual(['caddy-internal']); expect(plan[0]?.skip).toBeUndefined(); }); it('never treats the provider as its own consumer', () => { // A module that both provides and requires a capability would otherwise // have its on_install run a second time inside the deploy that just ran it. const plan = planProviderBackfill( 'caddy', ['public_web'], [ consumer('caddy', { requires: ['public_web', 'firewall'] }), consumer('forgejo', { requires: ['public_web'] }), ], ); expect(plan.map((t) => t.consumerId)).toEqual(['forgejo']); }); it('skips a consumer in a pre-deploy state, and says so rather than dropping it', () => { // Capabilities are registered at IMPORT, not deploy, so an imported module // has never resolved anything and has nothing to re-register. const plan = planProviderBackfill( 'iptables', ['firewall'], [ consumer('imported', { requires: ['firewall'] }, 'IMPORTED'), consumer('validated', { requires: ['firewall'] }, 'VALIDATED'), consumer('configured', { requires: ['firewall'] }, 'CONFIGURED'), ], ); expect(plan.map((t) => t.skip)).toEqual(['not-deployed', 'not-deployed', 'not-deployed']); }); it('skips a PAUSED consumer separately from a never-deployed one', () => { const plan = planProviderBackfill( 'iptables', ['firewall'], [consumer('paused', { requires: ['firewall'] }, 'PAUSED')], ); expect(plan[0]?.skip).toBe('paused'); }); it('names every capability the consumer takes from this provider, once', () => { const plan = planProviderBackfill( 'greenwave', ['firewall', 'dhcp_server'], [consumer('caddy', { requires: ['firewall'], optional: ['dhcp_server'] })], ); expect(plan).toHaveLength(1); expect(plan[0]?.capabilityNames).toEqual(['dhcp_server', 'firewall']); }); it('is empty for a module that provides nothing', () => { expect( planProviderBackfill('forgejo', [], [consumer('caddy', { requires: ['firewall'] })]), ).toEqual([]); }); it('is ordered by consumer id, so a failure is reproducible', () => { const plan = planProviderBackfill( 'iptables', ['firewall'], [consumer('zulu', { requires: ['firewall'] }), consumer('alpha', { requires: ['firewall'] })], ); expect(plan.map((t) => t.consumerId)).toEqual(['alpha', 'zulu']); }); }); const silentLogger = (): HookLogger & { warnings: string[] } => { const warnings: string[] = []; return { warnings, info: () => {}, warn: (m: string) => warnings.push(m), error: () => {}, success: () => {}, debug: () => {}, } as unknown as HookLogger & { warnings: string[] }; }; const ok = (): RunNamedHookResult => ({ success: true }) as RunNamedHookResult; const fails = (error: string): RunNamedHookResult => ({ success: false, error }) as RunNamedHookResult; describe('runProviderBackfill', () => { /** * The property the whole thing exists for. Stopping at the first failure * would leave the remaining consumers unregistered against a provider that is * now live — the same gap, just narrower. */ it('attempts every consumer even after one fails, and names each failure', async () => { const attempted: string[] = []; const result = await runProviderBackfill( 'iptables', [ { consumerId: 'alpha', capabilityNames: ['firewall'] }, { consumerId: 'bravo', capabilityNames: ['firewall'] }, { consumerId: 'charlie', capabilityNames: ['firewall'] }, ], {} as never, silentLogger(), async (id) => { attempted.push(id); return id === 'bravo' ? fails('ssh timed out') : ok(); }, ); expect(attempted).toEqual(['alpha', 'bravo', 'charlie']); expect(result.rerun).toEqual(['alpha', 'charlie']); expect(result.failures).toEqual([{ consumerId: 'bravo', error: 'ssh timed out' }]); }); it('names the consumer and the retry command in the warning an operator reads', async () => { const logger = silentLogger(); await runProviderBackfill( 'iptables', [{ consumerId: 'bravo', capabilityNames: ['firewall'] }], {} as never, logger, async () => fails('ssh timed out'), ); expect(logger.warnings.join('\n')).toContain('bravo'); expect(logger.warnings.join('\n')).toContain('celilo module deploy bravo'); }); it('does not run a skipped consumer at all', async () => { const attempted: string[] = []; const result = await runProviderBackfill( 'iptables', [ { consumerId: 'paused', capabilityNames: ['firewall'], skip: 'paused' }, { consumerId: 'fresh', capabilityNames: ['firewall'], skip: 'not-deployed' }, ], {} as never, silentLogger(), async (id) => { attempted.push(id); return ok(); }, ); expect(attempted).toEqual([]); expect(result.skipped).toEqual([ { consumerId: 'paused', reason: 'paused' }, { consumerId: 'fresh', reason: 'not-deployed' }, ]); }); /** * `runNamedHook` reports a paused module as a plain success, so trusting the * flag alone would log a re-registration that never happened — the silence * this change exists to end, reintroduced one layer down. */ it('reads skippedPaused rather than trusting success, for a module paused mid-dispatch', async () => { const result = await runProviderBackfill( 'iptables', [{ consumerId: 'racer', capabilityNames: ['firewall'] }], {} as never, silentLogger(), async () => ({ success: true, skippedPaused: true }) as RunNamedHookResult, ); expect(result.rerun).toEqual([]); expect(result.skipped).toEqual([{ consumerId: 'racer', reason: 'paused' }]); }); it('does not count a consumer with no on_install as re-registered', async () => { const result = await runProviderBackfill( 'iptables', [{ consumerId: 'hookless', capabilityNames: ['firewall'] }], {} as never, silentLogger(), async () => ({ success: true, notDefined: true }) as RunNamedHookResult, ); expect(result.rerun).toEqual([]); expect(result.failures).toEqual([]); }); });