Files
flynn/src/backup/run.ts
T

169 lines
4.5 KiB
TypeScript

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<BackupResult> {
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,
};