From 7476897f87629d3887feff62749427327d3a0d0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Tue, 23 Dec 2025 15:29:27 +0100 Subject: [PATCH] feat(config-import): support mirrors, oneFileSystem, and optional flags - Add mirrors support for backup schedules (copy to secondary repos) - Add oneFileSystem flag to prevent crossing filesystem boundaries - Support autoRemount=false for volumes (defaults to true) - Support enabled=false for notification destinations (defaults to true) - Optimize: move early return before dynamic imports in importBackupSchedules - Update example config and README with new fields documentation --- app/server/modules/lifecycle/config-import.ts | 69 ++++++++++++++++++- examples/config-file-import/README.md | 38 +++++++++- .../zerobyte.config.example.json | 7 +- 3 files changed, 109 insertions(+), 5 deletions(-) diff --git a/app/server/modules/lifecycle/config-import.ts b/app/server/modules/lifecycle/config-import.ts index d5e9ae09..7a458326 100644 --- a/app/server/modules/lifecycle/config-import.ts +++ b/app/server/modules/lifecycle/config-import.ts @@ -127,6 +127,12 @@ async function importVolumes(volumes: unknown[]): Promise { } await volumeService.createVolume(v.name, v.config as BackendConfig); logger.info(`Initialized volume from config: ${v.name}`); + + // If autoRemount is explicitly false, update the volume (default is true) + if (v.autoRemount === false) { + await volumeService.updateVolume(v.name, { autoRemount: false }); + logger.info(`Set autoRemount=false for volume: ${v.name}`); + } } catch (e) { const err = e instanceof Error ? e : new Error(String(e)); logger.warn(`Volume not created: ${err.message}`); @@ -165,8 +171,14 @@ async function importNotificationDestinations(notificationDestinations: unknown[ if (!isRecord(n) || typeof n.name !== "string" || !isRecord(n.config) || typeof n.config.type !== "string") { throw new Error("Invalid notification destination entry"); } - await notificationsServiceModule.notificationsService.createDestination(n.name, n.config as NotificationConfig); + const created = await notificationsServiceModule.notificationsService.createDestination(n.name, n.config as NotificationConfig); logger.info(`Initialized notification destination from config: ${n.name}`); + + // If enabled is explicitly false, update the destination (default is true) + if (n.enabled === false) { + await notificationsServiceModule.notificationsService.updateDestination(created.id, { enabled: false }); + logger.info(`Set enabled=false for notification destination: ${n.name}`); + } } catch (e) { const err = e instanceof Error ? e : new Error(String(e)); logger.warn(`Notification destination not created: ${err.message}`); @@ -243,9 +255,10 @@ async function attachScheduleNotifications( } async function importBackupSchedules(backupSchedules: unknown[]): Promise { + if (!Array.isArray(backupSchedules) || backupSchedules.length === 0) return; + const backupServiceModule = await import("../backups/backups.service"); const notificationsServiceModule = await import("../notifications/notifications.service"); - if (!Array.isArray(backupSchedules) || backupSchedules.length === 0) return; const volumes = await db.query.volumesTable.findMany(); const repositories = await db.query.repositoriesTable.findMany(); @@ -312,6 +325,7 @@ async function importBackupSchedules(backupSchedules: unknown[]): Promise excludePatterns: asStringArray(s.excludePatterns), excludeIfPresent: asStringArray(s.excludeIfPresent), includePatterns: asStringArray(s.includePatterns), + oneFileSystem: typeof s.oneFileSystem === "boolean" ? s.oneFileSystem : undefined, }); logger.info(`Initialized backup schedule from config: ${scheduleName}`); } catch (e) { @@ -323,6 +337,57 @@ async function importBackupSchedules(backupSchedules: unknown[]): Promise if (createdSchedule && Array.isArray(s.notifications) && s.notifications.length > 0) { await attachScheduleNotifications(createdSchedule.id, s.notifications, destinationBySlug, notificationsServiceModule); } + + if (createdSchedule && Array.isArray(s.mirrors) && s.mirrors.length > 0) { + await attachScheduleMirrors(createdSchedule.id, s.mirrors, repoByName, backupServiceModule); + } + } +} + +async function attachScheduleMirrors( + scheduleId: number, + mirrors: unknown[], + repoByName: Map, + backupServiceModule: typeof import("../backups/backups.service"), +): Promise { + try { + const mirrorConfigs: Array<{ repositoryId: string; enabled: boolean }> = []; + + for (const m of mirrors) { + if (!isRecord(m)) continue; + + // Support both repository name (string) and repository object with name + const repoName = + typeof m.repository === "string" + ? m.repository + : typeof m.repositoryName === "string" + ? m.repositoryName + : null; + + if (!repoName) { + logger.warn("Mirror missing repository name; skipping"); + continue; + } + + const repo = repoByName.get(repoName); + if (!repo) { + logger.warn(`Mirror repository '${repoName}' not found; skipping`); + continue; + } + + mirrorConfigs.push({ + repositoryId: repo.id, + enabled: typeof m.enabled === "boolean" ? m.enabled : true, + }); + } + + if (mirrorConfigs.length === 0) return; + + await backupServiceModule.backupsService.updateMirrors(scheduleId, { mirrors: mirrorConfigs }); + logger.info(`Assigned ${mirrorConfigs.length} mirror(s) to backup schedule`); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + logger.warn(`Failed to assign mirrors to schedule: ${err.message}`); } } diff --git a/examples/config-file-import/README.md b/examples/config-file-import/README.md index 4bf1aa7d..a16e0f0a 100644 --- a/examples/config-file-import/README.md +++ b/examples/config-file-import/README.md @@ -313,12 +313,34 @@ Note for importing existing local repositories (migration): "retentionPolicy": { "keepLast": 7, "keepDaily": 7 }, "includePatterns": ["important-folder"], "excludePatterns": ["*.tmp", "*.log"], + "excludeIfPresent": [".nobackup"], + "oneFileSystem": true, "enabled": true, - "notifications": ["slack-alerts", "email-admin"] + "notifications": ["slack-alerts", "email-admin"], + "mirrors": [ + { "repository": "s3-repo" }, + { "repository": "lo2" } + ] } ``` -`notifications` can also be an array of objects: +**Fields:** + +- `name`: Unique schedule name +- `volume`: Name of the source volume +- `repository`: Name of the primary destination repository +- `cronExpression`: Cron string for schedule timing +- `retentionPolicy`: Object with retention rules (`keepLast`, `keepHourly`, `keepDaily`, `keepWeekly`, `keepMonthly`, `keepYearly`, `keepWithinDuration`) +- `includePatterns` / `excludePatterns`: Arrays of file patterns +- `excludeIfPresent`: Array of filenames; if any of these files exist in a directory, that directory is excluded (e.g., `[".nobackup"]`) +- `oneFileSystem`: Boolean; if `true`, restic won't cross filesystem boundaries (useful when backing up `/` to avoid traversing into mounted volumes) +- `enabled`: Boolean +- `notifications`: Array of notification destination names or detailed objects (see below) +- `mirrors`: Array of mirror repositories (see below) + +#### Notifications (detailed) + +`notifications` can be strings (destination names) or objects with fine-grained control: ```json [ @@ -332,6 +354,18 @@ Note for importing existing local repositories (migration): ] ``` +#### Mirrors + +Mirrors let you automatically copy snapshots to additional repositories after each backup. +Each mirror references a repository by name: + +```json +"mirrors": [ + { "repository": "s3-repo" }, + { "repository": "lo2", "enabled": false } +] +``` + ### User setup (automated) Zerobyte currently supports a **single user**. diff --git a/examples/config-file-import/zerobyte.config.example.json b/examples/config-file-import/zerobyte.config.example.json index 6e30e340..33efda9a 100644 --- a/examples/config-file-import/zerobyte.config.example.json +++ b/examples/config-file-import/zerobyte.config.example.json @@ -125,8 +125,13 @@ "retentionPolicy": { "keepLast": 7, "keepDaily": 7 }, "includePatterns": ["important-folder"], "excludePatterns": ["*.tmp", "*.log"], + "excludeIfPresent": [".nobackup"], + "oneFileSystem": false, "enabled": true, - "notifications": ["slack-alerts"] + "notifications": ["slack-alerts"], + "mirrors": [ + { "repository": "s3-repo" } + ] } ], "notificationDestinations": [