/** * Re-running a provider's consumers when that provider arrives * (openspec/changes/capability-owned-tables, stage 1). * * celilo handled one side of the capability relationship generically and the * other by hand. A consumer leaving goes through `consumer-cleanup.ts`, which * finds every provider it used and calls each one's `on_consumer_removed` with * no capability names in the dispatch. A provider ARRIVING had three * hand-written pieces covering two capabilities, and `firewall` — which three * modules provide — had none at all (celilo#1011). * * This is the mirror of `consumer-cleanup.ts`, and it is deliberately built the * same way, down to sharing that file's `PRE_DEPLOY_STATES`. * * PULL, NOT PUSH (design D7). When a provider arrives, celilo re-runs the * CONSUMERS' `on_install` and lets each consumer re-register through the path * that worked the first time. It does NOT replay history into the provider by * calling the provider's own hooks on a consumer's behalf. Two of the three * pieces this replaces pushed, and one of them had already drifted: * `backfillWebRouteDns` cannot go through `on_system_event`, because that hook * concatenates `.` and would corrupt an already-qualified name, * so it reaches past the hook into `registerRecord` and now differs from the * live path in ways nothing checks. * * Split plan/execute (Rule 10.4): which consumers, and why one is skipped, is * pure and worth testing on its own. The rest is "run each hook and record what * happened". */ import { eq } from 'drizzle-orm'; import type { DbClient } from '../db/client'; import { capabilities, modules } from '../db/schema'; import { createConsoleLogger } from '../hooks/logger'; import { type RunNamedHookResult, runNamedHook } from '../hooks/run-named-hook'; import type { HookLogger } from '../hooks/types'; import { type ModuleManifest, ModuleManifestSchema } from '../manifest/schema'; import { PRE_DEPLOY_STATES } from './consumer-cleanup'; export type BackfillSkipReason = 'paused' | 'not-deployed'; export interface BackfillTarget { /** The consumer module to re-run. */ consumerId: string; /** Which of the arriving provider's capabilities it consumes — for the log line. */ capabilityNames: string[]; /** Set when the consumer will NOT be re-run. */ skip?: BackfillSkipReason; } export interface ConsumerCandidate { moduleId: string; manifest: ModuleManifest; state: string; } /** * Which consumers must be re-run now that `provider` is here. * * Pure. Sorted by consumer id so dispatch order is deterministic and a failure * is reproducible. * * `requires` AND `optional`, which is the edge `remove-guard.ts` already counts * and the one `optional` was added to express — "this hook will use monitoring * if it is there" describes a module that has something to gain the moment the * provider appears. * * The provider is never its own consumer. A module that both provides and * requires a capability would otherwise have its `on_install` run a second time * inside its own deploy, which just finished running it. */ export function planProviderBackfill( provider: string, providedCapabilities: Iterable, candidates: ConsumerCandidate[], ): BackfillTarget[] { const provided = new Set(providedCapabilities); if (provided.size === 0) return []; const targets: BackfillTarget[] = []; for (const candidate of candidates) { if (candidate.moduleId === provider) continue; const consumed = new Set([ ...(candidate.manifest.requires?.capabilities ?? []).map((c) => c.name), ...(candidate.manifest.optional?.capabilities ?? []).map((c) => c.name), ]); const capabilityNames = [...consumed].filter((name) => provided.has(name)).sort(); if (capabilityNames.length === 0) continue; if (candidate.state === 'PAUSED') { targets.push({ consumerId: candidate.moduleId, capabilityNames, skip: 'paused' }); continue; } if (PRE_DEPLOY_STATES.has(candidate.state)) { targets.push({ consumerId: candidate.moduleId, capabilityNames, skip: 'not-deployed' }); continue; } targets.push({ consumerId: candidate.moduleId, capabilityNames }); } return targets.sort((a, b) => a.consumerId.localeCompare(b.consumerId)); } /** * Read the plan's inputs out of the DB. * * A module's manifest lives in `modules.manifestData`; a manifest that no * longer parses is skipped rather than fatal, matching `module-remove.ts`'s * dependency scan — a provider deploy must not die on some unrelated module's * bad row. */ export function loadProviderBackfillPlan(provider: string, db: DbClient): BackfillTarget[] { const provided = db .select({ capabilityName: capabilities.capabilityName }) .from(capabilities) .where(eq(capabilities.moduleId, provider)) .all() .map((row) => row.capabilityName); if (provided.length === 0) return []; const candidates: ConsumerCandidate[] = []; for (const row of db.select().from(modules).all()) { const parsed = ModuleManifestSchema.safeParse(row.manifestData); if (!parsed.success) continue; candidates.push({ moduleId: row.id, manifest: parsed.data, state: row.state }); } return planProviderBackfill(provider, provided, candidates); } export interface BackfillFailure { consumerId: string; error: string; } export interface ProviderBackfillResult { /** Consumers whose `on_install` was re-run successfully. */ rerun: string[]; skipped: Array<{ consumerId: string; reason: BackfillSkipReason }>; failures: BackfillFailure[]; } /** * Run the plan. Sequential, deterministic, and it CONTINUES PAST A FAILURE: * every consumer is attempted even if an earlier one threw, because stopping * early would leave the remaining consumers unregistered against a provider * that is now live, which is the gap this exists to close. * * A failure never fails the provider's own deploy. The provider deployed fine; * what failed is one consumer's re-registration, and the honest report is a * named consumer with its error and the command that retries it. The caller * surfaces that — silence here would report a clean deploy over a fleet where * some consumers never re-registered. */ export async function runProviderBackfill( provider: string, plan: BackfillTarget[], db: DbClient, logger: HookLogger, /** Injectable hook runner — tests drive the failure paths through it. */ runHook: (consumerId: string) => Promise = (consumerId) => runNamedHook(consumerId, 'on_install', db, createConsoleLogger(consumerId, 'on_install'), {}), ): Promise { const result: ProviderBackfillResult = { rerun: [], skipped: [], failures: [] }; for (const target of plan) { if (target.skip) { result.skipped.push({ consumerId: target.consumerId, reason: target.skip }); // Reported, not silent. A paused consumer rebinds on its next deploy, so // it is genuinely fine — but the operator should know which modules are // NOT yet talking to the provider that just arrived. if (target.skip === 'paused') { logger.warn( `${target.consumerId} is paused, so it was not re-run against the new '${provider}' — it consumes ${target.capabilityNames.join(', ')} and will rebind on its next deploy.`, ); } continue; } const hookResult = await runHook(target.consumerId); // A module paused BETWEEN the plan and this dispatch. `runNamedHook` // reports that as success, so taking it at face value would log a // re-registration that never happened. if (hookResult.skippedPaused) { result.skipped.push({ consumerId: target.consumerId, reason: 'paused' }); logger.warn( `${target.consumerId} was paused while '${provider}' was arriving, so it was not re-run — it will rebind on its next deploy.`, ); continue; } if (hookResult.success) { // `notDefined` means the consumer declares no `on_install` — there is // nothing to re-run, and the dispatch succeeding is the right answer. if (!hookResult.notDefined) { result.rerun.push(target.consumerId); logger.info( `${target.consumerId} re-registered with '${provider}' (${target.capabilityNames.join(', ')})`, ); } continue; } const error = hookResult.error ?? 'unknown error'; result.failures.push({ consumerId: target.consumerId, error }); logger.warn( `${target.consumerId} failed to re-register with '${provider}': ${error}. Run \`celilo module deploy ${target.consumerId}\` to retry.`, ); } return result; }