/** * Build the canonical config map a hook script sees. * * Every code path that runs a hook (deploy / health_check / on_uninstall / * `module run-hook`) needs to assemble the same config shape; without a * single source of truth the implementations drift and hooks see * subtly-different state depending on which command invoked them. The * concrete bug that motivated this helper: caddy's `on_uninstall` hook * skipped its DNAT cleanup because `runNamedHook` (a recently-extracted * helper) didn't apply the `target_ip` fallback that * `capability-loader.ts:loadModuleConfig` had — same shape, different * code, easy to miss. * * The shape, in the order the layers are applied: * 1. Every row from `module_configs`, parsed from `valueJson` via * the shared `parseStoredConfigValue` helper. This preserves the * types declared in each module's manifest: `number` reads as * `number`, `boolean` as `boolean`, complex types as their * parsed JSON shape. Pre-Defect-1, this path returned raw strings * for primitives (because `valueJson` was NULL for them); that * broke capability calls like `firewall.exposeService({ports:[...]})` * that did a `typeof === 'number'` check downstream. Fixed in * v2 by always populating valueJson on write. * 2. If `target_ip` isn't in the row set, look up the deployment * machine and inject both `target_ip` AND `ip.primary` from * `machines.ipAddress`. Two keys because consumers historically used * either name (e.g. caddy's `setup-network.ts` checks * `target_ip || ip.primary`); fixing the drift means filling both. * 3. For each variable the module's manifest declares, the value the * resolution context currently computes — but only where the first two * layers left that key unset. See below. * * Container deploys write `target_ip` into `module_configs` explicitly * during generate/deploy, so the fallback only fires for machine * deploys (existing iron, no terraform — what every e2e test uses). * * ## Why layer 3 exists * * A derived variable — one whose value comes from system config, a * capability, or the selected infrastructure rather than from the operator — * only reaches a hook through this map. Reading `module_configs` alone can * only see the derives that happen to have been WRITTEN there, and one that * was never written is indistinguishable from one that does not exist. * * On 2026-08-14 that was not a theoretical gap. `technitium`'s `vpn_subnet` * (`source: system`) had no row, because the system key was set after the * module was configured and nothing re-derives a `source: system` value that * is already absent. Being `required: false`, deploy-validation passed in * silence. The capability factory that builds split-horizon DNS therefore * built no view for the admin VPN; queries from it matched nothing, returned * NOERROR with zero records, fell through to public DNS, and the operator * could not reach the forge while every service reported healthy. * * So the map is completed from `readResolutionContext` — the same computation * a build does, run without any of a build's side effects. Stored rows still * win, which is what makes this purely additive for every value already on * the fleet; the context only supplies what the table is missing. Context * values arrive as strings (declarative derivation resolves string templates * only), so this layer never overwrites a typed row with a stringified one. * * It is narrowed to the manifest's declared variables on purpose. The * resolution context also carries values that exist to drive template * generation — `inventory.*`, `requires.system.*`, `lxc_nameserver` — which a * hook has no business reading and which have never appeared in this map. * Widening a hook's view of the world is not what this layer is for. */ import { eq } from 'drizzle-orm'; import type { DbClient } from '../db/client'; import { machines, moduleConfigs, moduleInfrastructure, modules } from '../db/schema'; import type { ModuleManifest } from '../manifest/schema'; import { parseStoredConfigValue } from '../services/module-config'; import { readResolutionContext } from '../variables/context'; export async function loadHookConfigMap( moduleId: string, db: DbClient, ): Promise> { const configRecords = db .select() .from(moduleConfigs) .where(eq(moduleConfigs.moduleId, moduleId)) .all(); const configMap: Record = {}; for (const c of configRecords) { configMap[c.key] = parseStoredConfigValue(c); } applyMachineAddressFallback(configMap, moduleId, db); await applyRecomputedDerivedValues(configMap, moduleId, db); return configMap; } /** * Fill in the declared variables the config rows have no value for, using the * value the resolution context computes right now. Mutates `configMap`. */ async function applyRecomputedDerivedValues( configMap: Record, moduleId: string, db: DbClient, ): Promise { const module = db.select().from(modules).where(eq(modules.id, moduleId)).get(); if (!module?.manifestData) return; const declared = (module.manifestData as ModuleManifest).variables?.owns ?? []; const missing = declared.filter((variable) => !(variable.name in configMap)); if (missing.length === 0) return; let context: Awaited>; try { context = await readResolutionContext(moduleId, db); } catch (error) { // Derivation THROWS when a `required: true` variable cannot be resolved — // correct at generate time, where a deploy that cannot resolve a required // value should stop. This reader also serves health checks and // `module run-hook`, which previously could not fail this way at all: a // stored row cannot throw. Letting it propagate would mean a provider that // is paused, removed, or not yet deployed takes down the health checks of // every module that derives from it, reporting healthy consumers as // broken. // // So the hook falls back to what the table holds — exactly what it // received before recomputation existed, never less. Logged rather than // swallowed (Rule 6.2): a derive that cannot resolve is worth knowing // about even when the hook survives it. console.error( `Could not recompute derived config for '${moduleId}'; the hook sees only its stored config. ` + `Derived values (${missing.map((v) => v.name).join(', ')}) may be missing:`, error, ); return; } for (const variable of missing) { const value = context.selfConfig[variable.name]; if (value !== undefined) { configMap[variable.name] = value; } } } /** * Fill `target_ip` / `ip.primary` from the deployment machine when the config * rows carry no address of their own. Mutates `configMap` in place. */ function applyMachineAddressFallback( configMap: Record, moduleId: string, db: DbClient, ): void { if (configMap.target_ip) return; const infra = db .select() .from(moduleInfrastructure) .where(eq(moduleInfrastructure.moduleId, moduleId)) .get(); if (!infra?.machineId) return; const machine = db.select().from(machines).where(eq(machines.id, infra.machineId)).get(); if (!machine) return; configMap.target_ip = machine.ipAddress; configMap['ip.primary'] = machine.ipAddress; }