import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { eq } from 'drizzle-orm'; import { secrets } from '../db/schema'; import { decryptSecret } from '../secrets/encryption'; import { getOrCreateMasterKey } from '../secrets/master-key'; import { getVaultPassword } from '../secrets/vault'; /** * Ansible secrets generation result */ export interface AnsibleSecretsResult { success: boolean; secretsYaml?: string; encryptedPath?: string; error?: string; details?: unknown; } /** * Generate Ansible secrets YAML content * * Policy function (Rule 10.1) - pure formatting, no I/O * * @param secrets - Map of secret names to decrypted values * @returns YAML content as string */ export function formatSecretsYaml(secrets: Record): string { const lines = ['---', '# Ansible Vault encrypted secrets', '']; // Sort keys for deterministic output const sortedKeys = Object.keys(secrets).sort(); for (const key of sortedKeys) { const value = secrets[key]; // Escape YAML special characters const escaped = value.includes("'") ? `"${value.replace(/"/g, '\\"')}"` : `'${value}'`; lines.push(`${key}: ${escaped}`); } return `${lines.join('\n')}\n`; } /** * Decrypt all secrets for a module * * Execution function - performs database queries and decryption * * @param moduleId - Module ID * @param db - Database client * @returns Map of secret names to decrypted values */ export async function decryptModuleSecrets( moduleId: string, db: ReturnType, ): Promise> { // Get master key for decryption const masterKey = await getOrCreateMasterKey(); // Fetch encrypted secrets from database const secretRows = db.select().from(secrets).where(eq(secrets.moduleId, moduleId)).all(); const decryptedSecrets: Record = {}; for (const row of secretRows) { try { const decrypted = decryptSecret( { encryptedValue: row.encryptedValue, iv: row.iv, authTag: row.authTag, }, masterKey, ); decryptedSecrets[row.name] = decrypted; } catch (error) { throw new Error(`Failed to decrypt secret '${row.name}' for module '${moduleId}': ${error}`); } } return decryptedSecrets; } /** * Encrypt YAML content using ansible-vault * * Execution function - spawns external process * * Uses temporary files to avoid stdin/stdout complexity * * @param yamlContent - Plaintext YAML content * @param vaultPassword - Vault password * @returns Encrypted content or error */ export async function encryptWithAnsibleVault( yamlContent: string, vaultPassword: string, ): Promise<{ success: true; encrypted: string } | { success: false; error: string }> { // Check if ansible-vault is available const checkResult = Bun.spawnSync(['which', 'ansible-vault'], { stdout: 'pipe', stderr: 'pipe', }); if (checkResult.exitCode !== 0) { return { success: false, error: 'ansible-vault command not found. Please install Ansible: https://docs.ansible.com/ansible/latest/installation_guide/intro_installation.html', }; } // Create temporary directory for vault operations const tempDir = await mkdtemp(join(tmpdir(), 'celilo-vault-')); try { // Write plaintext YAML to temp file const plaintextPath = join(tempDir, 'plaintext.yml'); await writeFile(plaintextPath, yamlContent, 'utf-8'); // Write vault password to temp file const passwordPath = join(tempDir, 'vault-pass'); await writeFile(passwordPath, vaultPassword, 'utf-8'); // Encrypt using ansible-vault const encryptResult = Bun.spawnSync( ['ansible-vault', 'encrypt', '--vault-password-file', passwordPath, plaintextPath], { stdout: 'pipe', stderr: 'pipe', cwd: tempDir, }, ); if (encryptResult.exitCode !== 0) { const stderr = encryptResult.stderr ? new TextDecoder().decode(encryptResult.stderr) : 'Unknown error'; return { success: false, error: `ansible-vault encryption failed: ${stderr}`, }; } // Read encrypted content const encrypted = await readFile(plaintextPath, 'utf-8'); return { success: true, encrypted, }; } finally { // Clean up temp directory await rm(tempDir, { recursive: true, force: true }); } } /** * Generate and encrypt Ansible secrets file * * Orchestration function - coordinates all steps * * @param moduleId - Module ID * @param outputPath - Path to write encrypted secrets.yml * @param db - Database client * @returns Generation result */ export async function generateAnsibleSecrets( moduleId: string, outputPath: string, db: ReturnType, ): Promise { try { // Step 1: Decrypt secrets from database const decryptedSecrets = await decryptModuleSecrets(moduleId, db); if (Object.keys(decryptedSecrets).length === 0) { // No secrets to encrypt - skip file generation return { success: true, secretsYaml: '', }; } // Step 2: Format as YAML const yamlContent = formatSecretsYaml(decryptedSecrets); // Step 3: Get vault password const vaultPassword = await getVaultPassword(); // Step 4: Encrypt with ansible-vault const encryptResult = await encryptWithAnsibleVault(yamlContent, vaultPassword); if (!encryptResult.success) { return { success: false, error: encryptResult.error, }; } // Step 5: Write encrypted file // Ensure directory exists const outputDir = dirname(outputPath); await mkdir(outputDir, { recursive: true }); await writeFile(outputPath, encryptResult.encrypted, 'utf-8'); return { success: true, secretsYaml: yamlContent, encryptedPath: outputPath, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); const errorStack = error instanceof Error ? error.stack : undefined; return { success: false, error: `Failed to generate Ansible secrets: ${errorMessage}`, details: errorStack, }; } }