feat(backup): add MinIO snapshot backups via CLI and scheduler
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { runBackupSnapshot, backupInternals, type BackupRunOptions, type BackupResult } from './run.js';
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { backupInternals } from './run.js';
|
||||
|
||||
describe('backup internals', () => {
|
||||
it('builds minio host URL with protocol and encoded credentials', () => {
|
||||
const host = backupInternals.buildMinioHost({
|
||||
endpoint: 'localhost:9000',
|
||||
accessKey: 'minio-admin',
|
||||
secretKey: 's3cr3t/with:chars',
|
||||
secure: false,
|
||||
});
|
||||
|
||||
expect(host).toBe('http://minio-admin:s3cr3t%2Fwith%3Achars@localhost:9000');
|
||||
});
|
||||
|
||||
it('builds object key from prefix and file name', () => {
|
||||
expect(backupInternals.buildObjectKey('flynn/daily', 'a.tar.gz')).toBe('flynn/daily/a.tar.gz');
|
||||
expect(backupInternals.buildObjectKey('/flynn/daily/', 'a.tar.gz')).toBe('flynn/daily/a.tar.gz');
|
||||
expect(backupInternals.buildObjectKey('', 'a.tar.gz')).toBe('a.tar.gz');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user