/** * The side-effecting half of `celilo_module_deploy_worker` * (openspec/changes/submodules, D3, D6, D8). * * `createModuleDeployWorker` in `@celilo/capabilities` owns the refusals a * caller can reason about without a database: does this module declare that * submodule, is the key usable. This owns everything that touches celilo: * validating config against the submodule's declared variables, refusing a * network nobody declared, deriving the instance's `modules.id`, writing the * rows, and building its symlink farm. * * Split that way so the capability's contract is testable with no database at * all (Rule 2.3), and so every refusal here happens BEFORE anything is * provisioned. An instance whose config is wrong is refused at the request, not * discovered at deploy time when there is no operator to tell. * * ── What this does NOT do ── * * It records intent. It does not provision. `instantiate` returns as soon as * the rows and the farm exist, because provisioning takes minutes and the * caller is a hook on a timer (D6). What picks a `pending` instance up and * deploys it is a separate decision and is not made here. */ import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { type InstanceOps, InstanceRequestRefusedError, type InstantiateRequest, type ModuleInstance, } from '@celilo/capabilities'; import { eq } from 'drizzle-orm'; import type { DbClient } from '../db/client'; import { moduleInstances, modules } from '../db/schema'; import type { ModuleManifest } from '../manifest/schema'; import { SUBMODULES_DIR, validateManifest } from '../manifest/validate'; import { writeModuleConfigKey } from './config-interview'; import { buildInstanceLinkFarm, deriveInstanceModuleId, submoduleSourcePath, } from './module-instances'; /** * Config keys a caller may not set, because celilo derives them. * * A variable whose `source` is anything but `user` is celilo's answer, not the * parent's: `infrastructure` comes from the selected host, `capability` from a * provider, `system` from system config, `terraform` from an output. Letting a * caller supply one would have it silently overwritten at generate time, which * looks like celilo ignoring the request. */ function callerSettableVariables(manifest: ModuleManifest): Map { const settable = new Map(); for (const variable of manifest.variables?.owns ?? []) { if (variable.source !== 'user') continue; settable.set(variable.name, { required: variable.required && variable.default === undefined }); } return settable; } /** * Validate an instance's config against the submodule's declared variables. * * Pure, so the rules are testable without a filesystem or a database. Reports * EVERY problem rather than the first: a caller fixing one key at a time across * four round trips, each costing a failed instantiation, is the experience this * avoids. */ export function validateInstanceConfig( submoduleManifest: ModuleManifest, config: Record, ): string[] { const settable = callerSettableVariables(submoduleManifest); const declaredNames = new Set((submoduleManifest.variables?.owns ?? []).map((v) => v.name)); const problems: string[] = []; for (const key of Object.keys(config)) { if (settable.has(key)) continue; problems.push( declaredNames.has(key) ? `'${key}' is not a caller-supplied value — celilo derives it. Remove it from the instance config.` : `'${key}' is not declared by submodule '${submoduleManifest.id}'. It declares: ${[...settable.keys()].join(', ') || '(nothing settable)'}.`, ); } for (const [name, { required }] of settable) { if (required && config[name] === undefined) { problems.push( `'${name}' is required by submodule '${submoduleManifest.id}' and was not supplied.`, ); } } return problems; } /** * Refuse a submodule that insists on a network celilo has not been told about * (D8), at the REQUEST rather than at provision time. * * celilo owns the network namespace: a module may insist a network exists and * read its value, and may never write one. So an instantiation cannot create * the missing network, and discovering it three minutes into a terraform apply * gives an operator a failure that names an address instead of a name. * * Pure over the declared set, because "which networks exist" is a system-config * question the caller resolves. */ export function refuseUndeclaredNetworks( submoduleManifest: ModuleManifest, declaredNetworks: readonly string[], ): string | null { const insisted = (submoduleManifest.requires?.networks ?? []) .map((requirement) => requirement.name) .filter((name): name is string => typeof name === 'string'); const missing = insisted.filter((name) => !declaredNetworks.includes(name)); if (missing.length === 0) return null; return `Submodule '${submoduleManifest.id}' requires network(s) ${missing.join(', ')}, which celilo has no definition for. Declare them first — a module never creates a network, and an instance cannot either.`; } export interface InstanceOpsContext { /** The calling module, whose authority the capability has already checked. */ parentId: string; db: DbClient; /** Networks celilo has definitions for. Supplied so this stays testable. */ declaredNetworks: readonly string[]; } /** * Read and validate a submodule's own manifest from its parent's install. * * Re-read at request time rather than trusted from import. A parent updated * since it was imported ships a different submodule, and the instance about to * be created runs the version ON DISK. */ async function loadSubmoduleManifest( parentSourcePath: string, submodule: string, ): Promise { const manifestPath = join(submoduleSourcePath(parentSourcePath, submodule), 'manifest.yml'); if (!existsSync(manifestPath)) { throw new InstanceRequestRefusedError( `Submodule '${submodule}' has no manifest at ${SUBMODULES_DIR}/${submodule}/manifest.yml. Its parent's install is incomplete; reinstall or update it.`, ); } const parsed = validateManifest(await readFile(manifestPath, 'utf-8')); if (!parsed.success) { throw new InstanceRequestRefusedError( `Submodule '${submodule}' has an invalid manifest: ${parsed.errors.map((e) => `${e.path}: ${e.message}`).join(', ')}`, ); } return parsed.data; } /** Build the framework-side ops for one calling module. */ export function createInstanceOps(context: InstanceOpsContext): InstanceOps { const { parentId, db, declaredNetworks } = context; function parentRow() { const row = db.select().from(modules).where(eq(modules.id, parentId)).get(); if (!row) { throw new InstanceRequestRefusedError(`Module '${parentId}' is not installed.`); } return row; } function rowToInstance(row: typeof moduleInstances.$inferSelect): ModuleInstance { return { submodule: row.submodule, instanceKey: row.instanceKey, moduleId: row.moduleId, label: row.label, state: row.state, failureReason: row.failureReason, retryable: row.retryable, }; } return { declaredSubmodules() { const manifest = parentRow().manifestData as unknown as ModuleManifest; return manifest.submodules ?? []; }, async create(request: InstantiateRequest) { const parent = parentRow(); const submoduleManifest = await loadSubmoduleManifest(parent.sourcePath, request.submodule); const config = request.config ?? {}; const configProblems = validateInstanceConfig(submoduleManifest, config); if (configProblems.length > 0) { throw new InstanceRequestRefusedError( `Configuration for '${request.submodule}' instance '${request.instanceKey}' is not valid:\n ${configProblems.join('\n ')}`, ); } const networkRefusal = refuseUndeclaredNetworks(submoduleManifest, declaredNetworks); if (networkRefusal) throw new InstanceRequestRefusedError(networkRefusal); const moduleId = deriveInstanceModuleId(parentId, request.submodule, request.instanceKey); // Idempotent on the caller's key (D6), so a reconcile loop that fires // twice is harmless rather than producing a second system. The farm is // still rebuilt below: an existing instance whose parent gained a subtree // needs its links converged, and doing it here means a retry REPAIRS. const existing = db .select() .from(moduleInstances) .where(eq(moduleInstances.moduleId, moduleId)) .get(); // A peer of every other module, never nested under the parent (D4). The // flat store is what keeps backup's cross-module walk, `module list`, // health and fleet status working on an instance with no change. const instancePath = join(dirname(parent.sourcePath), moduleId); await buildInstanceLinkFarm({ instancePath, submodulePath: submoduleSourcePath(parent.sourcePath, request.submodule), }); if (!existing) { db.insert(modules) .values({ id: moduleId, name: `${submoduleManifest.name} (${request.label ?? request.instanceKey})`, version: submoduleManifest.version, description: submoduleManifest.description, manifestData: submoduleManifest as unknown as Record, sourcePath: instancePath, state: 'IMPORTED', }) .run(); db.insert(moduleInstances) .values({ moduleId, parentId, submodule: request.submodule, instanceKey: request.instanceKey, label: request.label ?? null, state: 'pending', }) .run(); } // After the rows exist, so a config write cannot land on a module that is // not there. Re-applied on a repeat create, which is what makes an // instantiate a converge rather than a create-once. for (const [key, value] of Object.entries(config)) { await writeModuleConfigKey(moduleId, key, value, db); } return moduleId; }, async markForDestruction(submodule: string, instanceKey: string) { const moduleId = deriveInstanceModuleId(parentId, submodule, instanceKey); const existing = db .select() .from(moduleInstances) .where(eq(moduleInstances.moduleId, moduleId)) .get(); // Nothing to destroy is a success, so a reconcile loop need not check // first. Null says so rather than an id that names nothing. if (!existing) return null; db.update(moduleInstances) .set({ state: 'destroying', updatedAt: new Date() }) .where(eq(moduleInstances.moduleId, moduleId)) .run(); return moduleId; }, async list() { return db .select() .from(moduleInstances) .where(eq(moduleInstances.parentId, parentId)) .orderBy(moduleInstances.createdAt) .all() .map(rowToInstance); }, }; }