diff --git a/app/server/cli/commands/import-config.ts b/app/server/cli/commands/import-config.ts index 8462a3bd..93b15687 100644 --- a/app/server/cli/commands/import-config.ts +++ b/app/server/cli/commands/import-config.ts @@ -2,6 +2,10 @@ import { Command } from "commander"; import path from "node:path"; import fs from "node:fs/promises"; +const toError = (e: unknown): Error => (e instanceof Error ? e : new Error(String(e))); + +type Output = ReturnType; + async function readStdin(): Promise { const chunks: Buffer[] = []; for await (const chunk of process.stdin) { @@ -10,85 +14,118 @@ async function readStdin(): Promise { return Buffer.concat(chunks).toString("utf-8"); } +function createOutput(jsonOutput: boolean) { + return { + error: (message: string): never => { + if (jsonOutput) { + console.log(JSON.stringify({ error: message })); + } else { + console.error(`āŒ ${message}`); + } + process.exit(1); + }, + info: (message: string): void => { + if (!jsonOutput) { + console.log(message); + } + }, + json: (data: object): void => { + if (jsonOutput) { + console.log(JSON.stringify(data)); + } + }, + }; +} + +async function readConfigJson(options: { stdin?: boolean; config?: string }, out: Output): Promise { + if (options.stdin) { + out.info("šŸ“„ Reading config from stdin..."); + try { + const configJson = await readStdin(); + if (!configJson.trim()) { + out.error("No input received from stdin"); + } + return configJson; + } catch (e) { + out.error(`Failed to read stdin: ${toError(e).message}`); + } + } + + const configPath = path.resolve(process.cwd(), options.config ?? ""); + try { + await fs.access(configPath); + } catch { + out.error(`Config file not found: ${configPath}`); + } + + out.info(`šŸ“„ Config file: ${configPath}`); + return fs.readFile(configPath, "utf-8"); +} + export const importConfigCommand = new Command("import-config") .description("Import configuration from a JSON file or stdin") .option("-c, --config ", "Path to the configuration file") .option("--stdin", "Read configuration from stdin") .option("--dry-run", "Validate the config without importing") + .option("--json", "Output results in JSON format") + .option("--log-level ", "Set log level (debug, info, warn, error)") .action(async (options) => { - console.log("\nšŸ“¦ Zerobyte Config Import\n"); + const jsonOutput = options.json; + const out = createOutput(jsonOutput); + + // Set log level: explicit option takes precedence + if (options.logLevel) { + process.env.LOG_LEVEL = options.logLevel; + } + + out.info("\nšŸ“¦ Zerobyte Config Import\n"); if (!options.config && !options.stdin) { - console.error("āŒ Either --config or --stdin is required"); - console.log("\nUsage:"); - console.log(" zerobyte import-config --config /path/to/config.json"); - console.log(" cat config.json | zerobyte import-config --stdin"); - process.exit(1); + if (!jsonOutput) { + console.log("\nUsage:"); + console.log(" zerobyte import-config --config /path/to/config.json"); + console.log(" cat config.json | zerobyte import-config --stdin"); + } + out.error("Either --config or --stdin is required"); } if (options.config && options.stdin) { - console.error("āŒ Cannot use both --config and --stdin"); - process.exit(1); + out.error("Cannot use both --config and --stdin"); } - let configJson: string; - - if (options.stdin) { - console.log("šŸ“„ Reading config from stdin..."); - try { - configJson = await readStdin(); - if (!configJson.trim()) { - console.error("āŒ No input received from stdin"); - process.exit(1); - } - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - console.error(`āŒ Failed to read stdin: ${err.message}`); - process.exit(1); - } - } else { - const configPath = path.resolve(process.cwd(), options.config); - - // Check if file exists - try { - await fs.access(configPath); - } catch { - console.error(`āŒ Config file not found: ${configPath}`); - process.exit(1); - } - - console.log(`šŸ“„ Config file: ${configPath}`); - configJson = await fs.readFile(configPath, "utf-8"); - } + const configJson = await readConfigJson(options, out); // Parse and validate JSON let config: unknown; try { config = JSON.parse(configJson); } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - console.error(`āŒ Invalid JSON: ${err.message}`); - process.exit(1); + out.error(`Invalid JSON: ${toError(e).message}`); } if (options.dryRun) { - console.log("šŸ” Dry run mode - validating config only\n"); - const root = typeof config === "object" && config !== null ? config : {}; const configObj = "config" in root && typeof root.config === "object" && root.config !== null ? root.config : root; const sections = ["volumes", "repositories", "backupSchedules", "notificationDestinations", "users"]; + const counts: Record = {}; for (const section of sections) { const items = (configObj as Record)[section] || []; - const count = Array.isArray(items) ? items.length : 0; - console.log(` ${section}: ${count} item(s)`); + counts[section] = Array.isArray(items) ? items.length : 0; } - const hasRecoveryKey = !!(configObj as Record).recoveryKey; - console.log(` recoveryKey: ${hasRecoveryKey ? "provided" : "not provided"}`); - console.log("\nāœ… Config is valid JSON"); + if (jsonOutput) { + out.json({ dryRun: true, valid: true, counts, hasRecoveryKey }); + } else { + console.log("šŸ” Dry run mode - validating config only\n"); + for (const section of sections) { + console.log(` ${section}: ${counts[section]} item(s)`); + } + console.log(` recoveryKey: ${hasRecoveryKey ? "provided" : "not provided"}`); + console.log("\nāœ… Config is valid JSON"); + } return; } @@ -100,13 +137,13 @@ export const importConfigCommand = new Command("import-config") const { applyConfigImport } = await import("../../modules/lifecycle/config-import"); const result = await applyConfigImport(config); + out.json({ ...result, success: result.errors === 0 }); + // Exit with error code if there were errors if (result.errors > 0) { process.exit(1); } } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - console.error(`āŒ Import failed: ${err.message}`); - process.exit(1); + out.error(`Import failed: ${toError(e).message}`); } }); diff --git a/app/server/modules/lifecycle/config-import.ts b/app/server/modules/lifecycle/config-import.ts index 3b20d9b7..41b44166 100644 --- a/app/server/modules/lifecycle/config-import.ts +++ b/app/server/modules/lifecycle/config-import.ts @@ -1,4 +1,6 @@ import { eq } from "drizzle-orm"; +import fs from "node:fs/promises"; +import path from "node:path"; import slugify from "slugify"; import { db } from "../../db/db"; import { usersTable } from "../../db/schema"; @@ -10,6 +12,8 @@ import type { BackendConfig } from "~/schemas/volumes"; const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null; +const toError = (e: unknown): Error => (e instanceof Error ? e : new Error(String(e))); + const asStringArray = (value: unknown): string[] => { if (!Array.isArray(value)) return []; return value.filter((item): item is string => typeof item === "string"); @@ -36,6 +40,7 @@ type ImportConfig = { export type ImportResult = { succeeded: number; + skipped: number; warnings: number; errors: number; }; @@ -62,8 +67,6 @@ function interpolateEnvVars(value: unknown): unknown { async function loadConfigFromFile(): Promise { try { const configPath = process.env.ZEROBYTE_CONFIG_PATH || "zerobyte.config.json"; - const fs = await import("node:fs/promises"); - const path = await import("node:path"); const configFullPath = path.resolve(process.cwd(), configPath); try { const raw = await fs.readFile(configFullPath, "utf-8"); @@ -73,8 +76,7 @@ async function loadConfigFromFile(): Promise { throw error; } } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - logger.warn(`No config file loaded or error parsing config: ${err.message}`); + logger.warn(`No config file loaded or error parsing config: ${toError(error).message}`); return null; } } @@ -100,11 +102,17 @@ function parseImportConfig(configRaw: unknown): ImportConfig { }; } +function mergeResults(target: ImportResult, source: ImportResult): void { + target.succeeded += source.succeeded; + target.skipped += source.skipped; + target.warnings += source.warnings; + target.errors += source.errors; +} + async function writeRecoveryKeyFromConfig(recoveryKey: string | null): Promise { - const result: ImportResult = { succeeded: 0, warnings: 0, errors: 0 }; + const result: ImportResult = { succeeded: 0, skipped: 0, warnings: 0, errors: 0 }; try { - const fs = await import("node:fs/promises"); const { RESTIC_PASS_FILE } = await import("../../core/constants.js"); if (!recoveryKey) return result; @@ -116,16 +124,22 @@ async function writeRecoveryKeyFromConfig(recoveryKey: string | null): Promise false, ); if (passFileExists) { - logger.warn(`Restic passfile already exists at ${RESTIC_PASS_FILE}; skipping config recovery key write`); - result.warnings++; + // Check if existing key matches the one being imported + const existingKey = await fs.readFile(RESTIC_PASS_FILE, "utf-8"); + if (existingKey.trim() === recoveryKey) { + logger.info("Recovery key already configured with matching value"); + result.skipped++; + } else { + logger.error("Recovery key already exists with different value; cannot overwrite"); + result.errors++; + } return result; } await fs.writeFile(RESTIC_PASS_FILE, recoveryKey, { mode: 0o600 }); logger.info(`Recovery key written from config to ${RESTIC_PASS_FILE}`); result.succeeded++; } catch (err) { - const e = err instanceof Error ? err : new Error(String(err)); - logger.error(`Failed to write recovery key from config: ${e.message}`); + logger.error(`Failed to write recovery key from config: ${toError(err).message}`); result.errors++; } @@ -133,13 +147,24 @@ async function writeRecoveryKeyFromConfig(recoveryKey: string | null): Promise { - const result: ImportResult = { succeeded: 0, warnings: 0, errors: 0 }; + const result: ImportResult = { succeeded: 0, skipped: 0, warnings: 0, errors: 0 }; + + // Get existing volumes to check for duplicates + const existingVolumes = await volumeService.listVolumes(); + const existingNames = new Set(existingVolumes.map((v) => v.name)); for (const v of volumes) { try { if (!isRecord(v) || typeof v.name !== "string" || !isRecord(v.config) || typeof v.config.backend !== "string") { throw new Error("Invalid volume entry"); } + + if (existingNames.has(v.name)) { + logger.info(`Volume '${v.name}' already exists`); + result.skipped++; + continue; + } + await volumeService.createVolume(v.name, v.config as BackendConfig); logger.info(`Initialized volume from config: ${v.name}`); result.succeeded++; @@ -150,8 +175,8 @@ async function importVolumes(volumes: unknown[]): Promise { 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}`); + const volumeName = isRecord(v) && typeof v.name === "string" ? v.name : "unknown"; + logger.warn(`Volume '${volumeName}' not created: ${toError(e).message}`); result.warnings++; } } @@ -160,7 +185,7 @@ async function importVolumes(volumes: unknown[]): Promise { } async function importRepositories(repositories: unknown[]): Promise { - const result: ImportResult = { succeeded: 0, warnings: 0, errors: 0 }; + const result: ImportResult = { succeeded: 0, skipped: 0, warnings: 0, errors: 0 }; const repoServiceModule = await import("../repositories/repositories.service"); const { buildRepoUrl, restic } = await import("../../utils/restic"); @@ -175,8 +200,7 @@ async function importRepositories(repositories: unknown[]): Promise true) .catch((e) => { - const err = e instanceof Error ? e : new Error(String(e)); - logger.debug(`Repo existence check for '${r.name}': ${err.message}`); + logger.debug(`Repo existence check for '${r.name}': ${toError(e).message}`); return false; }); @@ -223,8 +245,8 @@ async function importRepositories(repositories: unknown[]): Promise { - const result: ImportResult = { succeeded: 0, warnings: 0, errors: 0 }; + const result: ImportResult = { succeeded: 0, skipped: 0, warnings: 0, errors: 0 }; const notificationsServiceModule = await import("../notifications/notifications.service"); + + // Get existing destinations to check for duplicates + const existingDestinations = await notificationsServiceModule.notificationsService.listDestinations(); + const existingNames = new Set(existingDestinations.map((d) => d.name)); + for (const n of notificationDestinations) { try { if (!isRecord(n) || typeof n.name !== "string" || !isRecord(n.config) || typeof n.config.type !== "string") { throw new Error("Invalid notification destination entry"); } + + // The service uses slugify to normalize the name, so we check against stored names + if (existingNames.has(n.name)) { + logger.info(`Notification destination '${n.name}' already exists`); + result.skipped++; + continue; + } + const created = await notificationsServiceModule.notificationsService.createDestination( n.name, n.config as NotificationConfig, @@ -270,8 +305,8 @@ async function importNotificationDestinations(notificationDestinations: unknown[ 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}`); + const destName = isRecord(n) && typeof n.name === "string" ? n.name : "unknown"; + logger.warn(`Notification destination '${destName}' not created: ${toError(e).message}`); result.warnings++; } } @@ -297,6 +332,7 @@ function getScheduleRepositoryName(schedule: Record): string | type ScheduleNotificationAssignment = { destinationId: number; + destinationName: string; notifyOnStart: boolean; notifyOnSuccess: boolean; notifyOnWarning: boolean; @@ -304,25 +340,30 @@ type ScheduleNotificationAssignment = { }; function buildScheduleNotificationAssignments( + scheduleName: string, notifications: unknown[], destinationBySlug: Map, -): ScheduleNotificationAssignment[] { +): { assignments: ScheduleNotificationAssignment[]; warnings: number } { const assignments: ScheduleNotificationAssignment[] = []; + let warnings = 0; for (const notif of notifications) { const destName = typeof notif === "string" ? notif : isRecord(notif) ? notif.name : null; if (typeof destName !== "string" || destName.length === 0) { - logger.warn("Notification destination missing name for schedule"); + logger.warn(`Notification destination missing name for schedule '${scheduleName}'`); + warnings++; continue; } const destSlug = slugify(destName, { lower: true, strict: true }); const dest = destinationBySlug.get(destSlug); if (!dest) { - logger.warn(`Notification destination '${destName}' not found for schedule`); + logger.warn(`Notification destination '${destName}' not found for schedule '${scheduleName}'`); + warnings++; continue; } assignments.push({ destinationId: dest.id, + destinationName: dest.name, notifyOnStart: isRecord(notif) && typeof notif.notifyOnStart === "boolean" ? notif.notifyOnStart : true, notifyOnSuccess: isRecord(notif) && typeof notif.notifyOnSuccess === "boolean" ? notif.notifyOnSuccess : true, notifyOnWarning: isRecord(notif) && typeof notif.notifyOnWarning === "boolean" ? notif.notifyOnWarning : true, @@ -330,29 +371,66 @@ function buildScheduleNotificationAssignments( }); } - return assignments; + return { assignments, warnings }; } async function attachScheduleNotifications( scheduleId: number, + scheduleName: string, notifications: unknown[], destinationBySlug: Map, notificationsServiceModule: typeof import("../notifications/notifications.service"), -): Promise { +): Promise { + const result: ImportResult = { succeeded: 0, skipped: 0, warnings: 0, errors: 0 }; try { - const assignments = buildScheduleNotificationAssignments(notifications, destinationBySlug); - if (assignments.length === 0) return; + const existingNotifications = + await notificationsServiceModule.notificationsService.getScheduleNotifications(scheduleId); + const existingDestIds = new Set(existingNotifications.map((n) => n.destinationId)); - await notificationsServiceModule.notificationsService.updateScheduleNotifications(scheduleId, assignments); - logger.info(`Assigned ${assignments.length} notification(s) to backup schedule`); + const { assignments, warnings } = buildScheduleNotificationAssignments( + scheduleName, + notifications, + destinationBySlug, + ); + result.warnings += warnings; + + // Filter out already attached notifications and track skipped + const newAssignments: typeof assignments = []; + for (const a of assignments) { + if (existingDestIds.has(a.destinationId)) { + logger.info(`Notification '${a.destinationName}' already attached to schedule '${scheduleName}'`); + result.skipped++; + } else { + newAssignments.push(a); + } + } + if (newAssignments.length === 0) return result; + + // Merge existing with new (strip destinationName for API call) + const mergedAssignments = [ + ...existingNotifications.map((n) => ({ + destinationId: n.destinationId, + notifyOnStart: n.notifyOnStart, + notifyOnSuccess: n.notifyOnSuccess, + notifyOnWarning: n.notifyOnWarning, + notifyOnFailure: n.notifyOnFailure, + })), + ...newAssignments.map(({ destinationName: _, ...rest }) => rest), + ]; + + await notificationsServiceModule.notificationsService.updateScheduleNotifications(scheduleId, mergedAssignments); + const notifNames = newAssignments.map((a) => a.destinationName).join(", "); + logger.info(`Assigned notification(s) [${notifNames}] to schedule '${scheduleName}'`); + result.succeeded += newAssignments.length; } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - logger.warn(`Failed to assign notifications to schedule: ${err.message}`); + logger.warn(`Failed to assign notifications to schedule '${scheduleName}': ${toError(e).message}`); + result.warnings++; } + return result; } async function importBackupSchedules(backupSchedules: unknown[]): Promise { - const result: ImportResult = { succeeded: 0, warnings: 0, errors: 0 }; + const result: ImportResult = { succeeded: 0, skipped: 0, warnings: 0, errors: 0 }; if (!Array.isArray(backupSchedules) || backupSchedules.length === 0) return result; const backupServiceModule = await import("../backups/backups.service"); @@ -361,10 +439,12 @@ async function importBackupSchedules(backupSchedules: unknown[]): Promise [v.name, v] as const)); const repoByName = new Map(repositories.map((r) => [r.name, r] as const)); const destinationBySlug = new Map(destinations.map((d) => [d.name, d] as const)); + const scheduleByName = new Map(existingSchedules.map((s) => [s.name, s] as const)); for (const s of backupSchedules) { if (!isRecord(s)) { @@ -372,85 +452,105 @@ async function importBackupSchedules(backupSchedules: unknown[]): Promise 0 ? s.name : `${volumeName}-${repositoryName}`; if (typeof s.cronExpression !== "string" || s.cronExpression.length === 0) { - logger.warn(`Backup schedule not created: Missing cronExpression for '${scheduleName}'`); + logger.warn(`Backup schedule not processed: Missing cronExpression for '${scheduleName}'`); result.warnings++; continue; } - if (volume.status !== "mounted") { + // Check if schedule already exists - if so, skip creation but still try attachments + const existingSchedule = scheduleByName.get(scheduleName); + let scheduleId: number; + + if (existingSchedule) { + logger.info(`Backup schedule '${scheduleName}' already exists`); + result.skipped++; + scheduleId = existingSchedule.id; + } else { + // Mount volume if needed for new schedule + if (volume.status !== "mounted") { + try { + await volumeService.mountVolume(volume.name); + volumeByName.set(volume.name, { ...volume, status: "mounted" }); + logger.info(`Mounted volume ${volume.name} for backup schedule`); + } catch (e) { + logger.warn(`Could not mount volume ${volume.name}: ${toError(e).message}`); + result.warnings++; + continue; + } + } + try { - await volumeService.mountVolume(volume.name); - volumeByName.set(volume.name, { ...volume, status: "mounted" }); - logger.info(`Mounted volume ${volume.name} for backup schedule`); + const retentionPolicy = isRecord(s.retentionPolicy) ? (s.retentionPolicy as RetentionPolicy) : undefined; + const createdSchedule = await backupServiceModule.backupsService.createSchedule({ + name: scheduleName, + volumeId: volume.id, + repositoryId: repository.id, + enabled: typeof s.enabled === "boolean" ? s.enabled : true, + cronExpression: s.cronExpression, + retentionPolicy, + 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}`); + result.succeeded++; + scheduleId = createdSchedule.id; } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - logger.warn(`Could not mount volume ${volume.name}: ${err.message}`); + logger.warn(`Backup schedule '${scheduleName}' not created: ${toError(e).message}`); result.warnings++; continue; } } - let createdSchedule: { id: number } | null = null; - try { - const retentionPolicy = isRecord(s.retentionPolicy) ? (s.retentionPolicy as RetentionPolicy) : undefined; - createdSchedule = await backupServiceModule.backupsService.createSchedule({ - name: scheduleName, - volumeId: volume.id, - repositoryId: repository.id, - enabled: typeof s.enabled === "boolean" ? s.enabled : true, - cronExpression: s.cronExpression, - retentionPolicy, - 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}`); - result.succeeded++; - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - logger.warn(`Backup schedule not created: ${err.message}`); - result.warnings++; - continue; - } - - if (createdSchedule && Array.isArray(s.notifications) && s.notifications.length > 0) { - await attachScheduleNotifications( - createdSchedule.id, + // Attach notifications (checks if already attached) + if (Array.isArray(s.notifications) && s.notifications.length > 0) { + const notifResult = await attachScheduleNotifications( + scheduleId, + scheduleName, s.notifications, destinationBySlug, notificationsServiceModule, ); + mergeResults(result, notifResult); } - if (createdSchedule && Array.isArray(s.mirrors) && s.mirrors.length > 0) { - await attachScheduleMirrors(createdSchedule.id, s.mirrors, repoByName, backupServiceModule); + // Attach mirrors (checks if already attached) + if (Array.isArray(s.mirrors) && s.mirrors.length > 0) { + const mirrorResult = await attachScheduleMirrors( + scheduleId, + scheduleName, + s.mirrors, + repoByName, + backupServiceModule, + ); + mergeResults(result, mirrorResult); } } @@ -459,12 +559,17 @@ async function importBackupSchedules(backupSchedules: unknown[]): Promise, backupServiceModule: typeof import("../backups/backups.service"), -): Promise { +): Promise { + const result: ImportResult = { succeeded: 0, skipped: 0, warnings: 0, errors: 0 }; try { - const mirrorConfigs: Array<{ repositoryId: string; enabled: boolean }> = []; + const existingMirrors = await backupServiceModule.backupsService.getMirrors(scheduleId); + const existingRepoIds = new Set(existingMirrors.map((m) => m.repositoryId)); + + const mirrorConfigs: Array<{ repositoryId: string; repositoryName: string; enabled: boolean }> = []; for (const m of mirrors) { if (!isRecord(m)) continue; @@ -478,39 +583,70 @@ async function attachScheduleMirrors( : null; if (!repoName) { - logger.warn("Mirror missing repository name; skipping"); + logger.warn(`Mirror missing repository name for schedule '${scheduleName}'`); + result.warnings++; continue; } const repo = repoByName.get(repoName); if (!repo) { - logger.warn(`Mirror repository '${repoName}' not found; skipping`); + logger.warn(`Mirror repository '${repoName}' not found for schedule '${scheduleName}'`); + result.warnings++; continue; } mirrorConfigs.push({ repositoryId: repo.id, + repositoryName: repo.name, enabled: typeof m.enabled === "boolean" ? m.enabled : true, }); } - if (mirrorConfigs.length === 0) return; + // Filter out already attached mirrors and track skipped + const newMirrors: typeof mirrorConfigs = []; + for (const m of mirrorConfigs) { + if (existingRepoIds.has(m.repositoryId)) { + logger.info(`Mirror '${m.repositoryName}' already attached to schedule '${scheduleName}'`); + result.skipped++; + } else { + newMirrors.push(m); + } + } + if (newMirrors.length === 0) return result; - await backupServiceModule.backupsService.updateMirrors(scheduleId, { mirrors: mirrorConfigs }); - logger.info(`Assigned ${mirrorConfigs.length} mirror(s) to backup schedule`); + // Merge existing with new (strip repositoryName for API call) + const mergedMirrors = [ + ...existingMirrors.map((m) => ({ + repositoryId: m.repositoryId, + enabled: m.enabled, + })), + ...newMirrors.map(({ repositoryName: _, ...rest }) => rest), + ]; + + await backupServiceModule.backupsService.updateMirrors(scheduleId, { mirrors: mergedMirrors }); + const mirrorNames = newMirrors.map((m) => m.repositoryName).join(", "); + logger.info(`Assigned mirror(s) [${mirrorNames}] to schedule '${scheduleName}'`); + result.succeeded += newMirrors.length; } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - logger.warn(`Failed to assign mirrors to schedule: ${err.message}`); + logger.warn(`Failed to assign mirrors to schedule '${scheduleName}': ${toError(e).message}`); + result.warnings++; } + return result; } -async function setupInitialUser(users: unknown[], recoveryKey: string | null): Promise { - const result: ImportResult = { succeeded: 0, warnings: 0, errors: 0 }; +async function importUsers(users: unknown[], recoveryKey: string | null): Promise { + const result: ImportResult = { succeeded: 0, skipped: 0, warnings: 0, errors: 0 }; try { const { authService } = await import("../auth/auth.service"); const hasUsers = await authService.hasUsers(); - if (hasUsers) return result; + if (hasUsers) { + if (Array.isArray(users) && users.length > 0) { + logger.info("Users already exist; skipping user import from config"); + result.skipped++; + } + return result; + } if (!Array.isArray(users) || users.length === 0) return result; if (users.length > 1) { @@ -521,8 +657,16 @@ async function setupInitialUser(users: unknown[], recoveryKey: string | null): P } for (const u of users) { - if (!isRecord(u)) continue; - if (typeof u.username !== "string" || u.username.length === 0) continue; + if (!isRecord(u)) { + logger.warn("Invalid user entry in config; skipping"); + result.warnings++; + continue; + } + if (typeof u.username !== "string" || u.username.length === 0) { + logger.warn("User entry missing username; skipping"); + result.warnings++; + continue; + } if (typeof u.passwordHash === "string" && u.passwordHash.length > 0) { try { @@ -575,33 +719,36 @@ async function setupInitialUser(users: unknown[], recoveryKey: string | null): P } async function runImport(config: ImportConfig): Promise { - const result: ImportResult = { succeeded: 0, warnings: 0, errors: 0 }; + const result: ImportResult = { succeeded: 0, skipped: 0, warnings: 0, errors: 0 }; - const recoveryKeyResult = await writeRecoveryKeyFromConfig(config.recoveryKey); - const volumeResult = await importVolumes(config.volumes); - const repoResult = await importRepositories(config.repositories); - const notifResult = await importNotificationDestinations(config.notificationDestinations); - const scheduleResult = await importBackupSchedules(config.backupSchedules); - const userResult = await setupInitialUser(config.users, config.recoveryKey); + mergeResults(result, await writeRecoveryKeyFromConfig(config.recoveryKey)); - for (const r of [recoveryKeyResult, volumeResult, repoResult, notifResult, scheduleResult, userResult]) { - result.succeeded += r.succeeded; - result.warnings += r.warnings; - result.errors += r.errors; + // Stop immediately if recovery key has errors (e.g., mismatch with existing key) + if (result.errors > 0) { + return result; } + mergeResults(result, await importVolumes(config.volumes)); + mergeResults(result, await importRepositories(config.repositories)); + mergeResults(result, await importNotificationDestinations(config.notificationDestinations)); + mergeResults(result, await importBackupSchedules(config.backupSchedules)); + mergeResults(result, await importUsers(config.users, config.recoveryKey)); + return result; } function logImportSummary(result: ImportResult): void { + const skippedMsg = result.skipped > 0 ? `, ${result.skipped} skipped` : ""; if (result.errors > 0) { logger.error( - `Config import completed with ${result.errors} error(s) and ${result.warnings} warning(s), ${result.succeeded} item(s) imported`, + `Config import completed with ${result.errors} error(s) and ${result.warnings} warning(s), ${result.succeeded} imported${skippedMsg}`, ); } else if (result.warnings > 0) { - logger.warn(`Config import completed with ${result.warnings} warning(s), ${result.succeeded} item(s) imported`); - } else if (result.succeeded > 0) { - logger.info(`Config import completed successfully: ${result.succeeded} item(s) imported`); + logger.warn( + `Config import completed with ${result.warnings} warning(s), ${result.succeeded} imported${skippedMsg}`, + ); + } else if (result.succeeded > 0 || result.skipped > 0) { + logger.info(`Config import completed: ${result.succeeded} imported${skippedMsg}`); } else { logger.info("Config import completed: no items to import"); } @@ -632,7 +779,6 @@ export async function applyConfigImportFromFile(): Promise { const result = await runImport(config); logImportSummary(result); } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - logger.error(`Config import failed: ${err.message}`); + logger.error(`Config import failed: ${toError(e).message}`); } }