import { eq } from 'drizzle-orm'; import { getWellKnownCapability, isWellKnown } from '../capabilities/well-known'; import { getDb } from '../db/client'; import type { DbClient } from '../db/client'; import { capabilities, containerServices, isAllocatableZone, machines, moduleConfigs, moduleInfrastructure, moduleSystems, modules, secrets, systemConfig, systemSecrets, } from '../db/schema'; import { allocateResources, getAllocation } from '../ipam/allocator'; import { type ModuleManifest, getDeclaredSystems, getSingularSystemSpec } from '../manifest/schema'; import { decryptSecret } from '../secrets/encryption'; import { getOrCreateMasterKey } from '../secrets/master-key'; import { resolveMachineIdentityAddress } from '../services/deployed-systems'; import { upsertModuleConfig } from '../services/module-config'; import { resolveComputedFields } from './computed/evaluate'; import { containsComputedMarker } from './computed/marker'; import { buildProviderLookup } from './computed/provider-lookup'; import { applyDeclarativeDerivations } from './declarative-derivation'; import { applyIndex, parsePath } from './parser'; import type { ResolutionContext } from './types'; /** * Strip CIDR notation from IP address * Policy function - pure string manipulation * * @param ipWithCidr - IP address with optional CIDR (e.g., "10.0.10.10/24") * @returns IP address without CIDR (e.g., "10.0.10.10") */ function stripCidr(ipWithCidr: string): string { const slashIndex = ipWithCidr.indexOf('/'); if (slashIndex === -1) { return ipWithCidr; } return ipWithCidr.slice(0, slashIndex); } /** * Auto-assign hostname and zone from well-known capabilities * Policy function - derives values from capability registry * * Zero-config systems * If module provides a well-known capability, auto-assign: * - hostname from canonical_hostname * - zone from required_zone * * Only assigns if not already set (explicit config wins) * * @param manifest - Module manifest * @param selfConfig - Current module configuration * @param db - Database client for hostname conflict detection * @returns Object with assigned hostname and zone (if any) * @throws Error if hostname conflict or zone enforcement fails */ async function autoAssignFromWellKnown( manifest: ModuleManifest, selfConfig: Record, _moduleId: string, _db: DbClient, ): Promise<{ hostname?: string; zone?: string }> { const providedCapabilities = manifest.provides?.capabilities ?? []; const result: { hostname?: string; zone?: string } = {}; // Find first well-known capability (priority order) // Note: Capability uniqueness and zone enforcement are validated at import time for (const capability of providedCapabilities) { if (!isWellKnown(capability.name)) { continue; } const wellKnown = getWellKnownCapability(capability.name); // Determine current zone (from config or manifest) const currentZone = selfConfig.zone || getSingularSystemSpec(manifest)?.zone; // Auto-assign hostname if not already set if (!selfConfig.hostname) { result.hostname = wellKnown.canonical_hostname; } // Auto-assign zone if not already set (in config or manifest) if (!currentZone) { result.zone = wellKnown.required_zone; } // Only process first well-known capability (deterministic) break; } return result; } /** * Auto-derive inventory variables from module configuration * Policy function - derives values from existing config * * These variables are automatically available in Ansible templates: * - inventory.hostname: Derived from hostname variable * - inventory.ansible_host: Derived from target_ip (strips CIDR) or vps_ip * - inventory.ansible_user: Defaults to "root" * - inventory.groups: Derived from module ID * * @param moduleId - Module ID * @param selfConfig - Module configuration * @returns Additional derived variables to merge into selfConfig */ function autoDeriveInventoryVariables( moduleId: string, selfConfig: Record, ): Record { const derived: Record = {}; // Auto-derive hostname from hostname variable if (selfConfig.hostname) { derived['inventory.hostname'] = selfConfig.hostname; } // Auto-derive ansible_host from target_ip (strips CIDR) or vps_ip if (selfConfig.target_ip) { derived['inventory.ansible_host'] = stripCidr(selfConfig.target_ip); } else if (selfConfig.vps_ip) { // VPS-based modules use vps_ip directly (no CIDR to strip) derived['inventory.ansible_host'] = selfConfig.vps_ip; } // Auto-derive ansible_user (default: root) // Can be overridden by module config if (!selfConfig['inventory.ansible_user']) { derived['inventory.ansible_user'] = 'root'; } // Auto-derive groups from module ID // Format: module ID becomes the primary group derived['inventory.groups'] = moduleId; return derived; } /** * Recursively resolve "$self:key" and "$infra:." strings in a * capability data object using the provider module's actual config values and * its deployed systems. `$self:` supports the optional `[N]` array-index suffix * (e.g. `$self:domains[0]`). `$infra:` references a provider's deployed system * by name (the replacement for the old `$self:target_ip` — see * openspec/specs/module-systems-addressing/spec.md). Non-string values and strings that don't * start with one of those prefixes are returned unchanged. */ function resolveSelfRefsInObject( obj: Record, providerConfig: Record, providerSystems: Record = {}, ): Record { const result: Record = {}; for (const [key, value] of Object.entries(obj)) { if (typeof value === 'string' && value.startsWith('$self:')) { const { name, index } = parsePath(value.slice(6)); const resolved = applyIndex(providerConfig[name], index); result[key] = resolved !== undefined ? resolved : value; } else if (typeof value === 'string' && value.startsWith('$infra:')) { const path = value.slice('$infra:'.length); const dot = path.indexOf('.'); const sysName = dot === -1 ? path : path.slice(0, dot); const field = dot === -1 ? '' : path.slice(dot + 1); const sys = providerSystems[sysName] as unknown as Record | undefined; const resolved = sys ? sys[field] : undefined; result[key] = resolved !== undefined && resolved !== '' ? resolved : value; } else if (value !== null && typeof value === 'object' && !Array.isArray(value)) { result[key] = resolveSelfRefsInObject( value as Record, providerConfig, providerSystems, ); } else { result[key] = value; } } return result; } /** * Build the resolution context for a module, provisioning as it goes. * * Execution function (Rule 10.1) - performs database queries AND writes: * it seeds `module_configs` with defaults and zone-derived networking, and * records the module's deployed system (allocating IPAM addresses when the * host is a celilo-provisioned container). This is the generate/deploy-time * entrypoint. Anything that only wants to READ the resolved configuration * wants {@link readResolutionContext} instead. * * @param moduleId - Module to build context for * @param db - Database client (optional, for testing) * @returns Resolution context with all data sources */ export async function buildResolutionContext( moduleId: string, db = getDb(), ): Promise { return assembleResolutionContext(moduleId, db, { provision: true }); } /** * The same resolved configuration, computed without touching the database. * * Every derived value is recomputed from its current upstream, exactly as a * build would compute it, but nothing is written: no config rows are seeded, * no addresses are allocated, no deployed system is recorded. That makes it * safe to call from read paths that run on a cadence — health checks, hook * invocations, capability factories — where a provisioning side effect would * be both surprising and, in the IPAM case, harmful. * * This is what {@link import('../hooks/load-hook-config').loadHookConfigMap} * uses so a hook sees the value a derive currently produces rather than only * the ones that happen to have been stored. * * @param moduleId - Module to build context for * @param db - Database client (optional, for testing) * @returns Resolution context with all data sources */ export async function readResolutionContext( moduleId: string, db = getDb(), ): Promise { return assembleResolutionContext(moduleId, db, { provision: false }); } /** * The shared body of both entrypoints. `provision` is deliberately private * (Rule 10.3): callers choose a named function, not a flag. */ async function assembleResolutionContext( moduleId: string, db: DbClient, { provision }: { provision: boolean }, ): Promise { /** * Seed a config row — a no-op when only reading. Every seeded value is also * assigned into `selfConfig` by the caller, so the resolved context is the * same either way; what differs is whether it is written down. */ const persistConfig = ( key: string, value: string | number | boolean | unknown[] | Record, ): void => { if (provision) upsertModuleConfig(db, moduleId, key, value); }; // Fetch module manifest for VM resources const module = db.select().from(modules).where(eq(modules.id, moduleId)).get(); // Fetch module configuration (self) const configRows = db .select() .from(moduleConfigs) .where(eq(moduleConfigs.moduleId, moduleId)) .all(); const selfConfig: Record = {}; for (const row of configRows) { // Template variables substitute as strings (the YAML/HCL files // this map drives expect string replacements). `value` is already // the human-readable string form populated alongside valueJson; // safe to use directly here. The typed path (numbers, booleans // as their JS types) is parseStoredConfigValue, used by hook // contexts. selfConfig[row.key] = row.value; } // Well-known capability auto-assignment // Auto-assign hostname and zone if module provides well-known capability if (module?.manifestData) { const manifest = module.manifestData as ModuleManifest; const assigned = await autoAssignFromWellKnown(manifest, selfConfig, moduleId, db); // Store assigned values in module config if (assigned.hostname) { persistConfig('hostname', assigned.hostname); selfConfig.hostname = assigned.hostname; } if (assigned.zone) { persistConfig('zone', assigned.zone); selfConfig.zone = assigned.zone; } } // Variable defaults // Auto-apply default values from variable declarations if not already configured if (module?.manifestData) { const manifest = module.manifestData as ModuleManifest; const variables = manifest.variables?.owns ?? []; for (const variable of variables) { // Only apply if variable has a default and config doesn't already have it. // Pass the typed manifest default directly so valueJson preserves the // declared shape — e.g. `default: 2222` (YAML int) round-trips as // `number` not the string "2222". This is the root of Defect 1. if (variable.default !== undefined && !selfConfig[variable.name]) { persistConfig( variable.name, variable.default as string | number | boolean | unknown[] | Record, ); selfConfig[variable.name] = String(variable.default); } } } // VM resource defaults // Auto-apply VM resource defaults from manifest if not already configured if (module?.manifestData) { const manifest = module.manifestData as ModuleManifest; const systemResources = getSingularSystemSpec(manifest); if (systemResources) { // The DEPLOYED size is the SYSTEM's canonical state (ISS-0150), seeded from // requires.system at first provision and thereafter owned by // `celilo proxmox … resize`. So sizing flows: module_systems → these config // vars → `$self:{cores,memory,disk}` in the instance Terraform. // // Precedence: the recorded system size WINS and overwrites the cached // config (a resize must propagate on the next generate); only when this // module has no recorded system size yet (the very first provision, before // recordDeployedSystemForModule runs below) do we fall back to // requires.system — and seed-when-unset, matching the prior behavior so the // first-deploy / golden output is unchanged. `requires.system` stays the // minimum floor, never the canonical size. (CLAUDE.md / ISS-0150.) const sizedRow = db .select({ cpu: moduleSystems.cpu, memory: moduleSystems.memory, disk: moduleSystems.disk, }) .from(moduleSystems) .where(eq(moduleSystems.moduleId, moduleId)) .all() .find((r) => r.cpu != null || r.memory != null || r.disk != null); const resourceMappings: Array<{ manifestKey: keyof typeof systemResources; configKey: string; systemValue: number | null | undefined; }> = [ { manifestKey: 'cpu', configKey: 'cores', systemValue: sizedRow?.cpu }, // requires.system.cpu → cores { manifestKey: 'memory', configKey: 'memory', systemValue: sizedRow?.memory }, { manifestKey: 'disk', configKey: 'disk', systemValue: sizedRow?.disk }, { manifestKey: 'storage', configKey: 'storage', systemValue: undefined }, // pool name, not sizing ]; for (const { manifestKey, configKey, systemValue } of resourceMappings) { if (systemValue != null) { // Canonical system size — always wins so a resize propagates. persistConfig(configKey, systemValue); selfConfig[configKey] = String(systemValue); continue; } const value = systemResources[manifestKey]; // Manifest fields are typed (cpu: number, storage: string, etc.). // Pass them through unstringified so valueJson preserves the // shape — see comment in the variable-defaults block above. if (value !== undefined && !selfConfig[configKey]) { persistConfig( configKey, value as string | number | boolean | unknown[] | Record, ); selfConfig[configKey] = String(value); } } } } // Deployed-system recording (openspec/specs/module-systems-addressing/spec.md). // Record this module's system(s) into module_systems at generate time, so // `$infra:.…` resolves in templates. The address comes from: // - machine pool → the machine's own IP; no vmid (no container). // - container_service → IPAM-allocated zone IP + vmid (proxmox only). // - DigitalOcean → assigned by terraform; recorded at deploy from // outputs (resolveInfrastructureVariables), not here. // This is the single place generate-time addresses are recorded — `target_ip` // no longer lives in module_configs. // // Provisioning only. A read must never reach this: allocating an address is // not something looking at a config should do, and by the time any hook runs // the row is already there for `buildInfraSystemsMap` below to read. if (provision && module?.manifestData) { const manifest = module.manifestData as ModuleManifest; const declared = getDeclaredSystems(manifest); const hostname = selfConfig.hostname; if (declared.length > 0 && hostname) { // Single-system transition: every current module declares one system. const decl = declared[0]; const zone = decl.resources.zone; const infraSelection = db .select() .from(moduleInfrastructure) .where(eq(moduleInfrastructure.moduleId, moduleId)) .get(); const { upsertDeployedSystem } = await import('../services/deployed-systems'); if (infraSelection?.infrastructureType === 'machine') { if (!infraSelection.machineId) { throw new Error( `Module '${moduleId}' is assigned to a machine but module_infrastructure.machineId is null`, ); } const machineRow = db .select() .from(machines) .where(eq(machines.id, infraSelection.machineId)) .get(); if (!machineRow?.ipAddress) { throw new Error( `Machine '${infraSelection.machineId}' for module '${moduleId}' has no ipAddress`, ); } const deployedZone = machineRow.zone ?? zone; const machineIdentity = resolveMachineIdentityAddress(machineRow, deployedZone); if (!machineIdentity) { throw new Error( `Machine '${infraSelection.machineId}' for module '${moduleId}' has no non-loopback interface in zone '${deployedZone}'`, ); } upsertDeployedSystem(db, moduleId, { name: decl.name, hostname, ipv4Address: machineIdentity, // The machine's own zone, matching recordDeployedSystemForModule and // backfillModuleSystems. This is the THIRD place that decides a // deployed system's zone, and it ran last — so while the other two // took the machine's zone, this one kept overwriting it with the // manifest's, and the control plane stayed recorded as `internal`. // `requires.system.zone` is the minimum used to SELECT a host; the // machine we selected is where the system actually is. zone: deployedZone, infraType: 'machine', machineId: infraSelection.machineId, }); } else if (infraSelection?.infrastructureType === 'container_service') { // Only proxmox needs IPAM; DigitalOcean's IP comes from terraform // outputs at deploy. Determine the provider from the selected service. let isProxmox = false; if (infraSelection.serviceId) { const svc = db .select() .from(containerServices) .where(eq(containerServices.id, infraSelection.serviceId)) .get(); isProxmox = svc?.providerName === 'proxmox'; } // `isAllocatableZone`, not `zone !== 'external'`: that check was written // when `external` was the only zone celilo does not address, and it // silently became wrong the moment `vpn` joined it — a VPN client subnet // is assigned by the tunnel module, so allocating into it would collide. if (isProxmox && isAllocatableZone(zone)) { await db.transaction(async (tx) => { const existing = await getAllocation(moduleId, tx); const allocation = existing ?? (await allocateResources(moduleId, zone, tx)); upsertDeployedSystem(tx as unknown as DbClient, moduleId, { name: decl.name, hostname, ipv4Address: allocation.containerIp, zone, infraType: 'container_service', serviceId: infraSelection.serviceId, vmid: allocation.vmid, }); }); } } } } // Add system requirements from manifest to selfConfig so templates can // reference them via $self:requires.system.. if (module?.manifestData) { const systemSpec = getSingularSystemSpec(module.manifestData as ModuleManifest); if (systemSpec) { for (const [key, value] of Object.entries(systemSpec)) { selfConfig[`requires.system.${key}`] = String(value); } } } // Fetch secrets (encrypted values should be decrypted by caller) const secretRows = db.select().from(secrets).where(eq(secrets.moduleId, moduleId)).all(); const secretsMap: Record = {}; for (const row of secretRows) { secretsMap[row.name] = row.encryptedValue; } // Fetch all capabilities (for capability variables) const capabilityRows = db.select().from(capabilities).all(); const capabilitiesMap: Record> = {}; for (const row of capabilityRows) { // Parse JSON data if it's a string let data: unknown; if (typeof row.data === 'string') { try { data = JSON.parse(row.data); } catch (error) { throw new Error( `Failed to parse capability data for ${row.capabilityName}: ${error instanceof Error ? error.message : 'Invalid JSON'}`, ); } } else { data = row.data; } // Lazily resolve $self: references in capability data using the provider // module's actual config values. Capability data is stored with unresolved // $self: references (e.g. namecheap stores primary_domain: "$self:primary_domain") // because the real value isn't known at import time. We resolve them here so // consuming modules see the actual values (e.g. "iamtheinternet.org") rather // than the raw template strings. const providerConfigs = db .select() .from(moduleConfigs) .where(eq(moduleConfigs.moduleId, row.moduleId)) .all(); const providerConfigMap: Record = {}; for (const c of providerConfigs) { providerConfigMap[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value; } // The provider's deployed systems, so capability data can reference // `$infra:.ipv4_address` (replacing the old `$self:target_ip`). // CIDR uses the default prefix here (capability data reads bare ipv4/host, // not cidr); the full systemConfig isn't built yet at this point. const { buildInfraSystemsMap } = await import('../services/deployed-systems'); const providerSystems = buildInfraSystemsMap(row.moduleId, db, {}); let resolved = resolveSelfRefsInObject( data as Record, providerConfigMap, providerSystems, ); // Evaluate computed-field markers (openspec/specs/internal-dns-split-horizon/spec.md // D1) in the PROVIDER's context, so consumers — both `$capability:` template // refs AND `variables.imports` — see the real value (e.g. domain_list as an // array) rather than the raw marker. Only build the provider lookup when a // marker is actually present (the common case has none). Best-effort: a // single provider's eval failure must not break an unrelated module's // context build, so we log and leave the original data on error. if (containsComputedMarker(resolved)) { try { const lookup = await buildProviderLookup(row.moduleId, db); resolved = resolveComputedFields(resolved, lookup) as Record; } catch (err) { console.error( `Failed to evaluate computed fields for capability '${row.capabilityName}' (provider '${row.moduleId}'):`, err, ); } } capabilitiesMap[row.capabilityName] = resolved; } // Fetch system configuration (for $system: variables) const systemConfigRows = db.select().from(systemConfig).all(); const systemConfigMap: Record = {}; for (const row of systemConfigRows) { systemConfigMap[row.key] = row.value; } // LXC nameserver list (openspec/specs/lxc-dns-at-birth/spec.md). Proxmox's `nameserver` is a // space-separated primary/secondary list. Compose it so every celilo-built // LXC boots with a working resolver — before Ansible's apt update, and // independent of which Proxmox node it lands on (the nodes' DNS defaults // diverge): the internal dns_internal resolver first (it recurses, so it // also answers external names), then the configured fleet resolvers as // fallback. // Bootstrap (no dns_internal provider registered yet — e.g. the DNS module's // own LXC) → public only, so apt still works. Injected as // $self:lxc_nameserver, mirroring how inventory.* are auto-derived; the // proxmox_lxc terraform template drops it straight into `nameserver`. { // Named for what the keys hold, not for what a reader hopes they hold: // `dns.primary` / `dns.fallback` mean "the resolvers systems should use", // and an operator pointing the fleet at its own resolver (a deployed // dns_internal provider's address) is established, deliberate practice // (celilo#1239). These entries may therefore be private. The guard against // a resolver celilo installed ending up here lives where the keys are // WRITTEN from host state — discovery refuses a deployed provider's // address (dns-discovery.ts) — not in a privateness check here, which // would break that practice while fixing nothing: in an uncovered zone // dropping the entries takes DNS away rather than tightening anything. const configuredDns: string[] = []; for (const key of ['dns.primary', 'dns.fallback']) { const raw = systemConfigMap[key]; if (raw) { for (const part of raw.split(',')) { const ip = part.trim(); if (ip) configuredDns.push(ip); } } } type ResolverEndpoint = { server?: { ip?: unknown; internal_ip?: unknown }; /** Zones the provider's base-module aspect owns ongoing DNS for (D5d). */ aspect?: { covered_zones?: unknown }; }; const dnsInternal = capabilitiesMap.dns_internal as ResolverEndpoint | undefined; const dnsSecondary = capabilitiesMap.dns_internal_secondary as ResolverEndpoint | undefined; // A nameserver must be a bare IP. The advertised address may carry a CIDR // suffix (technitium's server.ip resolves from target_ip, e.g. // "192.168.0.151/24"), so strip it. const bareIp = (value: unknown): string | undefined => typeof value === 'string' && value.length > 0 ? value.split('/')[0] : undefined; const targetZone = module?.manifestData ? getSingularSystemSpec(module.manifestData as ModuleManifest)?.zone : undefined; // `internal` cannot route into the resolvers' own (dmz) subnet, so it uses // the ingress addresses the firewall DNATs. Every other zone reaches them // directly. const endpointFor = (resolver: ResolverEndpoint | undefined): string | undefined => targetZone === 'internal' ? (bareIp(resolver?.server?.internal_ip) ?? bareIp(resolver?.server?.ip)) : bareIp(resolver?.server?.ip); const primaryIp = endpointFor(dnsInternal); const secondaryIp = endpointFor(dnsSecondary); // Whether the DEPLOYED provider's base-module aspect covers this system's // zone, which is what decides if the public entries may go (design D5d). // // The predicate is aspect COVERAGE, not routing. Routing is why a zone goes // uncovered — `external` and the planned `quarantine` hold systems that // cannot reach a resolver inside the perimeter. Coverage is why the birth // list is PERMANENT: terraform injects // `lifecycle { ignore_changes = [nameserver] }`, so it cannot correct the // value afterwards even in principle, and a zone no aspect covers has no // owner for ongoing DNS at all. Keying on coverage therefore handles a // future excluded zone with no change here, and fails safe on an accidental // omission — where keeping the public entries is the lesser harm until the // array is fixed. // The provider DECLARES its coverage in the capability data. Core could // instead find the provider's module row and read // `base_module_aspect.applicable_zones` off its manifest, which would be // one source of truth rather than two — but that means core naming a // capability in order to find its provider, which is the pattern the // module-business gate exists to stop, and its advice is exactly this: let // the provider declare the behaviour. A manifest test holds the declared // list and the aspect's own `applicable_zones` together so they cannot // drift. const aspectZones = dnsInternal?.aspect?.covered_zones; const zoneIsAspectCovered = targetZone !== undefined && Array.isArray(aspectZones) && aspectZones.includes(targetZone); // Both halves are required. A pair with no aspect covering this zone is the // `external` case: two addresses it cannot route to and nothing else, which // does not tighten anything, it takes DNS away. A primary with no secondary // is D5a: removing the fallback and shipping a secondary are ONE decision, // because otherwise every resolver redeploy blanks fleet DNS. const dropPublicResolvers = primaryIp !== undefined && secondaryIp !== undefined && zoneIsAspectCovered; // The uncovered branch is main's composition unchanged: the primary, then // the public resolvers. The secondary is deliberately NOT added to it — in // a zone that cannot route to the resolvers, a second unreachable address // buys nothing but another timeout before the public entries answer. const nameservers = dropPublicResolvers ? [primaryIp, secondaryIp] : primaryIp ? [primaryIp, ...configuredDns] : configuredDns; const unique = [...new Set(nameservers)]; if (unique.length > 0) { selfConfig.lxc_nameserver = unique.join(' '); } } // Fetch system secrets (for $system_secret: variables) const systemSecretsMap: Record = {}; try { const systemSecretRows = db.select().from(systemSecrets).all(); if (systemSecretRows.length > 0) { // Get master key for decryption const masterKey = await getOrCreateMasterKey(); for (const row of systemSecretRows) { try { const decrypted = decryptSecret( { encryptedValue: row.encryptedValue, iv: row.iv, authTag: row.authTag, }, masterKey, ); systemSecretsMap[row.key] = decrypted; } catch (err) { // Log error but continue with other secrets console.error(`Failed to decrypt system secret ${row.key}:`, err); } } } } catch (_err) { // Table might not exist in test/old databases - that's okay // System secrets are optional } // Build the `$infra:.` lookup from the systems recorded above // (proxmox/machine at generate; DigitalOcean gets refreshed at deploy from // terraform outputs). This is what lets `$infra:` resolve in templates. // openspec/specs/module-systems-addressing/spec.md. let infraSystemsMap: Record = {}; if (module?.manifestData) { const { buildInfraSystemsMap } = await import('../services/deployed-systems'); infraSystemsMap = buildInfraSystemsMap(moduleId, db, systemConfigMap); } // Zone-based networking // Auto-derive network config from zone (gateway, vlan, subnet, bridge) if (module?.manifestData) { const manifest = module.manifestData as ModuleManifest; const zone = selfConfig.zone || getSingularSystemSpec(manifest)?.zone; // If zone from manifest but not in selfConfig, store it as first-class config if (zone && !selfConfig.zone) { persistConfig('zone', zone); selfConfig.zone = zone; } // Only apply for container-based zones (not external/VPS) if (zone && zone !== 'external') { const networkFields = ['gateway', 'vlan', 'subnet', 'bridge']; for (const field of networkFields) { // Only apply if not already configured by user if (!selfConfig[field]) { const systemConfigKey = `network.${zone}.${field}`; const value = systemConfigMap[systemConfigKey]; if (value) { // system_config stores everything as strings (no // valueJson companion column there — that's a sister // type-fidelity gap, tracked separately). Use a // try-JSON-parse heuristic at the boundary to recover // primitive types: "20" → 20 (vlan tags are numbers), // "true" → true, IPs/hostnames fall through to string. // Once system_config gets the same treatment as // module_configs this coercion becomes redundant. const coerced = ((): string | number | boolean => { try { const parsed = JSON.parse(value); if (typeof parsed === 'number' || typeof parsed === 'boolean') return parsed; } catch { // not JSON — fall through } return value; })(); persistConfig(field, coerced); selfConfig[field] = String(coerced); } } } } } // Declarative variable derivation // Apply template-based derivations from manifest if (module?.manifestData) { const manifest = module.manifestData as ModuleManifest; const context: ResolutionContext = { moduleId, selfConfig, systemConfig: systemConfigMap, systemSecrets: systemSecretsMap, secrets: secretsMap, capabilities: capabilitiesMap, systems: infraSystemsMap, }; // Snapshot values before derivation so we can detect changes const preDeriveValues: Record = { ...selfConfig }; // Apply declarative derivations from manifest applyDeclarativeDerivations(manifest, context); // Persist derived values to database // For capability/infrastructure-sourced variables, always update since // the upstream value may have changed. const rederivableSources = new Set(['capability', 'infrastructure']); const declaredVars = manifest.variables?.owns ?? []; for (const [key, value] of Object.entries(context.selfConfig)) { const decl = declaredVars.find((v) => v.name === key); const isNew = preDeriveValues[key] === undefined; const isChanged = decl?.source && rederivableSources.has(decl.source) && preDeriveValues[key] !== value; // Don't persist values that still contain unresolved template // variables ($self:, $system:, $capability:). This happens when // capability data contains template references that can't be // fully resolved in the consuming module's context — e.g., // $capability:dns_registrar.primary_domain resolves to // namecheap's capability data "$self:primary_domain", but // $self: in that context refers to namecheap, not the consumer. // Persisting the raw template would poison the config with an // unresolvable string. Keep the user-set value (if any) instead. if ( typeof value === 'string' && (value.includes('$self:') || value.includes('$system:') || value.includes('$capability:') || value.includes('$secret:')) ) { continue; } if (isNew || isChanged) { persistConfig(key, value); selfConfig[key] = value; } } } // Auto-derive inventory variables const derivedVars = autoDeriveInventoryVariables(moduleId, selfConfig); // Merge derived variables into selfConfig // Explicit config takes precedence over derived values const finalSelfConfig = { ...derivedVars, ...selfConfig }; return { moduleId, selfConfig: finalSelfConfig, systemConfig: systemConfigMap, systemSecrets: systemSecretsMap, secrets: secretsMap, capabilities: capabilitiesMap, systems: infraSystemsMap, }; } /** * Build resolution context from explicit data (for testing) * * Policy function - no database access * * @param moduleId - Module ID * @param data - Explicit data sources * @returns Resolution context */ export function buildContextFromData( moduleId: string, data: { selfConfig?: Record; systemConfig?: Record; systemSecrets?: Record; secrets?: Record; capabilities?: Record>; systems?: Record; } = {}, ): ResolutionContext { const selfConfig = data.selfConfig ?? {}; // Auto-derive inventory variables (same as buildResolutionContext) const derivedVars = autoDeriveInventoryVariables(moduleId, selfConfig); // Merge derived variables into selfConfig // Explicit config takes precedence over derived values const finalSelfConfig = { ...derivedVars, ...selfConfig }; return { moduleId, selfConfig: finalSelfConfig, systemConfig: data.systemConfig ?? {}, systemSecrets: data.systemSecrets ?? {}, secrets: data.secrets ?? {}, capabilities: data.capabilities ?? {}, systems: data.systems ?? {}, }; }