From 47af4c8f0a97b31176de005f7d2a5e08236ffa50 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Mon, 9 Feb 2026 18:51:14 +0000 Subject: [PATCH 1/9] Add backup types and config parsing Add BackupConfig, BackupResult, and BackupEntry interfaces. Parse [backup] section from TOML config with defaults: - enabled: false - schedule_hour: 3 (3am) - retain_days: 7 - local_dir: /var/backups/trafic Co-authored-by: Claude --- packages/trafic-agent/src/types.ts | 36 +++++++++++++++++++++++ packages/trafic-agent/src/utils/config.ts | 25 +++++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/trafic-agent/src/types.ts b/packages/trafic-agent/src/types.ts index cf887bc..7606641 100644 --- a/packages/trafic-agent/src/types.ts +++ b/packages/trafic-agent/src/types.ts @@ -18,6 +18,42 @@ export interface AgentConfig { idleCheckInterval: string; /** Auth configuration */ auth: AuthConfig; + /** Backup configuration */ + backup: BackupConfig; +} + +/** + * Backup configuration + */ +export interface BackupConfig { + /** Enable scheduled backups (default: false) */ + enabled: boolean; + /** Cron-like schedule (e.g., "0 3 * * *") — only the hour is used for the simple scheduler */ + scheduleHour: number; + /** Number of days to retain local backups (default: 7) */ + retainDays: number; + /** Local directory for backups (default: /var/backups/trafic) */ + localDir: string; +} + +/** + * Result of a single project backup + */ +export interface BackupResult { + project: string; + success: boolean; + file?: string; + error?: string; +} + +/** + * Metadata for an existing backup entry + */ +export interface BackupEntry { + project: string; + date: string; + file: string; + sizeBytes: number; } /** diff --git a/packages/trafic-agent/src/utils/config.ts b/packages/trafic-agent/src/utils/config.ts index 1f517fe..e9773c7 100644 --- a/packages/trafic-agent/src/utils/config.ts +++ b/packages/trafic-agent/src/utils/config.ts @@ -1,6 +1,6 @@ import { readFileSync, existsSync } from "node:fs"; import { parse } from "smol-toml"; -import type { AgentConfig, AuthConfig, AuthRule } from "../types.js"; +import type { AgentConfig, AuthConfig, AuthRule, BackupConfig } from "../types.js"; const DEFAULT_CONFIG_PATHS = [ "/etc/trafic/config.toml", @@ -8,6 +8,13 @@ const DEFAULT_CONFIG_PATHS = [ "./trafic.toml", ]; +const DEFAULT_BACKUP_CONFIG: BackupConfig = { + enabled: false, + scheduleHour: 3, + retainDays: 7, + localDir: "/var/backups/trafic", +}; + const DEFAULT_CONFIG: AgentConfig = { tld: "", port: 9876, @@ -23,6 +30,7 @@ const DEFAULT_CONFIG: AgentConfig = { basicAuth: [], rules: [], }, + backup: { ...DEFAULT_BACKUP_CONFIG }, }; /** @@ -60,6 +68,7 @@ export function loadConfig(configPath?: string): AgentConfig { // Merge with defaults const auth = mergeAuthConfig(parsed.auth as Record); + const backup = mergeBackupConfig(parsed.backup as Record); return { tld: (parsed.tld as string) ?? DEFAULT_CONFIG.tld, @@ -74,6 +83,7 @@ export function loadConfig(configPath?: string): AgentConfig { (parsed.idle_check_interval as string) ?? DEFAULT_CONFIG.idleCheckInterval, auth, + backup, }; } @@ -101,6 +111,19 @@ function mergeAuthConfig(raw?: Record): AuthConfig { }; } +function mergeBackupConfig(raw?: Record): BackupConfig { + if (!raw) return { ...DEFAULT_BACKUP_CONFIG }; + + return { + enabled: (raw.enabled as boolean) ?? DEFAULT_BACKUP_CONFIG.enabled, + scheduleHour: + (raw.schedule_hour as number) ?? DEFAULT_BACKUP_CONFIG.scheduleHour, + retainDays: + (raw.retain_days as number) ?? DEFAULT_BACKUP_CONFIG.retainDays, + localDir: (raw.local_dir as string) ?? DEFAULT_BACKUP_CONFIG.localDir, + }; +} + /** * Validate configuration */ From 0d7fe4ad798c205382df5f427f596c14c7b6f75b Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Mon, 9 Feb 2026 18:51:55 +0000 Subject: [PATCH 2/9] Add core backup logic Implement backup.ts with: - backupProjectDb: export a project DB via ddev export-db - backupAgentData: copy agent SQLite DB and config - runBackup: backup all or a specific project - listBackups: list available backups by date - cleanOldBackups: retention cleanup by date - restoreProjectDb: restore via ddev import-db - findBackup: find latest or date-specific backup file Auto-starts stopped projects before export/import. Backups stored as //.sql.gz. Co-authored-by: Claude --- packages/trafic-agent/src/tasks/backup.ts | 270 ++++++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 packages/trafic-agent/src/tasks/backup.ts diff --git a/packages/trafic-agent/src/tasks/backup.ts b/packages/trafic-agent/src/tasks/backup.ts new file mode 100644 index 0000000..49cebd5 --- /dev/null +++ b/packages/trafic-agent/src/tasks/backup.ts @@ -0,0 +1,270 @@ +import { execSync } from "node:child_process"; +import { mkdirSync, existsSync, readdirSync, statSync, rmSync, copyFileSync } from "node:fs"; +import { join } from "node:path"; +import { loadProjectList, startProject, getProjectInfo } from "../utils/ddev.js"; +import type { AgentConfig, BackupConfig, BackupResult, BackupEntry } from "../types.js"; + +/** + * Format a date as YYYY-MM-DD + */ +function formatDate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +/** + * Ensure backup directory exists for a given date + */ +function ensureDateDir(localDir: string, date: string): string { + const dir = join(localDir, date); + mkdirSync(dir, { recursive: true }); + return dir; +} + +/** + * Export a single project's database using ddev export-db + */ +export function backupProjectDb( + projectName: string, + projectDir: string, + outputDir: string, +): BackupResult { + const outputFile = join(outputDir, `${projectName}.sql.gz`); + + try { + // Check if the project is running, start it if needed + const info = getProjectInfo(projectName); + const wasStarted = info?.status !== "running"; + + if (wasStarted) { + console.log(` Starting ${projectName} for backup...`); + const started = startProject(projectName); + if (!started) { + return { project: projectName, success: false, error: "Failed to start project" }; + } + } + + // Export database + execSync(`ddev export-db --gzip --file="${outputFile}"`, { + cwd: projectDir, + encoding: "utf-8", + timeout: 300_000, // 5 minutes + stdio: "pipe", + }); + + return { project: projectName, success: true, file: outputFile }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { project: projectName, success: false, error: message }; + } +} + +/** + * Backup the agent's SQLite database and config file + */ +export function backupAgentData(config: AgentConfig, outputDir: string): void { + // Backup SQLite database + if (existsSync(config.dbPath)) { + const dest = join(outputDir, "agent-db.sqlite"); + copyFileSync(config.dbPath, dest); + console.log(` Agent DB → ${dest}`); + } + + // Backup config file (check common paths) + const configPaths = ["/etc/trafic/config.toml", "./config.toml", "./trafic.toml"]; + for (const configPath of configPaths) { + if (existsSync(configPath)) { + const dest = join(outputDir, "config.toml"); + copyFileSync(configPath, dest); + console.log(` Config → ${dest}`); + break; + } + } +} + +/** + * Run backup for all projects (or a specific one) + */ +export function runBackup( + config: AgentConfig, + projectName?: string, +): BackupResult[] { + const date = formatDate(new Date()); + const outputDir = ensureDateDir(config.backup.localDir, date); + const results: BackupResult[] = []; + + console.log(`Backup directory: ${outputDir}`); + + // Load all projects + const projects = loadProjectList(config.projectListPath); + + if (projectName) { + // Backup a specific project + const projectDir = projects.get(projectName); + if (!projectDir) { + results.push({ project: projectName, success: false, error: "Project not found" }); + return results; + } + + console.log(`Backing up: ${projectName}`); + results.push(backupProjectDb(projectName, projectDir, outputDir)); + } else { + // Backup all projects + console.log(`Backing up ${projects.size} projects...`); + + for (const [name, projectDir] of projects) { + console.log(`Backing up: ${name}`); + results.push(backupProjectDb(name, projectDir, outputDir)); + } + + // Also backup agent data when doing a full backup + backupAgentData(config, outputDir); + } + + // Print summary + const succeeded = results.filter((r) => r.success).length; + const failed = results.filter((r) => !r.success).length; + console.log(`\nBackup complete: ${succeeded} succeeded, ${failed} failed`); + + for (const result of results.filter((r) => !r.success)) { + console.error(` ✗ ${result.project}: ${result.error}`); + } + + return results; +} + +/** + * List all available backups + */ +export function listBackups(backupConfig: BackupConfig): BackupEntry[] { + const entries: BackupEntry[] = []; + + if (!existsSync(backupConfig.localDir)) { + return entries; + } + + const dateDirs = readdirSync(backupConfig.localDir) + .filter((name) => /^\d{4}-\d{2}-\d{2}$/.test(name)) + .sort() + .reverse(); + + for (const date of dateDirs) { + const dateDir = join(backupConfig.localDir, date); + const stat = statSync(dateDir); + if (!stat.isDirectory()) continue; + + const files = readdirSync(dateDir).filter( + (f) => f.endsWith(".sql.gz") || f.endsWith(".sqlite") || f.endsWith(".toml"), + ); + + for (const file of files) { + const filePath = join(dateDir, file); + const fileStat = statSync(filePath); + + // Derive project name from filename + const project = file.replace(/\.sql\.gz$/, "").replace(/\.sqlite$/, "").replace(/\.toml$/, ""); + + entries.push({ + project, + date, + file: filePath, + sizeBytes: fileStat.size, + }); + } + } + + return entries; +} + +/** + * Clean up old backups beyond the retention period + */ +export function cleanOldBackups(backupConfig: BackupConfig): number { + if (!existsSync(backupConfig.localDir)) return 0; + + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - backupConfig.retainDays); + const cutoffDate = formatDate(cutoff); + + let removed = 0; + const dateDirs = readdirSync(backupConfig.localDir).filter((name) => + /^\d{4}-\d{2}-\d{2}$/.test(name), + ); + + for (const date of dateDirs) { + if (date < cutoffDate) { + const dirPath = join(backupConfig.localDir, date); + rmSync(dirPath, { recursive: true, force: true }); + console.log(`Removed old backup: ${date}`); + removed++; + } + } + + return removed; +} + +/** + * Restore a project database from backup + */ +export function restoreProjectDb( + projectName: string, + projectDir: string, + backupFile: string, +): boolean { + try { + // Ensure project is running + const info = getProjectInfo(projectName); + if (info?.status !== "running") { + console.log(`Starting ${projectName} for restore...`); + const started = startProject(projectName); + if (!started) { + console.error(`Failed to start ${projectName}`); + return false; + } + } + + // Import database + execSync(`ddev import-db --file="${backupFile}"`, { + cwd: projectDir, + encoding: "utf-8", + timeout: 300_000, // 5 minutes + stdio: "pipe", + }); + + console.log(`Restored ${projectName} from ${backupFile}`); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Failed to restore ${projectName}: ${message}`); + return false; + } +} + +/** + * Find a backup file for a project on a given date + */ +export function findBackup( + backupConfig: BackupConfig, + projectName: string, + date?: string, +): string | undefined { + if (!existsSync(backupConfig.localDir)) return undefined; + + // If a date is specified, look in that directory + if (date) { + const file = join(backupConfig.localDir, date, `${projectName}.sql.gz`); + return existsSync(file) ? file : undefined; + } + + // Otherwise, find the most recent backup + const dateDirs = readdirSync(backupConfig.localDir) + .filter((name) => /^\d{4}-\d{2}-\d{2}$/.test(name)) + .sort() + .reverse(); + + for (const dir of dateDirs) { + const file = join(backupConfig.localDir, dir, `${projectName}.sql.gz`); + if (existsSync(file)) return file; + } + + return undefined; +} From 7f5bdab7e9dde7569ccaa5550ba49eda3b1421a3 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Mon, 9 Feb 2026 18:52:53 +0000 Subject: [PATCH 3/9] Wire backup and restore commands into agent CLI Add backup command with --name, --list, --clean options. Add restore command with --name, --date, --file options. Add backup-scheduler.ts for daily scheduled backups. Start backup scheduler alongside idle scheduler when backup.enabled is true. Co-authored-by: Claude --- packages/trafic-agent/src/cli.ts | 126 +++++++++++++++++- .../src/tasks/backup-scheduler.ts | 42 ++++++ 2 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 packages/trafic-agent/src/tasks/backup-scheduler.ts diff --git a/packages/trafic-agent/src/cli.ts b/packages/trafic-agent/src/cli.ts index c0f495f..1ae7c25 100644 --- a/packages/trafic-agent/src/cli.ts +++ b/packages/trafic-agent/src/cli.ts @@ -4,6 +4,9 @@ import { parseArgs } from "node:util"; import { loadConfig, validateConfig } from "./utils/config.js"; import { startServer } from "./server.js"; import { startIdleScheduler } from "./tasks/stop-idle.js"; +import { startBackupScheduler } from "./tasks/backup-scheduler.js"; +import { runBackup, listBackups, restoreProjectDb, findBackup, cleanOldBackups } from "./tasks/backup.js"; +import { loadProjectList } from "./utils/ddev.js"; import { closeDb } from "./utils/db.js"; import { setup, audit } from "./setup/index.js"; @@ -17,6 +20,8 @@ Usage: Commands: start Start the agent server + backup Backup project databases + restore Restore a project database from backup setup Setup a new server (Docker, DDEV, hardening) audit Run security audit checks version Show version @@ -26,6 +31,18 @@ Start options: -c, --config Path to config file (default: /etc/trafic/config.toml) -p, --port Override port from config +Backup options: + -c, --config Path to config file + --name Backup a specific project (default: all) + --list List available backups + --clean Clean old backups beyond retention period + +Restore options: + -c, --config Path to config file + --name Project name to restore (required) + --date Restore from a specific date (default: latest) + --file Restore from a specific backup file + Setup options: --tld TLD for DDEV projects (required) --email Email for Let's Encrypt certificates @@ -45,6 +62,18 @@ Examples: sudo trafic-agent setup --tld=previews.example.com --email=admin@example.com sudo trafic-agent setup --tld=previews.example.com --no-hardening --dry-run + # Backup all projects + trafic-agent backup + trafic-agent backup --name my-app + + # List and clean backups + trafic-agent backup --list + trafic-agent backup --clean + + # Restore a project + trafic-agent restore --name my-app + trafic-agent restore --name my-app --date 2026-02-07 + # Run security audit trafic-agent audit `; @@ -86,9 +115,13 @@ async function runStart(values: Record): Promise { process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); - // Start server and scheduler + // Start server and schedulers startServer(config); startIdleScheduler(config); + + if (config.backup.enabled) { + startBackupScheduler(config); + } } async function runSetup(values: Record): Promise { @@ -113,6 +146,80 @@ async function runSetup(values: Record): Promise { }); } +function runBackupCommand(values: Record): void { + const config = loadConfig(values.config as string | undefined); + + if (values.list) { + const entries = listBackups(config.backup); + if (entries.length === 0) { + console.log("No backups found."); + return; + } + + // Group by date + let currentDate = ""; + for (const entry of entries) { + if (entry.date !== currentDate) { + currentDate = entry.date; + console.log(`\n${currentDate}:`); + } + const sizeMb = (entry.sizeBytes / 1024 / 1024).toFixed(1); + console.log(` ${entry.project} (${sizeMb} MB)`); + } + return; + } + + if (values.clean) { + const removed = cleanOldBackups(config.backup); + console.log(`Cleaned ${removed} old backup(s) (retention: ${config.backup.retainDays} days)`); + return; + } + + const results = runBackup(config, values.name as string | undefined); + const failed = results.filter((r) => !r.success); + if (failed.length > 0) { + process.exit(1); + } +} + +function runRestoreCommand(values: Record): void { + const config = loadConfig(values.config as string | undefined); + const name = values.name as string | undefined; + + if (!name) { + console.error("Error: --name is required for restore"); + process.exit(1); + } + + // Find the backup file + let backupFile = values.file as string | undefined; + + if (!backupFile) { + backupFile = findBackup(config.backup, name, values.date as string | undefined); + if (!backupFile) { + const dateHint = values.date ? ` on ${values.date}` : ""; + console.error(`No backup found for ${name}${dateHint}`); + process.exit(1); + } + } + + // Find project directory + const projects = loadProjectList(config.projectListPath); + const projectDir = projects.get(name); + + if (!projectDir) { + console.error(`Project ${name} not found in project list`); + process.exit(1); + } + + console.log(`Restoring ${name} from ${backupFile}`); + const success = restoreProjectDb(name, projectDir, backupFile); + + if (!success) { + process.exit(1); + } +} + async function main(): Promise { const { values, positionals } = parseArgs({ allowPositionals: true, @@ -132,6 +239,15 @@ async function main(): Promise { "no-ddev": { type: "boolean" }, "ssh-users": { type: "string" }, "dry-run": { type: "boolean" }, + + // Backup options + name: { type: "string" }, + list: { type: "boolean" }, + clean: { type: "boolean" }, + + // Restore options + date: { type: "string" }, + file: { type: "string" }, }, }); @@ -159,6 +275,14 @@ async function main(): Promise { await runSetup(values); break; + case "backup": + runBackupCommand(values); + break; + + case "restore": + runRestoreCommand(values); + break; + case "audit": await audit(); break; diff --git a/packages/trafic-agent/src/tasks/backup-scheduler.ts b/packages/trafic-agent/src/tasks/backup-scheduler.ts new file mode 100644 index 0000000..836ad12 --- /dev/null +++ b/packages/trafic-agent/src/tasks/backup-scheduler.ts @@ -0,0 +1,42 @@ +import { runBackup, cleanOldBackups } from "./backup.js"; +import type { AgentConfig } from "../types.js"; + +/** + * Simple daily backup scheduler. + * Checks every 30 minutes if we've passed the scheduled hour and runs once per day. + */ +export function startBackupScheduler(config: AgentConfig): NodeJS.Timeout { + const { scheduleHour } = config.backup; + let lastRunDate = ""; + + console.log( + `Backup scheduler started: daily at ${String(scheduleHour).padStart(2, "0")}:00, ` + + `retention ${config.backup.retainDays} days, ` + + `dir ${config.backup.localDir}`, + ); + + const check = (): void => { + const now = new Date(); + const todayDate = now.toISOString().slice(0, 10); + const currentHour = now.getHours(); + + // Run once per day, after the scheduled hour + if (currentHour >= scheduleHour && lastRunDate !== todayDate) { + lastRunDate = todayDate; + + console.log(`[backup] Starting scheduled backup...`); + try { + runBackup(config); + cleanOldBackups(config.backup); + } catch (error) { + console.error("[backup] Scheduled backup failed:", error); + } + } + }; + + // Check immediately on start + check(); + + // Check every 30 minutes + return setInterval(check, 30 * 60 * 1000); +} From 43bff59e5fdf785bb9336d2fee5436af6af76ffb Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Mon, 9 Feb 2026 18:53:45 +0000 Subject: [PATCH 4/9] Add backup before destroy in CLI Run trafic-agent backup --name via SSH before deleting a project. Failure is non-blocking (warns and continues). Add --no-backup flag to skip the backup step. Co-authored-by: Claude --- packages/trafic-cli/src/cli.ts | 3 +++ packages/trafic-cli/src/commands/destroy.ts | 19 ++++++++++++++++--- packages/trafic-cli/src/types.ts | 2 ++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/trafic-cli/src/cli.ts b/packages/trafic-cli/src/cli.ts index 71b9325..0ea5228 100644 --- a/packages/trafic-cli/src/cli.ts +++ b/packages/trafic-cli/src/cli.ts @@ -38,6 +38,7 @@ const HELP = ` --name DDEV project name (required) --preview MR/PR number (computes the name) --projects-dir Projects directory (default: ~/www) + --no-backup Skip database backup before destroy Other: --version Show version @@ -115,6 +116,7 @@ function main(): void { "after-script": { type: "string" }, "projects-dir": { type: "string", default: "~/www" }, "no-start": { type: "boolean", default: false }, + "no-backup": { type: "boolean", default: false }, timeout: { type: "string", default: "10m" }, help: { type: "boolean", short: "h" }, version: { type: "boolean", short: "v" }, @@ -197,6 +199,7 @@ function main(): void { name: values.name, preview: values.preview, projectsDir: values["projects-dir"]!, + noBackup: values["no-backup"]!, }; destroy(destroyOptions).catch((err: Error) => { diff --git a/packages/trafic-cli/src/commands/destroy.ts b/packages/trafic-cli/src/commands/destroy.ts index 6bd18e4..f002f52 100644 --- a/packages/trafic-cli/src/commands/destroy.ts +++ b/packages/trafic-cli/src/commands/destroy.ts @@ -7,8 +7,9 @@ import { resolveProjectName } from "../types.js"; * Destroy a DDEV project on a remote server. * * Steps: - * 1. Stop and delete the DDEV project - * 2. Remove the project directory + * 1. Backup the database (unless --no-backup) + * 2. Stop and delete the DDEV project + * 3. Remove the project directory */ export async function destroy(options: DestroyOptions): Promise { resetSteps(); @@ -28,7 +29,19 @@ export async function destroy(options: DestroyOptions): Promise { return; } - // 1. Stop and delete DDEV project + // 1. Backup database before destroy + if (!options.noBackup) { + step("Backup database before destroy"); + + try { + await ssh.exec(options, `trafic-agent backup --name ${projectName}`); + } catch (err) { + warn("Backup failed — continuing with destroy"); + info(String(err)); + } + } + + // 2. Stop and delete DDEV project step("Delete DDEV project"); try { diff --git a/packages/trafic-cli/src/types.ts b/packages/trafic-cli/src/types.ts index b92a32b..38a3ed1 100644 --- a/packages/trafic-cli/src/types.ts +++ b/packages/trafic-cli/src/types.ts @@ -50,6 +50,8 @@ export interface DestroyOptions extends SSHOptions { preview?: string; /** Projects directory on the server (default: "~/www") */ projectsDir: string; + /** Skip backup before destroy (default: false) */ + noBackup: boolean; } /** From cf5705f941767eabd642532f77642df476a92724 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Mon, 9 Feb 2026 18:55:05 +0000 Subject: [PATCH 5/9] Add backup tests and update destroy tests Add 13 tests for backup logic (listBackups, cleanOldBackups, findBackup). Update destroy tests for the new backup-before-destroy step: - Expect 3 exec calls (backup + delete + rm) - Add test for --no-backup flag - Add test for backup failure being non-blocking Co-authored-by: Claude --- packages/trafic-agent/test/backup.test.ts | 203 ++++++++++++++++++++++ packages/trafic-agent/test/config.test.ts | 6 + packages/trafic-cli/test/destroy.test.ts | 35 +++- 3 files changed, 241 insertions(+), 3 deletions(-) create mode 100644 packages/trafic-agent/test/backup.test.ts diff --git a/packages/trafic-agent/test/backup.test.ts b/packages/trafic-agent/test/backup.test.ts new file mode 100644 index 0000000..4b2482e --- /dev/null +++ b/packages/trafic-agent/test/backup.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdirSync, writeFileSync, existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { listBackups, cleanOldBackups, findBackup } from "../src/tasks/backup.js"; +import type { BackupConfig } from "../src/types.js"; + +/** + * Create a temporary directory for test backups + */ +function createTempDir(): string { + return mkdtempSync(join(tmpdir(), "trafic-backup-test-")); +} + +/** + * Create a fake backup file in the given date directory + */ +function createFakeBackup(baseDir: string, date: string, project: string, content = "fake sql"): void { + const dir = join(baseDir, date); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${project}.sql.gz`), content); +} + +describe("listBackups", () => { + let tempDir: string; + let config: BackupConfig; + + beforeEach(() => { + tempDir = createTempDir(); + config = { + enabled: true, + scheduleHour: 3, + retainDays: 7, + localDir: tempDir, + }; + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("returns empty array when no backups exist", () => { + const entries = listBackups(config); + expect(entries).toEqual([]); + }); + + it("returns empty array when backup dir does not exist", () => { + const entries = listBackups({ ...config, localDir: "/nonexistent" }); + expect(entries).toEqual([]); + }); + + it("lists backups sorted by date (newest first)", () => { + createFakeBackup(tempDir, "2026-02-01", "app-a"); + createFakeBackup(tempDir, "2026-02-03", "app-b"); + createFakeBackup(tempDir, "2026-02-02", "app-a"); + + const entries = listBackups(config); + + expect(entries.length).toBe(3); + expect(entries[0].date).toBe("2026-02-03"); + expect(entries[1].date).toBe("2026-02-02"); + expect(entries[2].date).toBe("2026-02-01"); + }); + + it("includes correct metadata", () => { + createFakeBackup(tempDir, "2026-02-09", "my-project", "some content here"); + + const entries = listBackups(config); + + expect(entries.length).toBe(1); + expect(entries[0].project).toBe("my-project"); + expect(entries[0].date).toBe("2026-02-09"); + expect(entries[0].file).toBe(join(tempDir, "2026-02-09", "my-project.sql.gz")); + expect(entries[0].sizeBytes).toBeGreaterThan(0); + }); + + it("ignores non-date directories", () => { + mkdirSync(join(tempDir, "random-dir"), { recursive: true }); + mkdirSync(join(tempDir, "not-a-date"), { recursive: true }); + createFakeBackup(tempDir, "2026-02-09", "app"); + + const entries = listBackups(config); + expect(entries.length).toBe(1); + }); +}); + +describe("cleanOldBackups", () => { + let tempDir: string; + let config: BackupConfig; + + beforeEach(() => { + tempDir = createTempDir(); + config = { + enabled: true, + scheduleHour: 3, + retainDays: 3, + localDir: tempDir, + }; + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("removes backups older than retention period", () => { + // Create backups: today, 2 days ago, 5 days ago, 10 days ago + const today = new Date(); + const twoDaysAgo = new Date(today); + twoDaysAgo.setDate(today.getDate() - 2); + const fiveDaysAgo = new Date(today); + fiveDaysAgo.setDate(today.getDate() - 5); + const tenDaysAgo = new Date(today); + tenDaysAgo.setDate(today.getDate() - 10); + + const fmt = (d: Date) => d.toISOString().slice(0, 10); + + createFakeBackup(tempDir, fmt(today), "app"); + createFakeBackup(tempDir, fmt(twoDaysAgo), "app"); + createFakeBackup(tempDir, fmt(fiveDaysAgo), "app"); + createFakeBackup(tempDir, fmt(tenDaysAgo), "app"); + + const removed = cleanOldBackups(config); + + // 5 and 10 days ago should be removed (older than 3 days) + expect(removed).toBe(2); + + // Verify remaining directories + const remaining = readdirSync(tempDir); + expect(remaining).toContain(fmt(today)); + expect(remaining).toContain(fmt(twoDaysAgo)); + expect(remaining).not.toContain(fmt(fiveDaysAgo)); + expect(remaining).not.toContain(fmt(tenDaysAgo)); + }); + + it("does nothing when backup dir does not exist", () => { + const removed = cleanOldBackups({ ...config, localDir: "/nonexistent" }); + expect(removed).toBe(0); + }); + + it("does nothing when all backups are recent", () => { + const today = new Date().toISOString().slice(0, 10); + createFakeBackup(tempDir, today, "app"); + + const removed = cleanOldBackups(config); + expect(removed).toBe(0); + }); +}); + +describe("findBackup", () => { + let tempDir: string; + let config: BackupConfig; + + beforeEach(() => { + tempDir = createTempDir(); + config = { + enabled: true, + scheduleHour: 3, + retainDays: 7, + localDir: tempDir, + }; + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("finds the most recent backup when no date is specified", () => { + createFakeBackup(tempDir, "2026-02-07", "my-app"); + createFakeBackup(tempDir, "2026-02-09", "my-app"); + createFakeBackup(tempDir, "2026-02-08", "my-app"); + + const file = findBackup(config, "my-app"); + expect(file).toBe(join(tempDir, "2026-02-09", "my-app.sql.gz")); + }); + + it("finds a backup for a specific date", () => { + createFakeBackup(tempDir, "2026-02-07", "my-app"); + createFakeBackup(tempDir, "2026-02-09", "my-app"); + + const file = findBackup(config, "my-app", "2026-02-07"); + expect(file).toBe(join(tempDir, "2026-02-07", "my-app.sql.gz")); + }); + + it("returns undefined when no backup exists for the project", () => { + createFakeBackup(tempDir, "2026-02-09", "other-app"); + + const file = findBackup(config, "my-app"); + expect(file).toBeUndefined(); + }); + + it("returns undefined for a specific date with no backup", () => { + createFakeBackup(tempDir, "2026-02-09", "my-app"); + + const file = findBackup(config, "my-app", "2026-02-08"); + expect(file).toBeUndefined(); + }); + + it("returns undefined when backup dir does not exist", () => { + const file = findBackup({ ...config, localDir: "/nonexistent" }, "my-app"); + expect(file).toBeUndefined(); + }); +}); diff --git a/packages/trafic-agent/test/config.test.ts b/packages/trafic-agent/test/config.test.ts index c46cda8..81c7e23 100644 --- a/packages/trafic-agent/test/config.test.ts +++ b/packages/trafic-agent/test/config.test.ts @@ -44,6 +44,12 @@ describe("validateConfig", () => { basicAuth: [], rules: [], }, + backup: { + enabled: false, + scheduleHour: 3, + retainDays: 7, + localDir: "/var/backups/trafic", + }, }; it("returns no errors for valid config", () => { diff --git a/packages/trafic-cli/test/destroy.test.ts b/packages/trafic-cli/test/destroy.test.ts index a30ce13..9ee43f1 100644 --- a/packages/trafic-cli/test/destroy.test.ts +++ b/packages/trafic-cli/test/destroy.test.ts @@ -23,6 +23,7 @@ const baseOptions: DestroyOptions = { sshOptions: "", name: "my-app", projectsDir: "~/www", + noBackup: false, }; describe("destroy", () => { @@ -44,6 +45,18 @@ describe("destroy", () => { await destroy(baseOptions); + expect(mockedExec).toHaveBeenCalledTimes(3); + const commands = mockedExec.mock.calls.map((c) => c[1]); + expect(commands[0]).toContain("trafic-agent backup --name my-app"); + expect(commands[1]).toContain("ddev delete"); + expect(commands[2]).toContain("rm -rf"); + }); + + it("skips backup when --no-backup is set", async () => { + mockedTest.mockResolvedValue(true); + + await destroy({ ...baseOptions, noBackup: true }); + expect(mockedExec).toHaveBeenCalledTimes(2); const commands = mockedExec.mock.calls.map((c) => c[1]); expect(commands[0]).toContain("ddev delete"); @@ -65,13 +78,29 @@ describe("destroy", () => { it("continues if ddev delete fails", async () => { mockedTest.mockResolvedValue(true); mockedExec + .mockResolvedValueOnce({ stdout: "", stderr: "", exitCode: 0 }) // backup .mockRejectedValueOnce(new Error("ddev delete failed")) // ddev delete .mockResolvedValueOnce({ stdout: "", stderr: "", exitCode: 0 }); // rm -rf await destroy(baseOptions); - // Should still call rm -rf - expect(mockedExec).toHaveBeenCalledTimes(2); - expect(mockedExec.mock.calls[1]![1]).toContain("rm -rf"); + // Should still call rm -rf (backup + delete + rm) + expect(mockedExec).toHaveBeenCalledTimes(3); + expect(mockedExec.mock.calls[2]![1]).toContain("rm -rf"); + }); + + it("continues destroy if backup fails", async () => { + mockedTest.mockResolvedValue(true); + mockedExec + .mockRejectedValueOnce(new Error("backup failed")) // backup + .mockResolvedValueOnce({ stdout: "", stderr: "", exitCode: 0 }) // ddev delete + .mockResolvedValueOnce({ stdout: "", stderr: "", exitCode: 0 }); // rm -rf + + await destroy(baseOptions); + + // Should still delete and rm (backup failure is non-blocking) + expect(mockedExec).toHaveBeenCalledTimes(3); + expect(mockedExec.mock.calls[1]![1]).toContain("ddev delete"); + expect(mockedExec.mock.calls[2]![1]).toContain("rm -rf"); }); }); From 18756488e9c96c13d9d0d9f320c5353ad155d825 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Mon, 9 Feb 2026 18:55:33 +0000 Subject: [PATCH 6/9] Update config example and export backup utilities Add [backup] section to examples/config.toml with documentation. Export backup types and functions from the agent's public API. Co-authored-by: Claude --- examples/config.toml | 14 ++++++++++++++ packages/trafic-agent/src/index.ts | 13 +++++++++++++ 2 files changed, 27 insertions(+) diff --git a/examples/config.toml b/examples/config.toml index 5f619ea..022d976 100644 --- a/examples/config.toml +++ b/examples/config.toml @@ -57,3 +57,17 @@ policy = "basic" match = "api.*" policy = "token" tokens = ["api-specific-token"] + +# Backup configuration +[backup] +# Enable scheduled daily backups (default: false) +enabled = true + +# Hour of day to run backups (0-23, default: 3 = 3am) +schedule_hour = 3 + +# Number of days to keep local backups (default: 7) +retain_days = 7 + +# Directory for local backup storage (default: /var/backups/trafic) +local_dir = "/var/backups/trafic" diff --git a/packages/trafic-agent/src/index.ts b/packages/trafic-agent/src/index.ts index 842001b..0cff493 100644 --- a/packages/trafic-agent/src/index.ts +++ b/packages/trafic-agent/src/index.ts @@ -5,6 +5,9 @@ export type { AuthRule, AuthRequest, AuthResult, + BackupConfig, + BackupResult, + BackupEntry, DdevProject, ProjectRecord, AccessLog, @@ -58,6 +61,16 @@ export type { ProjectConfig } from "./utils/project-config.js"; // Tasks export { stopIdleProjects, startIdleScheduler } from "./tasks/stop-idle.js"; +export { + runBackup, + backupProjectDb, + backupAgentData, + listBackups, + cleanOldBackups, + restoreProjectDb, + findBackup, +} from "./tasks/backup.js"; +export { startBackupScheduler } from "./tasks/backup-scheduler.js"; // Server export { startServer } from "./server.js"; From 45900df1e06d6f7d60db3694a561a9d74cac4082 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Mon, 9 Feb 2026 19:06:38 +0000 Subject: [PATCH 7/9] Trigger CI From bd09af5ba7cf5ab5cf2cc64c4f160fac9a56009e Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Mon, 9 Feb 2026 19:19:59 +0000 Subject: [PATCH 8/9] Stop projects after backup if they were stopped before Use a finally block to ensure projects that were stopped before backup are stopped again after export, whether it succeeds or fails. Prevents daily backups from leaving all projects running. Co-authored-by: Claude --- packages/trafic-agent/src/tasks/backup.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/trafic-agent/src/tasks/backup.ts b/packages/trafic-agent/src/tasks/backup.ts index 49cebd5..5fcfddd 100644 --- a/packages/trafic-agent/src/tasks/backup.ts +++ b/packages/trafic-agent/src/tasks/backup.ts @@ -1,7 +1,7 @@ import { execSync } from "node:child_process"; import { mkdirSync, existsSync, readdirSync, statSync, rmSync, copyFileSync } from "node:fs"; import { join } from "node:path"; -import { loadProjectList, startProject, getProjectInfo } from "../utils/ddev.js"; +import { loadProjectList, startProject, stopProject, getProjectInfo } from "../utils/ddev.js"; import type { AgentConfig, BackupConfig, BackupResult, BackupEntry } from "../types.js"; /** @@ -30,12 +30,12 @@ export function backupProjectDb( ): BackupResult { const outputFile = join(outputDir, `${projectName}.sql.gz`); - try { - // Check if the project is running, start it if needed - const info = getProjectInfo(projectName); - const wasStarted = info?.status !== "running"; + // Check if the project is running, start it if needed + const info = getProjectInfo(projectName); + const wasStopped = info?.status !== "running"; - if (wasStarted) { + try { + if (wasStopped) { console.log(` Starting ${projectName} for backup...`); const started = startProject(projectName); if (!started) { @@ -55,6 +55,12 @@ export function backupProjectDb( } catch (error) { const message = error instanceof Error ? error.message : String(error); return { project: projectName, success: false, error: message }; + } finally { + // Stop the project again if it was stopped before backup + if (wasStopped) { + console.log(` Stopping ${projectName} after backup...`); + stopProject(projectName); + } } } From 46e6165c99395ad097715049dc366a4a1d1e87d2 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Mon, 9 Feb 2026 19:25:37 +0000 Subject: [PATCH 9/9] Backup before stop instead of starting stopped projects Avoid starting all stopped projects during scheduled backups. Instead: - Idle scheduler backs up each project before stopping it - Scheduled daily backup only exports running projects - Manual CLI backup with --name forces start for a specific project - trafic-agent backup (all) skips stopped projects This ensures every project gets a fresh backup before going idle, without the resource cost of starting everything for a full backup. Co-authored-by: Claude --- packages/trafic-agent/src/cli.ts | 9 ++- packages/trafic-agent/src/index.ts | 1 + packages/trafic-agent/src/tasks/backup.ts | 71 ++++++++++++-------- packages/trafic-agent/src/tasks/stop-idle.ts | 18 +++++ 4 files changed, 70 insertions(+), 29 deletions(-) diff --git a/packages/trafic-agent/src/cli.ts b/packages/trafic-agent/src/cli.ts index 1ae7c25..91e1cb3 100644 --- a/packages/trafic-agent/src/cli.ts +++ b/packages/trafic-agent/src/cli.ts @@ -175,8 +175,13 @@ function runBackupCommand(values: Record): void { return; } - const results = runBackup(config, values.name as string | undefined); - const failed = results.filter((r) => !r.success); + const projectName = values.name as string | undefined; + const results = runBackup(config, { + projectName, + // Explicit CLI request: start stopped projects if targeting a specific one + forceStart: !!projectName, + }); + const failed = results.filter((r) => !r.success && !r.error?.includes("skipped")); if (failed.length > 0) { process.exit(1); } diff --git a/packages/trafic-agent/src/index.ts b/packages/trafic-agent/src/index.ts index 0cff493..acac33e 100644 --- a/packages/trafic-agent/src/index.ts +++ b/packages/trafic-agent/src/index.ts @@ -70,6 +70,7 @@ export { restoreProjectDb, findBackup, } from "./tasks/backup.js"; +export type { RunBackupOptions } from "./tasks/backup.js"; export { startBackupScheduler } from "./tasks/backup-scheduler.js"; // Server diff --git a/packages/trafic-agent/src/tasks/backup.ts b/packages/trafic-agent/src/tasks/backup.ts index 5fcfddd..d887c3d 100644 --- a/packages/trafic-agent/src/tasks/backup.ts +++ b/packages/trafic-agent/src/tasks/backup.ts @@ -1,7 +1,7 @@ import { execSync } from "node:child_process"; import { mkdirSync, existsSync, readdirSync, statSync, rmSync, copyFileSync } from "node:fs"; import { join } from "node:path"; -import { loadProjectList, startProject, stopProject, getProjectInfo } from "../utils/ddev.js"; +import { loadProjectList, startProject, getProjectInfo } from "../utils/ddev.js"; import type { AgentConfig, BackupConfig, BackupResult, BackupEntry } from "../types.js"; /** @@ -21,29 +21,33 @@ function ensureDateDir(localDir: string, date: string): string { } /** - * Export a single project's database using ddev export-db + * Export a single project's database using ddev export-db. + * The project must be running. Use `forceStart` to start stopped projects + * (only for explicit manual requests, not for scheduled backups). */ export function backupProjectDb( projectName: string, projectDir: string, outputDir: string, + forceStart = false, ): BackupResult { const outputFile = join(outputDir, `${projectName}.sql.gz`); - // Check if the project is running, start it if needed const info = getProjectInfo(projectName); - const wasStopped = info?.status !== "running"; - try { - if (wasStopped) { - console.log(` Starting ${projectName} for backup...`); - const started = startProject(projectName); - if (!started) { - return { project: projectName, success: false, error: "Failed to start project" }; - } + if (info?.status !== "running") { + if (!forceStart) { + return { project: projectName, success: false, error: "Project is not running (skipped)" }; } - // Export database + console.log(` Starting ${projectName} for backup...`); + const started = startProject(projectName); + if (!started) { + return { project: projectName, success: false, error: "Failed to start project" }; + } + } + + try { execSync(`ddev export-db --gzip --file="${outputFile}"`, { cwd: projectDir, encoding: "utf-8", @@ -55,12 +59,6 @@ export function backupProjectDb( } catch (error) { const message = error instanceof Error ? error.message : String(error); return { project: projectName, success: false, error: message }; - } finally { - // Stop the project again if it was stopped before backup - if (wasStopped) { - console.log(` Stopping ${projectName} after backup...`); - stopProject(projectName); - } } } @@ -88,12 +86,30 @@ export function backupAgentData(config: AgentConfig, outputDir: string): void { } /** - * Run backup for all projects (or a specific one) + * Options for runBackup + */ +export interface RunBackupOptions { + /** Backup a specific project (default: all) */ + projectName?: string; + /** Start stopped projects for backup (default: false, only for explicit CLI requests) */ + forceStart?: boolean; +} + +/** + * Run backup for all projects (or a specific one). + * + * By default, only running projects are backed up. Stopped projects are + * skipped because starting them is resource-intensive. Instead, projects + * are backed up automatically before being stopped by the idle scheduler. + * + * Use `forceStart: true` for explicit manual backup requests + * (e.g., `trafic-agent backup --name my-app`). */ export function runBackup( config: AgentConfig, - projectName?: string, + options: RunBackupOptions = {}, ): BackupResult[] { + const { projectName, forceStart = false } = options; const date = formatDate(new Date()); const outputDir = ensureDateDir(config.backup.localDir, date); const results: BackupResult[] = []; @@ -104,7 +120,7 @@ export function runBackup( const projects = loadProjectList(config.projectListPath); if (projectName) { - // Backup a specific project + // Backup a specific project — forceStart for explicit requests const projectDir = projects.get(projectName); if (!projectDir) { results.push({ project: projectName, success: false, error: "Project not found" }); @@ -112,14 +128,14 @@ export function runBackup( } console.log(`Backing up: ${projectName}`); - results.push(backupProjectDb(projectName, projectDir, outputDir)); + results.push(backupProjectDb(projectName, projectDir, outputDir, forceStart)); } else { - // Backup all projects + // Backup all projects — only running ones unless forceStart console.log(`Backing up ${projects.size} projects...`); for (const [name, projectDir] of projects) { console.log(`Backing up: ${name}`); - results.push(backupProjectDb(name, projectDir, outputDir)); + results.push(backupProjectDb(name, projectDir, outputDir, forceStart)); } // Also backup agent data when doing a full backup @@ -128,10 +144,11 @@ export function runBackup( // Print summary const succeeded = results.filter((r) => r.success).length; - const failed = results.filter((r) => !r.success).length; - console.log(`\nBackup complete: ${succeeded} succeeded, ${failed} failed`); + const skipped = results.filter((r) => !r.success && r.error?.includes("skipped")).length; + const failed = results.filter((r) => !r.success && !r.error?.includes("skipped")).length; + console.log(`\nBackup complete: ${succeeded} succeeded, ${skipped} skipped, ${failed} failed`); - for (const result of results.filter((r) => !r.success)) { + for (const result of results.filter((r) => !r.success && !r.error?.includes("skipped"))) { console.error(` ✗ ${result.project}: ${result.error}`); } diff --git a/packages/trafic-agent/src/tasks/stop-idle.ts b/packages/trafic-agent/src/tasks/stop-idle.ts index 2779d61..ad1076a 100644 --- a/packages/trafic-agent/src/tasks/stop-idle.ts +++ b/packages/trafic-agent/src/tasks/stop-idle.ts @@ -1,7 +1,10 @@ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; import { getIdleProjects, setProjectStatus } from "../utils/db.js"; import { stopProject, getProjectInfo, loadProjectList } from "../utils/ddev.js"; import { parseDuration } from "../utils/config.js"; import { loadProjectConfig, shouldNeverStop, getIdleTimeoutMs } from "../utils/project-config.js"; +import { backupProjectDb } from "./backup.js"; import type { AgentConfig } from "../types.js"; /** @@ -49,6 +52,21 @@ export function stopIdleProjects(config: AgentConfig): void { } console.log(`Stopping idle project: ${project.name}`); + + // Backup database before stopping (project is still running) + if (config.backup.enabled && projectDir) { + const date = new Date().toISOString().slice(0, 10); + const outputDir = join(config.backup.localDir, date); + mkdirSync(outputDir, { recursive: true }); + + const result = backupProjectDb(project.name, projectDir, outputDir); + if (result.success) { + console.log(` Backed up before stop: ${project.name}`); + } else { + console.warn(` Backup failed before stop: ${project.name} — ${result.error}`); + } + } + const success = stopProject(project.name); if (success) {