import type { HookDefinition } from '@celilo/capabilities'; /** * Helper to load + invoke a single named hook on a module. * * Centralises the boilerplate that `module run-hook` and `module remove` * (for `on_uninstall`) and `module deploy` (for `on_install`) all share: * looking up the module + manifest, decrypting secrets, building the * config map, loading the capability table, and calling `invokeHook` * with the right logger. * * The helper is intentionally orchestration-only (Rule 10.1): it has no * UI / progress concerns of its own — callers wrap the returned * promise with FuelGauge or console output as fits the surface they're * exposing. */ import { eq } from 'drizzle-orm'; import type { DbClient } from '../db/client'; import { modules, secrets } from '../db/schema'; import type { ModuleManifest } from '../manifest/schema'; import { decryptSecret } from '../secrets/encryption'; import { getOrCreateMasterKey } from '../secrets/master-key'; import { getModuleSystems } from '../services/deployed-systems'; import { listDnsRegistrations, stampDnsRegistrationsRefreshed, } from '../services/dns-registrations'; import { remoteAccessPolicy } from '../services/remote-access'; import { loadCapabilityFunctions } from './capability-loader'; import { invokeHook } from './executor'; import { createHookStores } from './hook-store'; import { loadHookConfigMap } from './load-hook-config'; import type { HookLogger, HookName, HookResult } from './types'; export interface RunNamedHookOptions { /** Stream hook stdio to console rather than capture for a gauge. */ debug?: boolean; /** Hook inputs (key=value pairs from the CLI, etc.). Default: empty. */ inputs?: Record; /** * Total timeout for this invocation (ms). A bus delivery passes the * subscription's declared `timeout_ms` (celilo#622); omitted, the manifest * hook's own `timeout` or the executor default applies. */ timeoutMs?: number; /** * Use this hook definition instead of the manifest's own entry for the * name. Only `on_upstream_publish` needs it: its manifest value is an * ARRAY of match-rule entries and the build-bus dispatcher has already * chosen the matching one, so the definition it passes is the matched * entry (module-orchestrator-primitives slice 7). Every other hook keeps * reading the manifest, and `celilo module run-hook` — which has no * matched entry to supply — still gets `notDefined` for the array form. */ hookDefinition?: HookDefinition; } export interface RunNamedHookResult extends HookResult { /** * True when the module's manifest does not declare a hook with this * name. Distinguishes "the hook ran and returned success" from "no * hook to run." Callers like `module remove` use this to skip * gracefully when a module has no `on_uninstall` defined. */ notDefined?: boolean; /** * True when the hook was not run because the module is PAUSED. Reported as * success rather than failure: the module is deliberately quiesced, and a * failure here would be retried by the bus and then alerted on — paging the * operator about the pause they took themselves. */ skippedPaused?: boolean; } /** * Load the module's hook definition from its manifest and run it, * returning the executor's result. * * @param moduleId - DB id of the module to run the hook on. * @param hookName - Name of the hook (e.g. `on_install`, `on_uninstall`). * @param db - Database client. * @param logger - Hook logger; the caller chooses gauge vs. console. * @param options - Optional inputs / debug toggle. * @returns Hook result, with `notDefined: true` when the manifest has * no hook of that name. */ export async function runNamedHook( moduleId: string, hookName: HookName, db: DbClient, logger: HookLogger, options: RunNamedHookOptions = {}, ): Promise { const startedAt = Date.now(); const module = db.select().from(modules).where(eq(modules.id, moduleId)).get(); if (!module) { return { success: false, outputs: {}, error: `Module not found: ${moduleId}`, duration: Date.now() - startedAt, }; } // Quiescence for a paused module (openspec/changes/module-pause-lifecycle, // tasks 2.1/2.2). This is the chokepoint every non-lifecycle invocation // funnels through — bus dispatch, timer fan-out, aspect fan-out, // public-web republish, the dns-provider backfill, and `module run-hook` — // so guarding here covers the paths individually rather than each caller // remembering to. // // The exemptions are decided from the hook NAME, not a caller-supplied flag // (Rule 10.3): `on_install` is how unpause redeploys the module back to life, // and `on_uninstall` is how a paused module is removed — which is the entire // point of pausing it. Both must run while `state` is still PAUSED. const LIFECYCLE_HOOKS: readonly HookName[] = ['on_install', 'on_uninstall']; if (module.state === 'PAUSED' && !LIFECYCLE_HOOKS.includes(hookName)) { return { success: true, outputs: {}, duration: Date.now() - startedAt, skippedPaused: true, }; } const manifest = module.manifestData as ModuleManifest; // `on_upstream_publish` is under `manifest.hooks` as an ARRAY of // match-rule entries; the dispatcher supplies the matched entry via // `options.hookDefinition`. Without one (e.g. `celilo module run-hook`) // there is no single definition to run, so the hook reports notDefined // rather than guessing an entry. const hookDef = options.hookDefinition ?? manifest.hooks?.[hookName as keyof typeof manifest.hooks]; if (!hookDef || Array.isArray(hookDef)) { return { success: true, outputs: {}, duration: Date.now() - startedAt, notDefined: true, }; } const configMap = await loadHookConfigMap(moduleId, db); // Decrypt secrets. const secretRecords = db.select().from(secrets).where(eq(secrets.moduleId, moduleId)).all(); const masterKey = await getOrCreateMasterKey(); const secretMap: Record = {}; for (const s of secretRecords) { secretMap[s.name] = decryptSecret( { encryptedValue: s.encryptedValue, iv: s.iv, authTag: s.authTag }, masterKey, ); } // Capability pre-flight check: enforce manifest.requires for hooks // that mutate state (on_install, health_check, etc.); skip it for // teardown-style hooks where we want best-effort cleanup. The hook's // own `defineHook({ requires, optional })` declaration still governs // what the script actually accesses — this only controls the // framework-level pre-flight gate, which would otherwise refuse to // run an `on_uninstall` whenever a provider is "imported but not // deployed" (a state the test harness produces routinely, and that // operators producing failed half-removed modules also hit). const enforceRequires = hookName !== 'on_uninstall'; const requiredCapabilities = enforceRequires ? manifest.requires.capabilities.map((c) => c.name) : []; const capabilityFunctions = await loadCapabilityFunctions(moduleId, db, logger); // refresh_registrations is fed by the framework: module scripts can't // read the celilo DB, so the dns_registrations ledger rows for THIS // provider are injected as the contract's `registrations` input // (designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md B3). Authoritative — // overrides any caller-supplied value. let inputs = options.inputs ?? {}; if (hookName === 'refresh_registrations') { const registrations = listDnsRegistrations(db, { providerModuleId: moduleId }).map((r) => ({ fqdn: r.fqdn, })); inputs = { ...inputs, registrations }; } const result = await invokeHook( module.sourcePath, hookName, manifest.celilo_contract, hookDef, inputs, configMap, secretMap, logger, { debug: options.debug ?? false, capabilities: capabilityFunctions, requiredCapabilities, systems: getModuleSystems(moduleId, db), remoteAccess: remoteAccessPolicy(moduleId, db), hookStores: () => createHookStores(db, moduleId), timeoutMs: options.timeoutMs, }, ); // A fully successful refresh stamps the ledger so `celilo dns // registrations` shows when each provider last re-asserted. if (hookName === 'refresh_registrations' && result.success) { stampDnsRegistrationsRefreshed(db, moduleId); } return result; }