/** * Ansible Collection Dependency Management * * Handles version constraint parsing, merging, conflict detection, * and automatic collection installation. */ import { log } from '../cli/prompts'; import type { AnsibleCollection } from '../manifest/schema'; import { GalaxyCollectionListSchema, GalaxyFilesSchema, GalaxyManifestSchema, parseJsonWithValidation, } from '../validation/schemas'; /** * Parsed version constraint */ export interface VersionConstraint { operator: '>=' | '==' | '<=' | '>' | '<'; version: SemanticVersion; } /** * Semantic version (major.minor.patch) */ export interface SemanticVersion { major: number; minor: number; patch: number; } /** * Parse a semantic version string * * @param versionStr - Version string (e.g., "8.0.0") * @returns Parsed semantic version */ export function parseSemanticVersion(versionStr: string): SemanticVersion { const match = versionStr.match(/^(\d+)\.(\d+)\.(\d+)$/); if (!match) { throw new Error(`Invalid semantic version: ${versionStr}`); } return { major: Number.parseInt(match[1], 10), minor: Number.parseInt(match[2], 10), patch: Number.parseInt(match[3], 10), }; } /** * Compare two semantic versions * * @returns -1 if a < b, 0 if a === b, 1 if a > b */ export function compareVersions(a: SemanticVersion, b: SemanticVersion): number { if (a.major !== b.major) return a.major - b.major; if (a.minor !== b.minor) return a.minor - b.minor; return a.patch - b.patch; } /** * Parse a version constraint string * * @param constraintStr - Constraint string (e.g., ">=8.0.0" or ">=8.0.0,<10.0.0") * @returns Array of parsed constraints */ export function parseVersionConstraint(constraintStr: string): VersionConstraint[] { const parts = constraintStr.split(',').map((s) => s.trim()); const constraints: VersionConstraint[] = []; for (const part of parts) { const match = part.match(/^(>=|==|<=|>|<)(\d+\.\d+\.\d+)$/); if (!match) { throw new Error(`Invalid version constraint: ${part}`); } constraints.push({ operator: match[1] as VersionConstraint['operator'], version: parseSemanticVersion(match[2]), }); } return constraints; } /** * Check if a version satisfies a single constraint */ export function satisfiesConstraint( version: SemanticVersion, constraint: VersionConstraint, ): boolean { const cmp = compareVersions(version, constraint.version); switch (constraint.operator) { case '>=': return cmp >= 0; case '==': return cmp === 0; case '<=': return cmp <= 0; case '>': return cmp > 0; case '<': return cmp < 0; } } /** * Check if a version satisfies all constraints */ export function satisfiesConstraints( version: SemanticVersion, constraints: VersionConstraint[], ): boolean { return constraints.every((c) => satisfiesConstraint(version, c)); } /** * Find the intersection of multiple constraint sets * * @param constraintSets - Array of constraint sets to intersect * @returns Merged constraints, or null if no intersection exists */ export function intersectConstraints( constraintSets: VersionConstraint[][], ): VersionConstraint[] | null { if (constraintSets.length === 0) return []; if (constraintSets.length === 1) return constraintSets[0]; // Flatten all constraints const allConstraints = constraintSets.flat(); // Group by operator type const lowerBounds: VersionConstraint[] = []; const upperBounds: VersionConstraint[] = []; const exactPins: VersionConstraint[] = []; for (const constraint of allConstraints) { if (constraint.operator === '>=' || constraint.operator === '>') { lowerBounds.push(constraint); } else if (constraint.operator === '<=' || constraint.operator === '<') { upperBounds.push(constraint); } else if (constraint.operator === '==') { exactPins.push(constraint); } } // If there are multiple exact pins, they must all be the same if (exactPins.length > 1) { const first = exactPins[0]; const allSame = exactPins.every((pin) => compareVersions(pin.version, first.version) === 0); if (!allSame) { return null; // Conflicting exact pins } return [first]; // All exact pins are the same } // If there's one exact pin, check it against bounds if (exactPins.length === 1) { const pin = exactPins[0]; const satisfiesAll = lowerBounds.every((lb) => satisfiesConstraint(pin.version, lb)) && upperBounds.every((ub) => satisfiesConstraint(pin.version, ub)); return satisfiesAll ? [pin] : null; } // No exact pins - find the most restrictive lower and upper bounds const result: VersionConstraint[] = []; // Most restrictive lower bound (highest minimum) if (lowerBounds.length > 0) { const maxLower = lowerBounds.reduce((max, curr) => { const cmp = compareVersions(curr.version, max.version); if (cmp > 0) return curr; if (cmp === 0 && curr.operator === '>' && max.operator === '>=') return curr; return max; }); result.push(maxLower); } // Most restrictive upper bound (lowest maximum) if (upperBounds.length > 0) { const minUpper = upperBounds.reduce((min, curr) => { const cmp = compareVersions(curr.version, min.version); if (cmp < 0) return curr; if (cmp === 0 && curr.operator === '<' && min.operator === '<=') return curr; return min; }); result.push(minUpper); } // Check if bounds are compatible if (result.length === 2) { const [lower, upper] = result; const cmp = compareVersions(lower.version, upper.version); // Lower bound must be less than upper bound if (cmp > 0) return null; // If equal, check operators if (cmp === 0) { // Only valid if both are inclusive (>= and <=) if (lower.operator !== '>=' || upper.operator !== '<=') { return null; } } } return result.length > 0 ? result : []; } /** * Collection requirement with module context */ export interface CollectionRequirement { name: string; constraints: VersionConstraint[]; moduleId: string; reason?: string; modules_used?: string[]; } /** * Merged requirement result */ export interface MergedRequirement { name: string; constraints: VersionConstraint[]; satisfiesAll: boolean; conflictingModules?: string[]; } /** * Dependency merge result */ export interface DependencyMergeResult { success: boolean; resolved: MergedRequirement[]; conflicts?: Array<{ collection: string; requirements: Array<{ moduleId: string; version: string; reason?: string; }>; }>; } /** * Merge Ansible collection requirements from multiple modules * * @param requirements - Collection requirements from all modules * @returns Merge result with resolved requirements or conflicts */ export function mergeAnsibleRequirements( requirements: CollectionRequirement[], ): DependencyMergeResult { // Group by collection name const byCollection = new Map(); for (const req of requirements) { if (!byCollection.has(req.name)) { byCollection.set(req.name, []); } byCollection.get(req.name)?.push(req); } const resolved: MergedRequirement[] = []; const conflicts: DependencyMergeResult['conflicts'] = []; // Merge each collection's requirements for (const [collectionName, reqs] of byCollection) { const constraintSets = reqs.map((r) => r.constraints); const merged = intersectConstraints(constraintSets); if (merged === null) { // CONFLICT conflicts.push({ collection: collectionName, requirements: reqs.map((r) => ({ moduleId: r.moduleId, version: formatConstraints(r.constraints), reason: r.reason, })), }); } else { // SUCCESS resolved.push({ name: collectionName, constraints: merged, satisfiesAll: true, }); } } return { success: conflicts.length === 0, resolved, conflicts: conflicts.length > 0 ? conflicts : undefined, }; } /** * Format constraints as a string * * @param constraints - Array of constraints * @returns Formatted string (e.g., ">=8.0.0" or ">=8.0.0,<10.0.0") */ export function formatConstraints(constraints: VersionConstraint[]): string { return constraints .map((c) => `${c.operator}${c.version.major}.${c.version.minor}.${c.version.patch}`) .join(','); } /** * Format a semantic version as a string */ export function formatVersion(version: SemanticVersion): string { return `${version.major}.${version.minor}.${version.patch}`; } /** * Convert AnsibleCollection from manifest to CollectionRequirement */ export function collectionToRequirement( collection: AnsibleCollection, moduleId: string, ): CollectionRequirement { return { name: collection.name, constraints: parseVersionConstraint(collection.version), moduleId, reason: collection.reason, modules_used: collection.modules_used, }; } /** * Get all Ansible collection requirements from database * * @param db - Database client * @returns Array of collection requirements from all imported modules */ export async function getAllAnsibleRequirements( db: ReturnType, ): Promise { const { modules } = await import('../db/schema'); const allModules = db.select().from(modules).all(); const requirements: CollectionRequirement[] = []; for (const module of allModules) { const manifest = module.manifestData as { ansible?: { collections?: AnsibleCollection[] } }; const collections = manifest.ansible?.collections || []; for (const collection of collections) { requirements.push(collectionToRequirement(collection, module.id)); } } return requirements; } /** * Check if importing a module would cause Ansible dependency conflicts * * @param newModuleManifest - Manifest of module being imported * @param db - Database client * @returns Merge result (success=false if conflicts exist) */ export async function checkImportConflicts( newModuleManifest: { id: string; ansible?: { collections?: AnsibleCollection[] } }, db: ReturnType, ): Promise { // Get existing requirements const existingRequirements = await getAllAnsibleRequirements(db); // Get new module's requirements const newCollections = newModuleManifest.ansible?.collections || []; const newRequirements = newCollections.map((col) => collectionToRequirement(col, newModuleManifest.id), ); // Merge all requirements const allRequirements = [...existingRequirements, ...newRequirements]; return mergeAnsibleRequirements(allRequirements); } /** * Format conflict error message for display * * @param conflicts - Array of conflicts from merge result * @returns Formatted error message with resolution suggestions */ export function formatConflictError(conflicts: DependencyMergeResult['conflicts']): string { if (!conflicts || conflicts.length === 0) { return 'No conflicts detected'; } const lines: string[] = ['Cannot import module: Ansible dependency conflicts detected', '']; for (const conflict of conflicts) { lines.push(`Conflict: ${conflict.collection}`); for (const req of conflict.requirements) { const reasonText = req.reason ? ` (${req.reason})` : ''; lines.push(` - ${req.moduleId} requires: ${req.version}${reasonText}`); } lines.push(''); lines.push('No version satisfies all constraints.'); lines.push(''); } lines.push('Resolution options:'); lines.push(''); lines.push('1. Update existing modules to support newer versions:'); lines.push(' Check if module authors have released compatible updates'); lines.push(''); lines.push("2. Use a different version of the module you're importing:"); lines.push(' Some older versions may have more permissive dependencies'); lines.push(''); lines.push('3. Contact module authors:'); lines.push(' Report the compatibility issue so dependencies can be updated'); lines.push(''); lines.push('For more information, see:'); lines.push(' https://docs.celilo.example.com/ansible-dependencies'); return lines.join('\n'); } /** * Installed collection info */ export interface InstalledCollection { name: string; version: SemanticVersion; path: string; } /** * Dependency cache entry */ interface DependencyCacheEntry { moduleRequirements: Array<{ moduleId: string; collections: Array<{ name: string; version: string; reason?: string; }>; }>; installedCollections: Array<{ name: string; version: string; }>; lastVerified: number; } /** * Get path to dependency cache file */ function getDependencyCachePath(): string { const { homedir } = require('node:os'); const { join } = require('node:path'); return join(homedir(), '.celilo', 'ansible-deps-cache.json'); } /** * Save dependency cache */ async function saveDependencyCache(cache: DependencyCacheEntry): Promise { const { writeFile, mkdir } = await import('node:fs/promises'); const { dirname } = await import('node:path'); try { const cachePath = getDependencyCachePath(); await mkdir(dirname(cachePath), { recursive: true }); await writeFile(cachePath, JSON.stringify(cache, null, 2), 'utf-8'); } catch { // Non-fatal - cache is optional } } /** * Update dependency cache after installation */ export async function updateDependencyCache( db: ReturnType, ): Promise { try { // Get all module requirements const allRequirements = await getAllAnsibleRequirements(db); const moduleMap = new Map(); for (const req of allRequirements) { if (!moduleMap.has(req.moduleId)) { moduleMap.set(req.moduleId, []); } moduleMap.get(req.moduleId)?.push(req); } const moduleRequirements = Array.from(moduleMap.entries()).map(([moduleId, reqs]) => ({ moduleId, collections: reqs.map((r) => ({ name: r.name, version: formatConstraints(r.constraints), reason: r.reason, })), })); // Get installed collections const installed = await getInstalledCollections(); const installedCollections = Array.from(installed.values()).map((col) => ({ name: col.name, version: formatVersion(col.version), })); const cache: DependencyCacheEntry = { moduleRequirements, installedCollections, lastVerified: Date.now(), }; await saveDependencyCache(cache); } catch { // Non-fatal - cache is optional } } /** * Get list of installed Ansible collections * * Runs `ansible-galaxy collection list` and parses output * * @returns Map of collection name to installed version */ export async function getInstalledCollections(): Promise> { const { exec } = await import('node:child_process'); const { promisify } = await import('node:util'); const execAsync = promisify(exec); try { const { stdout } = await execAsync('ansible-galaxy collection list --format json'); const data = parseJsonWithValidation( stdout, GalaxyCollectionListSchema, 'ansible-galaxy collection list output', ); const collections = new Map(); // ansible-galaxy collection list output format: // { // "/path/to/collections": { // "namespace.collection": { "version": "1.2.3" } // } // } for (const [path, pathCollections] of Object.entries(data)) { if (typeof pathCollections !== 'object' || pathCollections === null) continue; for (const [name, info] of Object.entries(pathCollections as Record)) { if (typeof info === 'object' && info !== null && 'version' in info) { const versionStr = (info as { version: string }).version; try { collections.set(name, { name, version: parseSemanticVersion(versionStr), path: path as string, }); } catch (error) { // Rule 6.2: never a bare catch. An unparseable version dropped the // collection from the map entirely, so it read as NOT INSTALLED — // and the installer would then try to install it again, every time, // reporting success while nothing changed. console.warn( ` ⚠ Ignoring installed collection '${name}': unparseable version '${versionStr}' (${ error instanceof Error ? error.message : String(error) })`, ); } } } } return collections; } catch (_error) { // If ansible-galaxy not found or other error, return empty map return new Map(); } } /** * Decide what an integrity outcome means for the import, and say so. * * A REFUTED collection stops the import; an UNCHECKABLE one warns. The split is * the point (celilo#524): * * - `mismatch` — files on disk do not match the collection's own manifest. * Ansible is about to execute that content, and celilo cannot account for * it. Refusing costs an import; accepting runs unaccounted-for code. * - `unverifiable` — the check could not run, most often because the * collection ships no `file_manifest_file` checksum at all. That is a * property of the publisher, not evidence of tampering, and blocking on it * would refuse ordinary collections forever. It warns. * * Refusing is cheap HERE specifically. This runs during `module import` * (`module/import.ts`), the only caller, and nothing installs collections in * the deploy path — so a false positive means "this module did not import", * not "the fleet stopped deploying". There is also no module row yet, so * nothing is left half-created to clean up. * * ⚠️ This is SELF-ATTESTATION, not provenance: the checksums live inside the * artifact being checked. It catches corruption and post-install modification. * It cannot catch a coherently re-signed tampered collection, and a clean * import must not be read as saying otherwise. */ export function reportIntegrity(name: string, outcome: IntegrityOutcome): string | null { if (outcome.status === 'mismatch') { return `Integrity check FAILED for Ansible collection '${name}': ${outcome.detail}.\nThe installed files do not match the collection's own manifest — this is corruption or tampering.\nImport refused rather than run unverified content.`; } if (outcome.status === 'unverifiable') { console.warn(` ⚠ Integrity could not be checked for ${name}: ${outcome.reason} (non-fatal)`); } return null; } /** * Install result */ export interface InstallResult { success: boolean; installed: string[]; skipped: string[]; error?: string; details?: unknown; } /** * The three things checking one file's checksum can tell you. * * Named states rather than `boolean | null` (celilo#524). The tri-state was * right — a file that is not on disk is genuinely neither a match nor a * mismatch — but spelled as `null` it read as "nothing to worry about", and * every caller duly treated it as one. `absent` cannot be misread. */ export type FileCheck = 'match' | 'mismatch' | 'absent'; /** * Verify one file against its expected SHA-256. * * `absent` means the file could not be read at all, which is a statement about * our knowledge, not about the file's integrity. */ async function verifyFileChecksum(filePath: string, expectedChecksum: string): Promise { const { createHash } = await import('node:crypto'); const { readFile, access } = await import('node:fs/promises'); const { constants } = await import('node:fs'); try { // Check if file exists await access(filePath, constants.R_OK); const content = await readFile(filePath); const hash = createHash('sha256'); hash.update(content); const actualChecksum = hash.digest('hex'); return actualChecksum === expectedChecksum ? 'match' : 'mismatch'; } catch (_error: unknown) { // Unreadable. NOT evidence of integrity — see the type's note. return 'absent'; } } /** * What a verification run actually established. * * Three states, because there are three (celilo#524). The function used to * return `boolean` and could not, in practice, return `false`: three * independent paths reported "integrity verified" for a collection that had * been tampered with, and no constructible input reached the failure return. * * `unverifiable` is the state that was missing. A manifest that declares no * checksum, or a file set with nothing on disk to sample, tells you NOTHING * about integrity — and folding that into `verified` is what let the one thing * an attacker fully controls (the manifest they ship) switch the check off. * Same absent-vs-empty distinction as `parseInterfaceBaseline`. */ export type IntegrityOutcome = | { status: 'verified'; filesChecked: number } /** Something was checked and did not match. Integrity is refuted. */ | { status: 'mismatch'; detail: string } /** Nothing could be checked. Integrity is unknown — NOT confirmed. */ | { status: 'unverifiable'; reason: string }; /** * Verify an installed Galaxy collection against the checksums in its own * MANIFEST.json / FILES.json. * * ⚠️ This is self-attestation, not provenance: the checksums live inside the * artifact being checked. It detects corruption and post-install modification, * and it CANNOT detect a coherently re-signed tampered collection. That is a * reason to be precise about what it reports, not a reason to report a * comforting answer. */ export async function verifyCollectionIntegrity( collectionInfo: InstalledCollection, ): Promise { const { readFile } = await import('node:fs/promises'); const { join } = await import('node:path'); const [namespace, collection] = collectionInfo.name.split('.'); const dir = join(collectionInfo.path, namespace, collection); try { const manifest = parseJsonWithValidation( await readFile(join(dir, 'MANIFEST.json'), 'utf-8'), GalaxyManifestSchema, 'Ansible Galaxy MANIFEST.json', ); const expectedFilesChecksum = manifest.file_manifest_file?.chksum_sha256; if (!expectedFilesChecksum) { // Was `return true`. Stripping `file_manifest_file` from the manifest was // enough to report a tampered collection as verified. return { status: 'unverifiable', reason: 'MANIFEST.json declares no FILES.json checksum', }; } const filesJsonPath = join(dir, 'FILES.json'); switch (await verifyFileChecksum(filesJsonPath, expectedFilesChecksum)) { case 'mismatch': return { status: 'mismatch', detail: 'FILES.json does not match its manifest checksum' }; case 'absent': return { status: 'unverifiable', reason: 'FILES.json is missing or unreadable' }; } const filesData = parseJsonWithValidation( await readFile(filesJsonPath, 'utf-8'), GalaxyFilesSchema, 'Ansible Galaxy FILES.json', ); const files = filesData.files.filter( (f: { ftype: string; chksum_sha256?: string }) => f.ftype === 'file' && f.chksum_sha256, ); if (files.length === 0) { return { status: 'unverifiable', reason: 'FILES.json lists no checksummed files' }; } // Sample rather than verify everything: a large collection is thousands of // files and this runs on every deploy. Sampling bounds the cost; what it // must never do is report a sample of ZERO as a pass. let verified = 0; let attempts = 0; const maxAttempts = Math.min(20, files.length); while (verified < 5 && attempts < maxAttempts) { attempts++; const file = files[Math.floor(Math.random() * files.length)]; if (!file.chksum_sha256) continue; const result = await verifyFileChecksum(join(dir, file.name), file.chksum_sha256); if (result === 'mismatch') { return { status: 'mismatch', detail: `${file.name} does not match its checksum` }; } if (result === 'match') verified++; // 'absent' — try another. Bounded by maxAttempts, and a run that finds // nothing present is reported as unverifiable below, not as a pass. } if (verified === 0) { // Was `return true`. A collection whose every listed file was missing // from disk verified clean. return { status: 'unverifiable', reason: `none of the ${attempts} sampled file(s) were present on disk`, }; } return { status: 'verified', filesChecked: verified }; } catch (error) { // Was `return true` behind a comment claiming it logged, which it did not — // so ANY exception in the body above (unreadable manifest, schema // violation, malformed JSON) was reported as integrity verified. Rule 6.2: // the caller renders this, and it is now a distinct outcome from success. return { status: 'unverifiable', reason: error instanceof Error ? error.message : String(error), }; } } /** * Install Ansible collections to satisfy requirements * * @param requirements - Merged requirements from all modules * @returns Install result */ export async function installAnsibleCollections( requirements: MergedRequirement[], ): Promise { const { exec } = await import('node:child_process'); const { promisify } = await import('node:util'); const execAsync = promisify(exec); const installed: string[] = []; const skipped: string[] = []; try { // Get currently installed collections const currentlyInstalled = await getInstalledCollections(); // Determine what needs to be installed for (const req of requirements) { const existing = currentlyInstalled.get(req.name); // Check if we need to install const needsInstall = !existing || !satisfiesConstraints(existing.version, req.constraints); if (needsInstall) { const constraintStr = formatConstraints(req.constraints); // Suppress individual install messages - show summary instead try { // Install collection to user directory (~/.ansible/collections) // This location is standard across all Ansible installations and works with: // - Homebrew ansible // - Pipx ansible-lint // - System ansible // - Any other installation method await execAsync( `ansible-galaxy collection install "${req.name}:${constraintStr}" -p ~/.ansible/collections`, ); installed.push(`${req.name} ${constraintStr}`); // Verify integrity after installation const freshInstalled = await getInstalledCollections(); const installedCollection = freshInstalled.get(req.name); if (installedCollection) { const refusal = reportIntegrity( req.name, await verifyCollectionIntegrity(installedCollection), ); if (refusal) return { success: false, installed, skipped, error: refusal }; } } catch (installError) { // Installation failed return { success: false, installed, skipped, error: `Failed to install ${req.name}`, details: installError, }; } } else { // Already installed and satisfies constraints const existingVersionStr = formatVersion(existing.version); // Suppress individual skipped messages - show summary instead skipped.push(`${req.name} ${existingVersionStr}`); // Verify integrity of existing collection const refusal = reportIntegrity(req.name, await verifyCollectionIntegrity(existing)); if (refusal) return { success: false, installed, skipped, error: refusal }; } } return { success: true, installed, skipped, }; } catch (error) { return { success: false, installed, skipped, error: 'Failed to install Ansible collections', details: error, }; } } /** * Install Ansible collections for a module being imported * * Convenience wrapper that checks for conflicts and installs collections * * @param newModuleManifest - Manifest of module being imported * @param db - Database client * @returns Install result */ export async function installCollectionsForModule( newModuleManifest: { id: string; ansible?: { collections?: AnsibleCollection[] } }, db: ReturnType, ): Promise { // Check for conflicts first const conflictCheck = await checkImportConflicts(newModuleManifest, db); if (!conflictCheck.success) { return { success: false, installed: [], skipped: [], error: formatConflictError(conflictCheck.conflicts), }; } // If no collections needed, skip if (conflictCheck.resolved.length === 0) { return { success: true, installed: [], skipped: [], }; } // Install collections const installResult = await installAnsibleCollections(conflictCheck.resolved); if (installResult.success) { // Build complete message with all details const lines = ['Installing Ansible collections...']; // List each installed collection for (const item of installResult.installed) { lines.push(` ${item} installed`); } // List each skipped collection for (const item of installResult.skipped) { lines.push(` ${item} already installed`); } // Show summary if multiple collections const total = installResult.installed.length + installResult.skipped.length; if (total > 1) { const parts: string[] = []; if (installResult.installed.length > 0) { parts.push(`${installResult.installed.length} installed`); } if (installResult.skipped.length > 0) { parts.push(`${installResult.skipped.length} skipped`); } lines.push(` ${parts.join(', ')}`); } log.info(lines.join('\n')); } else { log.info('Installing Ansible collections...'); } return installResult; }