/** * Shared template resolution for base-module aspect declarations. * * Both `proxmox_reconcile.tfvars` (SC5) and `ansible_vars` (SC6) * accept string templates with the same substitution rules celilo * uses for manifest variables — `$self:`, `$capability:`, * `$system:`. Resolution happens against the PROVIDING module's * context: the values come from the module that owns the aspect, * not the target system the aspect runs on. * * Factored out so the two callers stay consistent — a fix to the * substitution rules lands here once. */ import type { getDb } from '../db/client'; import { buildResolutionContext } from '../variables/context'; type DbClient = ReturnType; /** * Resolve a template string against `providerModuleId`'s context. * * `scopeLabel` is included in error messages so failures point at * the right manifest block (e.g., 'proxmox_reconcile.tfvars', * 'base_module_aspect.ansible_vars'). * * Throws when a substitution references a value that doesn't * exist — the caller is expected to surface this to the operator * since it's an aspect-author bug. */ export async function resolveAspectTemplate( template: string, providerModuleId: string, db: DbClient, scopeLabel: string, ): Promise { const ctx = await buildResolutionContext(providerModuleId, db); let result = template; result = result.replace(/\$\{?system:([a-zA-Z0-9_.]+)\}?/g, (_match, key) => { const value = ctx.systemConfig[key]; if (value === undefined) { throw new Error( `Cannot resolve $system:${key} in ${scopeLabel} (provider module: ${providerModuleId})`, ); } return value; }); result = result.replace(/\$\{?self:([a-zA-Z0-9_]+)\}?/g, (_match, key) => { const value = ctx.selfConfig[key]; if (value === undefined) { throw new Error( `Cannot resolve $self:${key} in ${scopeLabel} (provider module: ${providerModuleId})`, ); } return value; }); // $infra:. — the provider's deployed system, by name // (openspec/specs/module-systems-addressing/spec.md). This is how an aspect references the // provider's own host IP (e.g. knot's dns-client-config aspect sets every // fleet machine's nameserver to $infra:main.ipv4_address). Without this the // literal template string would leak into resolv.conf. result = result.replace( /\$\{?infra:([a-z0-9-]+)\.([a-zA-Z0-9_]+)\}?/g, (_match, sysName, field) => { const sys = ctx.systems?.[sysName] as unknown as Record | undefined; const value = sys?.[field]; if (value === undefined || value === '') { throw new Error( `Cannot resolve $infra:${sysName}.${field} in ${scopeLabel} (provider module: ${providerModuleId}) — no such deployed system or field`, ); } return String(value); }, ); result = result.replace( /\$capability:([a-zA-Z0-9_]+)\.([a-zA-Z0-9_.]+)/g, (_match, capName, path) => { const capData = ctx.capabilities[capName]; if (!capData) { throw new Error( `Cannot resolve $capability:${capName}.${path} — capability not registered (provider module: ${providerModuleId}, in ${scopeLabel})`, ); } const parts = path.split('.'); let cur: unknown = capData; for (const p of parts) { if (cur && typeof cur === 'object' && p in (cur as Record)) { cur = (cur as Record)[p]; } else { throw new Error( `Cannot resolve $capability:${capName}.${path} — field missing on capability data (provider module: ${providerModuleId}, in ${scopeLabel})`, ); } } return String(cur); }, ); return result; } /** * Convenience: resolve a record-of-templates, returning a record * of resolved strings. The keys pass through unchanged. */ export async function resolveAspectTemplateRecord( templates: Record, providerModuleId: string, db: DbClient, scopeLabel: string, ): Promise> { const resolved: Record = {}; for (const [name, template] of Object.entries(templates)) { resolved[name] = await resolveAspectTemplate(template, providerModuleId, db, scopeLabel); } return resolved; }