/** * Telling every provider that one of its consumers is leaving * (openspec/changes/consumer-removal-cleanup). * * A capability is two-sided. The consumer asks, the provider mints something in * its own world — a site block in caddy's Caddyfile, a DNAT rule in a ruleset, * an OIDC client at authentik — and removal only ever touched one side of it. * The FK cascade made that worse rather than better: the registry row * disappeared, so the provider's next converge had no way to learn the thing * had ever existed. The registry went quiet and the machine kept serving. * * This is the generic path that replaces `web-route-cleanup.ts`, which did the * same job for exactly one capability, called by name from core. * * Split plan/execute (Rule 10.4) because the interesting decisions — which * providers, which are skipped and why — are pure, and the part that isn't is * just "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 { type RunNamedHookResult, runNamedHook } from '../hooks/run-named-hook'; import type { HookLogger } from '../hooks/types'; import type { ModuleManifest } from '../manifest/schema'; import { deleteClaimedRows } from './capability-table-rows'; /** * States in which a module has never resolved a capability and therefore holds * nothing minted on anyone's behalf. Capabilities are registered at IMPORT, not * deploy, so the `capabilities` table routinely names providers that were never * deployed. The same predicate `remove-guard.ts` uses to decide a module is not * a dependent — the guard, the cleanup and the provider-arrival backfill must * keep ONE definition of a live module (D8). Exported rather than re-spelled: * `module-remove.ts` had its own copy of the literal, and `provider-arrival.ts` * would have been a third. */ export const PRE_DEPLOY_STATES = new Set(['IMPORTED', 'VALIDATED', 'CONFIGURED']); export type CleanupSkipReason = 'paused' | 'not-deployed'; export interface CleanupTarget { /** The provider module to notify. */ providerId: string; /** Which of its capabilities the departing consumer used — for the log line. */ capabilityNames: string[]; /** Set when the provider will NOT be notified. */ skip?: CleanupSkipReason; } /** * The only part of a manifest this plan reads. * * Narrower than `ModuleManifest` on purpose. A full manifest is a large, * strictly-validated shape, and the console reads manifests straight out of the * `modules` table where they are stored as opaque JSON. Asking for the whole * shape would force either a full re-validation on every poll or a cast, and a * cast at a trust boundary is the thing that eventually bites. `ModuleManifest` * satisfies this structurally, so existing callers are unaffected. */ export interface ConsumedCapabilities { requires?: { capabilities?: readonly { name: string }[] }; optional?: { capabilities?: readonly { name: string }[] }; } export interface ProviderRow { moduleId: string; capabilityName: string; } export interface ProviderState { moduleId: string; state: string; } /** * Which providers must be told that `consumer` is going away. * * Pure. Sorted by provider id so dispatch order is deterministic and a failure * is reproducible. * * ONE ENTRY PER PROVIDER, not per capability (D1): a provider can hold two * capabilities the same consumer used, and dispatching per capability would run * the same withdrawal twice. But MANY providers per capability (D3) — * `capabilities` has no uniqueness on the name, and `firewall` deliberately has * several rows (an edge provider plus inner layers). Every one is told. * * The provider is never its own consumer: a module that both provides and * requires a capability would otherwise be asked to withdraw its own state as * it is being removed, which its `on_uninstall` already owns. */ export function planConsumerCleanup( consumer: string, manifest: ConsumedCapabilities, providerRows: ProviderRow[], providerStates: ProviderState[], ): CleanupTarget[] { // `requires` AND `optional` — the same set `remove-guard.ts` counts as a // dependency edge. A capability consumed optionally still minted state. const consumed = new Set([ ...(manifest.requires?.capabilities ?? []).map((c) => c.name), ...(manifest.optional?.capabilities ?? []).map((c) => c.name), ]); if (consumed.size === 0) return []; const stateOf = new Map(providerStates.map((s) => [s.moduleId, s.state])); const byProvider = new Map>(); for (const row of providerRows) { if (!consumed.has(row.capabilityName)) continue; if (row.moduleId === consumer) continue; const names = byProvider.get(row.moduleId) ?? new Set(); names.add(row.capabilityName); byProvider.set(row.moduleId, names); } return [...byProvider.entries()] .map(([providerId, names]): CleanupTarget => { const state = stateOf.get(providerId); const capabilityNames = [...names].sort(); if (state === 'PAUSED') return { providerId, capabilityNames, skip: 'paused' }; if (state === undefined || PRE_DEPLOY_STATES.has(state)) { return { providerId, capabilityNames, skip: 'not-deployed' }; } return { providerId, capabilityNames }; }) .sort((a, b) => a.providerId.localeCompare(b.providerId)); } /** * Every (module, capability) provider row. * * Exported because the console's closure walk needs the same edge this plan * does, and reads it for many modules rather than one. Two copies of this query * would be two definitions of what a provider is. */ export function loadCapabilityProviderRows(db: DbClient): ProviderRow[] { return db .select({ moduleId: capabilities.moduleId, capabilityName: capabilities.capabilityName }) .from(capabilities) .all(); } /** Read the plan's inputs out of the DB. */ export function loadConsumerCleanupPlan( consumer: string, manifest: ModuleManifest, db: DbClient, ): CleanupTarget[] { const providerRows = loadCapabilityProviderRows(db); const providerStates = db .select({ moduleId: modules.id, state: modules.state }) .from(modules) .all(); return planConsumerCleanup(consumer, manifest, providerRows, providerStates); } export interface CleanupFailure { providerId: string; error: string; } export interface ConsumerCleanupResult { notified: string[]; skipped: Array<{ providerId: string; reason: CleanupSkipReason }>; failures: CleanupFailure[]; } /** * Run the plan. Sequential, deterministic, and it CONTINUES PAST A FAILURE * (D13) — every provider is told even if an earlier one threw. Stopping early * would leave MORE providers holding state for a module that is about to * disappear, which is the failure this exists to fix. * * A failed withdrawal NEVER blocks the removal (D6). The record is an ERRORed * PROVIDER, not a refused removal: the hook is a full converge, so a failure * does not mean "it failed to forget one thing" — the provider may have * re-rendered its state without the departing consumer AND without everything * else. ERROR is the honest label for a provider whose state is now unknown, * and `audit/undeployed-modules.ts` already turns it into a `blocked` finding. * Nothing refuses to USE an ERRORed module, so the provider keeps serving while * carrying the flag. */ export async function runConsumerCleanup( consumer: string, plan: CleanupTarget[], db: DbClient, logger: HookLogger, /** Injectable hook runner — tests drive the failure and paused paths through it. */ runHook: (providerId: string) => Promise = (providerId) => runNamedHook(providerId, 'on_consumer_removed', db, logger, { inputs: { consumer } }), ): Promise { const result: ConsumerCleanupResult = { notified: [], skipped: [], failures: [] }; for (const target of plan) { if (target.skip) { result.skipped.push({ providerId: target.providerId, reason: target.skip }); // Reported, not silent (D7/D8). Nothing was attempted, so nothing is // unknown and the provider is NOT marked ERROR. if (target.skip === 'paused') { logger.warn( `${target.providerId} is paused, so it was not told that '${consumer}' is gone — it is still holding whatever it minted for it (${target.capabilityNames.join(', ')}). Unpause and redeploy it to reconcile.`, ); } continue; } const hookResult = await runHook(target.providerId); // A module paused BETWEEN the plan and this dispatch. `runNamedHook` reports // that as success, so taking it at face value would log a withdrawal that // did not happen — which is the silence this whole change exists to end. // The window is small (one CLI process) and the consequence of trusting it // is not, so it is read rather than assumed. if (hookResult.skippedPaused) { result.skipped.push({ providerId: target.providerId, reason: 'paused' }); logger.warn( `${target.providerId} was paused while '${consumer}' was being removed, so it was not told — it is still holding whatever it minted for it (${target.capabilityNames.join(', ')}). Unpause and redeploy it to reconcile.`, ); continue; } if (hookResult.success) { // `notDefined` means the provider declares no such hook — it mints // nothing per consumer, and the dispatch succeeding is the right answer. if (!hookResult.notDefined) { result.notified.push(target.providerId); logger.info(`${target.providerId} withdrew what it held for '${consumer}'`); } continue; } const error = hookResult.error ?? 'unknown error'; result.failures.push({ providerId: target.providerId, error }); markProviderErrored(target.providerId, consumer, error, db); logger.warn( `${target.providerId} failed to withdraw what it held for '${consumer}' and is now marked ERROR: ${error}`, ); } // The rows, once every provider has converged without them (D4). // // Driven by the capability declarations rather than by name. This used to be // two hardcoded imports, `deletePortForwardsForModule` and // `deleteTrustedSourcesForModule`, which is the shape of the whole problem // openspec/changes/capability-owned-tables addresses: this file exists to // delete per-capability special-casing, it succeeded for the hook dispatch, // and then grew two capability-named calls for the rows because the rows had // no generic rule. Now they have one, and core no longer has to know that the // claim column is spelled `module_id` on one table, `registered_by` on two and // `consumer_module_id` on a fourth. deleteClaimedRows(db, consumer); return result; } /** * Mark the PROVIDER — not the module being removed — as ERROR, naming the * departing consumer and the raw hook error. * * The consumer is in `errorMessage` because the audit finding is read long * after the removal, by someone with no reason to connect the two. */ function markProviderErrored( providerId: string, consumer: string, error: string, db: DbClient, ): void { db.update(modules) .set({ state: 'ERROR', errorMessage: `Failed to withdraw state held for removed consumer '${consumer}': ${error}`, updatedAt: new Date(), }) .where(eq(modules.id, providerId)) .run(); }