import { execFile } from 'node:child_process'; import { existsSync, mkdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { promisify } from 'node:util'; import { homedir } from 'node:os'; import type { BackupConfig } from '../config/index.js'; import { auditLogger } from '../audit/index.js'; const execFileAsync = promisify(execFile); export interface BackupTargetFiles { sessionsDb: string; vectorsDb?: string; memoryDir?: string; } export interface BackupRunOptions { dataDir: string; backupConfig: BackupConfig; now?: Date; } export interface BackupResult { archivePath: string; fileName: string; uploaded: boolean; remotePath?: string; } function expandHomePath(pathValue: string): string { if (!pathValue.startsWith('~/')) { return pathValue; } return resolve(homedir(), pathValue.slice(2)); } function fileTimestamp(now: Date): string { const year = now.getUTCFullYear(); const month = String(now.getUTCMonth() + 1).padStart(2, '0'); const day = String(now.getUTCDate()).padStart(2, '0'); const hour = String(now.getUTCHours()).padStart(2, '0'); const minute = String(now.getUTCMinutes()).padStart(2, '0'); const second = String(now.getUTCSeconds()).padStart(2, '0'); return `${year}${month}${day}_${hour}${minute}${second}`; } function buildMinioHost(opts: { endpoint: string; accessKey: string; secretKey: string; secure: boolean; }): string { const protocol = opts.secure ? 'https' : 'http'; const encodedAccess = encodeURIComponent(opts.accessKey); const encodedSecret = encodeURIComponent(opts.secretKey); return `${protocol}://${encodedAccess}:${encodedSecret}@${opts.endpoint}`; } function buildObjectKey(prefix: string, fileName: string): string { const trimmed = prefix.replace(/^\/+|\/+$/g, ''); return trimmed.length > 0 ? `${trimmed}/${fileName}` : fileName; } function collectExistingEntries(opts: { dataDir: string; backupConfig: BackupConfig; }): string[] { const entries: string[] = []; const sessionsPath = join(opts.dataDir, 'sessions.db'); if (existsSync(sessionsPath)) { entries.push('sessions.db'); } if (opts.backupConfig.include_vectors) { const vectorsPath = join(opts.dataDir, 'vectors.db'); if (existsSync(vectorsPath)) { entries.push('vectors.db'); } } const memoryPath = join(opts.dataDir, 'memory'); if (existsSync(memoryPath)) { entries.push('memory'); } return entries; } export async function runBackupSnapshot(opts: BackupRunOptions): Promise { const now = opts.now ?? new Date(); const localDir = expandHomePath(opts.backupConfig.local_dir); mkdirSync(localDir, { recursive: true }); const entries = collectExistingEntries({ dataDir: opts.dataDir, backupConfig: opts.backupConfig, }); if (entries.length === 0) { throw new Error(`No backup inputs found under ${opts.dataDir}`); } const fileName = `flynn_${fileTimestamp(now)}.tar.gz`; const archivePath = join(localDir, fileName); await execFileAsync('tar', ['-czf', archivePath, '-C', opts.dataDir, ...entries]); auditLogger?.systemConfig('backup', 'snapshot', { archive_path: archivePath, entry_count: entries.length, }); if (!opts.backupConfig.minio.enabled) { return { archivePath, fileName, uploaded: false, }; } const endpoint = opts.backupConfig.minio.endpoint; const accessKey = opts.backupConfig.minio.access_key; const secretKey = opts.backupConfig.minio.secret_key; const bucket = opts.backupConfig.minio.bucket; if (!endpoint || !accessKey || !secretKey || !bucket) { throw new Error('backup.minio.enabled=true requires endpoint, access_key, secret_key, and bucket'); } const alias = 'flynnbackup'; const host = buildMinioHost({ endpoint, accessKey, secretKey, secure: opts.backupConfig.minio.secure, }); const env = { ...process.env, [`MC_HOST_${alias}`]: host, }; await execFileAsync('mc', ['mb', '--ignore-existing', `${alias}/${bucket}`], { env }); const objectKey = buildObjectKey(opts.backupConfig.minio.prefix, fileName); const remotePath = `${alias}/${bucket}/${objectKey}`; await execFileAsync('mc', ['cp', archivePath, remotePath], { env }); auditLogger?.systemConfig('backup', 'upload', { archive_path: archivePath, remote_path: remotePath, endpoint, bucket, }); return { archivePath, fileName, uploaded: true, remotePath, }; } export const backupInternals = { buildMinioHost, buildObjectKey, collectExistingEntries, expandHomePath, fileTimestamp, };