/** * Module Build Service * * Executes module build scripts (Ansible playbooks) * to compile custom software binaries before deployment. * * Example: Caddy with RFC2136 DNS provider */ import { existsSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { eq } from 'drizzle-orm'; import { log } from '../cli/prompts'; import type { DbClient } from '../db/client'; import { type BuildStatus, moduleBuilds, modules } from '../db/schema'; import type { ModuleManifest } from '../manifest/schema'; /** * Build result */ export interface BuildResult { success: boolean; artifacts?: string[]; buildLog?: string; error?: string; } /** * Build artifact from manifest (simple path string) */ export type BuildArtifact = string; /** * Build status record from database */ export interface BuildStatusRecord { status: BuildStatus; artifacts: string[]; builtAt: Date; buildLog?: string; } /** * Execute build playbook with streaming progress * Execution function - runs ansible-playbook with fuel-gauge progress indicator * * @param modulePath - Path to module directory * @param commandStr - Build command to execute * @returns Build result with output */ async function executeBuildCommand( modulePath: string, commandStr: string, ): Promise<{ success: boolean; output: string; error?: string }> { const { executeBuildWithProgress } = await import('./build-stream'); // build-stream passes through `shell: true`, so spawn re-wraps everything in // `/bin/sh -c`. If we sent ['bash', '-c', commandStr] as command+args, sh // would re-parse the join — `&&` outside the inner quotes splits the chain // and `bun install` runs in modulePath instead of the cd'd subdir. Pass the // full command as one string so sh -c gets a single, unsplit shell command. const command = commandStr; const result = await executeBuildWithProgress({ command, args: [], cwd: modulePath, // Match packaging/build.ts so deploy-time builds work the same as // package-time builds. celilo-registry's manifest.build.command uses // $CELILO_MODULE_SOURCE_DIR to find sibling packages in the monorepo; // without this, that cd resolves to "/" and the build silently fails. env: { CELILO_MODULE_SOURCE_DIR: modulePath }, }); return { success: result.success, output: result.output, error: result.error }; } async function executeBuildScript( modulePath: string, scriptPath: string, ): Promise<{ success: boolean; output: string; error?: string }> { const fullScriptPath = `${modulePath}/${scriptPath}`; if (!existsSync(fullScriptPath)) { throw new Error(`Build script not found: ${scriptPath}`); } const { executeBuildWithProgress } = await import('./build-stream'); const isShellScript = scriptPath.endsWith('.sh'); let command: string; let args: string[]; if (isShellScript) { command = 'bash'; args = [scriptPath]; } else { command = 'ansible-playbook'; args = [scriptPath]; } const result = await executeBuildWithProgress({ command, args, cwd: modulePath, env: { CELILO_MODULE_SOURCE_DIR: modulePath }, }); return { success: result.success, output: result.output, error: result.error }; } /** * Verify build artifacts exist * Policy function - validates file existence * * @param artifacts - List of artifact paths from manifest (relative to module directory) * @param modulePath - Module directory path * @returns Array of [artifactPath, exists, size] */ function verifyBuildArtifacts( artifacts: BuildArtifact[], modulePath: string, ): Array<{ path: string; exists: boolean; size?: number }> { const results: Array<{ path: string; exists: boolean; size?: number }> = []; for (const artifactPath of artifacts) { // Resolve relative to module directory const fullPath = join(modulePath, artifactPath); const exists = existsSync(fullPath); let size: number | undefined; if (exists) { const stats = statSync(fullPath); size = stats.size; } results.push({ path: fullPath, exists, size }); } return results; } /** * Format bytes to human-readable size * Policy function - formatting * * @param bytes - Size in bytes * @returns Formatted string (e.g., "48.2 MB") */ function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } /** * Record build metadata in database * Execution function - database write * * @param moduleId - Module identifier * @param version - Module version * @param artifacts - List of artifact paths * @param status - Build status * @param buildLog - Build output log * @param db - Database client */ async function recordBuildMetadata( moduleId: string, version: string, artifacts: string[], status: BuildStatus, buildLog: string, db: DbClient, ): Promise { await db.insert(moduleBuilds).values({ moduleId, version, artifacts, status, buildLog, }); } /** * Build module from source * Orchestrator function - coordinates build workflow * * @param moduleId - Module identifier * @param db - Database client * @returns Build result */ export async function buildModuleFromSource(moduleId: string, db: DbClient): Promise { // Fetch module from database const module = await db.select().from(modules).where(eq(modules.id, moduleId)).get(); if (!module) { return { success: false, error: `Module not found: ${moduleId}`, }; } const manifest = module.manifestData as ModuleManifest; // Check if module has build section if (!manifest.build) { return { success: false, error: `Module ${moduleId} does not have a build section in manifest`, }; } const { command, script, artifacts } = manifest.build; const modulePath = module.sourcePath; if (!command && !script) { return { success: false, error: `Module ${moduleId} build section must specify either "command" or "script"`, }; } try { // Execute build script with streaming progress const buildResult = command ? await executeBuildCommand(modulePath, command) : await executeBuildScript(modulePath, script as string); // Check if build command failed if (!buildResult.success) { const errorMsg = buildResult.error || 'Build command failed'; // Record failed build await recordBuildMetadata(moduleId, manifest.version, [], 'failed', buildResult.output, db); return { success: false, error: errorMsg, buildLog: buildResult.output, }; } const buildLog = buildResult.output.trim(); // Verify artifacts if (artifacts && artifacts.length > 0) { log.message('Verifying build artifacts...'); const verification = verifyBuildArtifacts(artifacts, modulePath); const allExist = verification.every((v) => v.exists); if (!allExist) { const missing = verification.filter((v) => !v.exists); const errorMsg = `Build failed: Missing artifacts:\n${missing.map((m) => ` - ${m.path}`).join('\n')}`; // Record failed build await recordBuildMetadata( moduleId, manifest.version, [], 'failed', `${buildLog}\n\n${errorMsg}`, db, ); return { success: false, error: errorMsg, buildLog, }; } // Display artifact info for (const result of verification) { const sizeStr = result.size ? ` (${formatBytes(result.size)})` : ''; log.success(`Artifact verified: ${result.path}${sizeStr}`); } // Record successful build const artifactPaths = verification.map((v) => v.path); await recordBuildMetadata(moduleId, manifest.version, artifactPaths, 'success', buildLog, db); return { success: true, artifacts: artifactPaths, buildLog, }; } // No artifacts defined, just record success await recordBuildMetadata(moduleId, manifest.version, [], 'success', buildLog, db); return { success: true, artifacts: [], buildLog, }; } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unknown error'; const buildLog = `Build failed: ${errorMsg}`; // Record failed build await recordBuildMetadata(moduleId, manifest.version, [], 'failed', buildLog, db); return { success: false, error: errorMsg, buildLog, }; } } /** * Get module build status * Execution function - database query * * @param moduleId - Module identifier * @param db - Database client * @returns Build status record or null if never built */ export async function getModuleBuildStatus( moduleId: string, db: DbClient, ): Promise { const build = await db .select() .from(moduleBuilds) .where(eq(moduleBuilds.moduleId, moduleId)) .orderBy(moduleBuilds.builtAt) .limit(1) .get(); if (!build) { return null; } return { status: build.status, artifacts: build.artifacts as string[], builtAt: new Date(build.builtAt), buildLog: build.buildLog || undefined, }; } /** * Verify build artifacts still exist * Policy function - validates current state of artifacts * * @param artifactPaths - List of full artifact paths (from database) * @returns True if all artifacts exist */ export function verifyArtifactsExist(artifactPaths: string[]): boolean { for (const path of artifactPaths) { if (!existsSync(path)) { return false; } } return true; }