169 lines
4.5 KiB
TypeScript
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,
|
|
};
|