feat(backup): add MinIO snapshot backups via CLI and scheduler

This commit is contained in:
William Valentin
2026-02-16 13:16:29 -08:00
parent 8bed99c770
commit 01ee6ba53f
13 changed files with 416 additions and 1 deletions
+4
View File
@@ -65,6 +65,7 @@ Flynn provides a full CLI via the `flynn` binary (or `npx tsx src/cli/index.ts`
| `flynn sessions` | List active sessions | | `flynn sessions` | List active sessions |
| `flynn doctor` | Validate config and check system health | | `flynn doctor` | Validate config and check system health |
| `flynn config` | Show resolved configuration (secrets redacted) | | `flynn config` | Show resolved configuration (secrets redacted) |
| `flynn backup` | Create a snapshot backup and optionally upload to MinIO |
| `flynn completion <shell>` | Generate shell completions (bash, zsh, fish) | | `flynn completion <shell>` | Generate shell completions (bash, zsh, fish) |
| `flynn setup` | Interactive setup wizard | | `flynn setup` | Interactive setup wizard |
| `flynn gmail-auth` | Authenticate with Gmail via OAuth2 | | `flynn gmail-auth` | Authenticate with Gmail via OAuth2 |
@@ -89,6 +90,9 @@ flynn config
# List sessions # List sessions
flynn sessions flynn sessions
# Create backup now (uses config backup + MinIO settings)
flynn backup
# Generate shell completions # Generate shell completions
flynn completion bash # Print bash completions to stdout flynn completion bash # Print bash completions to stdout
flynn completion zsh --install # Install zsh completions to ~/.zshrc flynn completion zsh --install # Install zsh completions to ~/.zshrc
+18
View File
@@ -284,6 +284,24 @@ hooks:
# failure_threshold: 2 # failure_threshold: 2
# disk_threshold_mb: 100 # disk_threshold_mb: 100
# ── Backup ──────────────────────────────────────────────────────────
# Snapshot sessions.db, vectors.db (optional), and memory/ into a tarball.
# If MinIO is enabled, upload with `mc` using ephemeral credentials.
#
# backup:
# enabled: false
# interval: "24h"
# local_dir: ~/.local/share/flynn/backups
# include_vectors: true
# minio:
# enabled: false
# endpoint: localhost:9000
# access_key: ${MINIO_ACCESS_KEY}
# secret_key: ${MINIO_SECRET_KEY}
# bucket: flynn-backups
# prefix: flynn
# secure: true
# ── Audio ──────────────────────────────────────────────────────────── # ── Audio ────────────────────────────────────────────────────────────
# Configure a Whisper-compatible endpoint for audio transcription. # Configure a Whisper-compatible endpoint for audio transcription.
# Models that support native audio input (Gemini, OpenAI, GitHub) will # Models that support native audio input (Gemini, OpenAI, GitHub) will
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail
# Required env vars:
# FLYNN_DATA_DIR (default: ~/.local/share/flynn)
# FLYNN_BACKUP_DIR (default: ~/.local/share/flynn/backups)
# MINIO_ENDPOINT
# MINIO_ACCESS_KEY
# MINIO_SECRET_KEY
# MINIO_BUCKET
#
# Optional:
# MINIO_PREFIX (default: flynn)
# MINIO_SECURE (default: true)
DATA_DIR="${FLYNN_DATA_DIR:-$HOME/.local/share/flynn}"
BACKUP_DIR="${FLYNN_BACKUP_DIR:-$HOME/.local/share/flynn/backups}"
MINIO_PREFIX="${MINIO_PREFIX:-flynn}"
MINIO_SECURE="${MINIO_SECURE:-true}"
if [[ -z "${MINIO_ENDPOINT:-}" || -z "${MINIO_ACCESS_KEY:-}" || -z "${MINIO_SECRET_KEY:-}" || -z "${MINIO_BUCKET:-}" ]]; then
echo "Missing MinIO config. Set MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY, MINIO_BUCKET." >&2
exit 1
fi
if ! command -v mc >/dev/null 2>&1; then
echo "MinIO client (mc) not found in PATH." >&2
exit 1
fi
mkdir -p "${BACKUP_DIR}"
STAMP="$(date -u +%Y%m%d_%H%M%S)"
ARCHIVE="flynn_${STAMP}.tar.gz"
ARCHIVE_PATH="${BACKUP_DIR}/${ARCHIVE}"
ENTRIES=()
[[ -f "${DATA_DIR}/sessions.db" ]] && ENTRIES+=("sessions.db")
[[ -f "${DATA_DIR}/vectors.db" ]] && ENTRIES+=("vectors.db")
[[ -d "${DATA_DIR}/memory" ]] && ENTRIES+=("memory")
if [[ ${#ENTRIES[@]} -eq 0 ]]; then
echo "No backup inputs found under ${DATA_DIR}" >&2
exit 1
fi
tar -czf "${ARCHIVE_PATH}" -C "${DATA_DIR}" "${ENTRIES[@]}"
if [[ "${MINIO_SECURE}" == "true" ]]; then
PROTO="https"
else
PROTO="http"
fi
export MC_HOST_flynnbackup="${PROTO}://${MINIO_ACCESS_KEY}:${MINIO_SECRET_KEY}@${MINIO_ENDPOINT}"
mc mb --ignore-existing "flynnbackup/${MINIO_BUCKET}" >/dev/null
mc cp "${ARCHIVE_PATH}" "flynnbackup/${MINIO_BUCKET}/${MINIO_PREFIX}/${ARCHIVE}" >/dev/null
echo "Backup created: ${ARCHIVE_PATH}"
echo "Uploaded to: flynnbackup/${MINIO_BUCKET}/${MINIO_PREFIX}/${ARCHIVE}"
+1
View File
@@ -0,0 +1 @@
export { runBackupSnapshot, backupInternals, type BackupRunOptions, type BackupResult } from './run.js';
+21
View File
@@ -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');
});
});
+168
View File
@@ -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,
};
+40
View File
@@ -0,0 +1,40 @@
import type { Command } from 'commander';
import { getDataDir, loadConfigSafe } from './shared.js';
import { runBackupSnapshot } from '../backup/index.js';
export function registerBackupCommand(program: Command): void {
program
.command('backup')
.description('Create a snapshot backup and optionally upload to MinIO')
.option('-c, --config <path>', 'Config file path')
.option('--data-dir <path>', 'Data directory (defaults to FLYNN_DATA_DIR or ~/.local/share/flynn)')
.option('--no-upload', 'Disable MinIO upload for this run')
.action(async (opts: { config?: string; dataDir?: string; upload?: boolean }) => {
const { config, error } = loadConfigSafe(opts.config);
if (!config) {
console.error(error);
process.exit(1);
}
const backupConfig = {
...config.backup,
minio: {
...config.backup.minio,
enabled: opts.upload === false ? false : config.backup.minio.enabled,
},
};
const dataDir = opts.dataDir ?? getDataDir();
const result = await runBackupSnapshot({
dataDir,
backupConfig,
});
console.log(`Backup archive: ${result.archivePath}`);
if (result.uploaded && result.remotePath) {
console.log(`Uploaded to MinIO: ${result.remotePath}`);
} else {
console.log('MinIO upload skipped.');
}
});
}
+1
View File
@@ -13,6 +13,7 @@ describe('CLI program', () => {
expect(commandNames).toContain('doctor'); expect(commandNames).toContain('doctor');
expect(commandNames).toContain('config'); expect(commandNames).toContain('config');
expect(commandNames).toContain('skills'); expect(commandNames).toContain('skills');
expect(commandNames).toContain('backup');
expect(commandNames).toContain('openai-auth'); expect(commandNames).toContain('openai-auth');
expect(commandNames).toContain('openai-key'); expect(commandNames).toContain('openai-key');
+2
View File
@@ -27,6 +27,7 @@ import { registerOpenaiKeyCommand } from './openai-key.js';
import { registerZaiAuthCommand } from './zai-auth.js'; import { registerZaiAuthCommand } from './zai-auth.js';
import { registerAnthropicAuthCommand } from './anthropic-auth.js'; import { registerAnthropicAuthCommand } from './anthropic-auth.js';
import { registerSkillsCommand } from './skills.js'; import { registerSkillsCommand } from './skills.js';
import { registerBackupCommand } from './backup.js';
export function createProgram(): Command { export function createProgram(): Command {
const program = new Command(); const program = new Command();
@@ -54,6 +55,7 @@ export function createProgram(): Command {
registerZaiAuthCommand(program); registerZaiAuthCommand(program);
registerAnthropicAuthCommand(program); registerAnthropicAuthCommand(program);
registerSkillsCommand(program); registerSkillsCommand(program);
registerBackupCommand(program);
return program; return program;
} }
+1 -1
View File
@@ -1,3 +1,3 @@
export { loadConfig, deepMerge } from './loader.js'; export { loadConfig, deepMerge } from './loader.js';
export { persistConfig } from './persistence.js'; export { persistConfig } from './persistence.js';
export { configSchema, MODEL_PROVIDERS, type ModelProvider, type Config, type TelegramConfig, type ModelConfig, type CronJobConfig, type AgentsConfig, type CompactionConfig, type ToolProfile, type ToolOverrideConfig, type ToolsConfig, type SandboxConfig, type AgentConfigEntry, type RoutingConfig, type ServerConfig } from './schema.js'; export { configSchema, MODEL_PROVIDERS, type ModelProvider, type Config, type TelegramConfig, type ModelConfig, type CronJobConfig, type AgentsConfig, type CompactionConfig, type ToolProfile, type ToolOverrideConfig, type ToolsConfig, type SandboxConfig, type AgentConfigEntry, type RoutingConfig, type ServerConfig, type BackupConfig } from './schema.js';
+48
View File
@@ -196,6 +196,54 @@ describe('configSchema — server', () => {
}); });
}); });
describe('configSchema — backup', () => {
const minimalConfig = {
telegram: { bot_token: 'test', allowed_chat_ids: [1] },
models: { default: { provider: 'anthropic', model: 'claude-3' } },
};
it('defaults backup settings', () => {
const result = configSchema.parse(minimalConfig);
expect(result.backup.enabled).toBe(false);
expect(result.backup.interval).toBe('24h');
expect(result.backup.include_vectors).toBe(true);
expect(result.backup.minio.enabled).toBe(false);
expect(result.backup.minio.prefix).toBe('flynn');
expect(result.backup.minio.secure).toBe(true);
});
it('accepts custom backup settings', () => {
const result = configSchema.parse({
...minimalConfig,
backup: {
enabled: true,
interval: '12h',
local_dir: '/tmp/flynn-backups',
include_vectors: false,
minio: {
enabled: true,
endpoint: 'localhost:9000',
access_key: 'key',
secret_key: 'secret',
bucket: 'flynn-backups',
prefix: 'daily',
secure: false,
},
},
});
expect(result.backup.enabled).toBe(true);
expect(result.backup.interval).toBe('12h');
expect(result.backup.local_dir).toBe('/tmp/flynn-backups');
expect(result.backup.include_vectors).toBe(false);
expect(result.backup.minio.enabled).toBe(true);
expect(result.backup.minio.endpoint).toBe('localhost:9000');
expect(result.backup.minio.bucket).toBe('flynn-backups');
expect(result.backup.minio.prefix).toBe('daily');
expect(result.backup.minio.secure).toBe(false);
});
});
describe('configSchema — agent_configs', () => { describe('configSchema — agent_configs', () => {
const minimalConfig = { const minimalConfig = {
telegram: { bot_token: 'test', allowed_chat_ids: [1] }, telegram: { bot_token: 'test', allowed_chat_ids: [1] },
+18
View File
@@ -672,6 +672,22 @@ const sessionsSchema = z.object({
ttl: z.string().default('30d'), ttl: z.string().default('30d'),
}).default({}); }).default({});
const backupSchema = z.object({
enabled: z.boolean().default(false),
interval: z.string().default('24h'),
local_dir: z.string().default('~/.local/share/flynn/backups'),
include_vectors: z.boolean().default(true),
minio: z.object({
enabled: z.boolean().default(false),
endpoint: z.string().optional(),
access_key: z.string().optional(),
secret_key: z.string().optional(),
bucket: z.string().optional(),
prefix: z.string().default('flynn'),
secure: z.boolean().default(true),
}).default({}),
}).default({});
const historyIndexSchema = z.object({ const historyIndexSchema = z.object({
enabled: z.boolean().default(false), enabled: z.boolean().default(false),
max_keywords: z.number().min(1).max(20).default(8), max_keywords: z.number().min(1).max(20).default(8),
@@ -736,6 +752,7 @@ export const configSchema = z.object({
routing_policy: routingPolicySchema, routing_policy: routingPolicySchema,
history_index: historyIndexSchema, history_index: historyIndexSchema,
sessions: sessionsSchema, sessions: sessionsSchema,
backup: backupSchema,
pairing: pairingSchema, pairing: pairingSchema,
}); });
@@ -777,6 +794,7 @@ export type RoutingPolicyConfig = z.infer<typeof routingPolicySchema>;
export type HistoryIndexConfig = z.infer<typeof historyIndexSchema>; export type HistoryIndexConfig = z.infer<typeof historyIndexSchema>;
export type ServerConfig = z.infer<typeof serverSchema>; export type ServerConfig = z.infer<typeof serverSchema>;
export type SessionsConfig = z.infer<typeof sessionsSchema>; export type SessionsConfig = z.infer<typeof sessionsSchema>;
export type BackupConfig = z.infer<typeof backupSchema>;
export type ThinkingConfig = z.infer<typeof thinkingSchema>; export type ThinkingConfig = z.infer<typeof thinkingSchema>;
export type HeartbeatConfig = z.infer<typeof heartbeatSchema>; export type HeartbeatConfig = z.infer<typeof heartbeatSchema>;
export type HeartbeatCheck = z.infer<typeof heartbeatCheckSchema>; export type HeartbeatCheck = z.infer<typeof heartbeatCheckSchema>;
+34
View File
@@ -33,6 +33,7 @@ import type { McpManager } from '../mcp/index.js';
import type { SkillRegistry, SkillInstaller } from '../skills/index.js'; import type { SkillRegistry, SkillInstaller } from '../skills/index.js';
import type { GatewayServer } from '../gateway/index.js'; import type { GatewayServer } from '../gateway/index.js';
import { AuditLogger, initAuditLogger } from '../audit/index.js'; import { AuditLogger, initAuditLogger } from '../audit/index.js';
import { runBackupSnapshot } from '../backup/index.js';
export interface DaemonContext { export interface DaemonContext {
config: Config; config: Config;
@@ -103,6 +104,39 @@ export async function startDaemon(config: Config, options?: StartDaemonOptions):
lifecycle.onShutdown(async () => { clearInterval(pruneInterval); }); lifecycle.onShutdown(async () => { clearInterval(pruneInterval); });
} }
if (config.backup.enabled) {
const backupIntervalMs = parseDuration(config.backup.interval);
if (!backupIntervalMs) {
console.warn(`Backup enabled but interval is invalid: ${config.backup.interval}`);
} else {
let backupRunning = false;
const runScheduledBackup = async (): Promise<void> => {
if (backupRunning) {
return;
}
backupRunning = true;
try {
const result = await runBackupSnapshot({
dataDir,
backupConfig: config.backup,
});
console.log(`Backup completed: ${result.archivePath}${result.uploaded && result.remotePath ? ` -> ${result.remotePath}` : ''}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Backup failed: ${message}`);
} finally {
backupRunning = false;
}
};
const backupInterval = setInterval(() => {
void runScheduledBackup();
}, backupIntervalMs);
lifecycle.onShutdown(async () => { clearInterval(backupInterval); });
console.log(`Backup scheduler enabled (${config.backup.interval})`);
}
}
// ── Core Services ── // ── Core Services ──
const hookEngine = new HookEngine(config.hooks); const hookEngine = new HookEngine(config.hooks);
const { toolRegistry, toolExecutor, browserManager } = initTools({ config, lifecycle, hookEngine }); const { toolRegistry, toolExecutor, browserManager } = initTools({ config, lifecycle, hookEngine });