diff --git a/app/schemas/config-import.ts b/app/schemas/config-import.ts new file mode 100644 index 00000000..c162d6f5 --- /dev/null +++ b/app/schemas/config-import.ts @@ -0,0 +1,114 @@ +import { type } from "arktype"; +import { volumeConfigSchema } from "./volumes"; +import { repositoryConfigSchema } from "./restic"; +import { notificationConfigSchema } from "./notifications"; +import { retentionPolicySchema } from "../server/modules/backups/backups.dto"; + +/** + * ArkType schemas for validating config import JSON files. + * These provide runtime validation with detailed error messages. + */ + +// Short ID format: 8 character base64url string +const shortIdSchema = type(/^[A-Za-z0-9_-]{8}$/); + +// Volume entry schema for import +export const volumeImportSchema = type({ + name: "string>=1", + shortId: shortIdSchema.optional(), + autoRemount: "boolean?", + config: volumeConfigSchema, +}).onUndeclaredKey("delete"); + +// Repository entry schema for import +export const repositoryImportSchema = type({ + name: "string>=1", + shortId: shortIdSchema.optional(), + compressionMode: type("'auto' | 'off' | 'max'").optional(), + config: repositoryConfigSchema, +}).onUndeclaredKey("delete"); + +// Notification destination entry schema for import +export const notificationDestinationImportSchema = type({ + name: "string>=1", + enabled: "boolean?", + config: notificationConfigSchema, +}).onUndeclaredKey("delete"); + +// Schedule notification assignment (either string name or object with settings) +const scheduleNotificationObjectSchema = type({ + name: "string>=1", + notifyOnStart: "boolean?", + notifyOnSuccess: "boolean?", + notifyOnWarning: "boolean?", + notifyOnFailure: "boolean?", +}).onUndeclaredKey("delete"); + +export const scheduleNotificationAssignmentSchema = type("string>=1").or(scheduleNotificationObjectSchema); + +// Schedule mirror assignment +export const scheduleMirrorSchema = type({ + repository: "string>=1", + enabled: "boolean?", +}).onUndeclaredKey("delete"); + +// Array types for complex schemas +const scheduleNotificationsArray = scheduleNotificationAssignmentSchema.array(); +const scheduleMirrorsArray = scheduleMirrorSchema.array(); + +// Backup schedule entry schema for import +export const backupScheduleImportSchema = type({ + name: "string?", + shortId: shortIdSchema.optional(), + volume: "string>=1", + repository: "string>=1", + cronExpression: "string", + enabled: "boolean?", + retentionPolicy: retentionPolicySchema.or("null").optional(), + excludePatterns: "string[]?", + excludeIfPresent: "string[]?", + includePatterns: "string[]?", + oneFileSystem: "boolean?", + notifications: scheduleNotificationsArray.optional(), + mirrors: scheduleMirrorsArray.optional(), +}).onUndeclaredKey("delete"); + +// User entry schema for import +export const userImportSchema = type({ + username: "string>=1", + password: "(string>=1)?", + passwordHash: "(string>=1)?", + hasDownloadedResticPassword: "boolean?", +}).onUndeclaredKey("delete"); + +// Recovery key format: 64-character hex string +const recoveryKeySchema = type(/^[a-fA-F0-9]{64}$/); + +// Array types for root config +const volumesArray = volumeImportSchema.array(); +const repositoriesArray = repositoryImportSchema.array(); +const backupSchedulesArray = backupScheduleImportSchema.array(); +const notificationDestinationsArray = notificationDestinationImportSchema.array(); +const usersArray = userImportSchema.array(); + +// Root config schema +export const importConfigSchema = type({ + volumes: volumesArray.optional(), + repositories: repositoriesArray.optional(), + backupSchedules: backupSchedulesArray.optional(), + notificationDestinations: notificationDestinationsArray.optional(), + users: usersArray.optional(), + recoveryKey: recoveryKeySchema.optional(), +}).onUndeclaredKey("delete"); + +// Type exports +export type VolumeImport = typeof volumeImportSchema.infer; +export type RepositoryImport = typeof repositoryImportSchema.infer; +export type NotificationDestinationImport = typeof notificationDestinationImportSchema.infer; +export type BackupScheduleImport = typeof backupScheduleImportSchema.infer; +export type UserImport = typeof userImportSchema.infer; +export type ImportConfig = typeof importConfigSchema.infer; +export type ScheduleNotificationAssignment = typeof scheduleNotificationAssignmentSchema.infer; +export type ScheduleMirror = typeof scheduleMirrorSchema.infer; +// RetentionPolicy type is re-exported from backups.dto.ts +export type { RetentionPolicy } from "../server/modules/backups/backups.dto"; diff --git a/app/server/cli/commands/import-config.ts b/app/server/cli/commands/import-config.ts index b336f0e5..6e02f729 100644 --- a/app/server/cli/commands/import-config.ts +++ b/app/server/cli/commands/import-config.ts @@ -104,27 +104,41 @@ export const importConfigCommand = new Command("import-config") } if (options.dryRun) { - const root = typeof config === "object" && config !== null ? config : {}; - const configObj = - "config" in root && typeof root.config === "object" && root.config !== null ? root.config : root; + const { validateConfig } = await import("../../modules/lifecycle/config-import"); + const validation = validateConfig(config); - const sections = ["volumes", "repositories", "backupSchedules", "notificationDestinations", "users"]; - const counts: Record = {}; - for (const section of sections) { - const items = (configObj as Record)[section] || []; - counts[section] = Array.isArray(items) ? items.length : 0; + if (!validation.success) { + if (jsonOutput) { + out.json({ dryRun: true, valid: false, validationErrors: validation.errors }); + } else { + console.log("šŸ” Dry run mode - validating config\n"); + console.log("āŒ Validation errors:"); + for (const error of validation.errors) { + console.log(` • ${error.path}: ${error.message}`); + } + } + process.exit(1); } - const hasRecoveryKey = !!(configObj as Record).recoveryKey; + + const { config: validConfig } = validation; + const counts = { + volumes: validConfig.volumes?.length ?? 0, + repositories: validConfig.repositories?.length ?? 0, + backupSchedules: validConfig.backupSchedules?.length ?? 0, + notificationDestinations: validConfig.notificationDestinations?.length ?? 0, + users: validConfig.users?.length ?? 0, + }; + const hasRecoveryKey = !!validConfig.recoveryKey; 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("šŸ” Dry run mode - validating config\n"); + for (const [section, count] of Object.entries(counts)) { + console.log(` ${section}: ${count} item(s)`); } console.log(` recoveryKey: ${hasRecoveryKey ? "provided" : "not provided"}`); - console.log("\nāœ… Config is valid JSON"); + console.log("\nāœ… Config is valid"); } return; } @@ -135,8 +149,21 @@ export const importConfigCommand = new Command("import-config") runDbMigrations(); const { applyConfigImport } = await import("../../modules/lifecycle/config-import"); - const result = await applyConfigImport(config, { overwriteRecoveryKey: options.overwriteRecoveryKey }); + const importResult = await applyConfigImport(config, { overwriteRecoveryKey: options.overwriteRecoveryKey }); + if (!importResult.success) { + if (jsonOutput) { + out.json({ success: false, validationErrors: importResult.validationErrors }); + } else { + console.log("āŒ Validation errors:"); + for (const error of importResult.validationErrors) { + console.log(` • ${error.path}: ${error.message}`); + } + } + process.exit(1); + } + + const { result } = importResult; out.json({ ...result, success: result.errors === 0 }); // Exit with error code if there were errors diff --git a/app/server/modules/backups/backups.dto.ts b/app/server/modules/backups/backups.dto.ts index fa21cfa5..6a7f9009 100644 --- a/app/server/modules/backups/backups.dto.ts +++ b/app/server/modules/backups/backups.dto.ts @@ -3,7 +3,7 @@ import { describeRoute, resolver } from "hono-openapi"; import { volumeSchema } from "../volumes/volume.dto"; import { repositorySchema } from "../repositories/repositories.dto"; -const retentionPolicySchema = type({ +export const retentionPolicySchema = type({ keepLast: "number?", keepHourly: "number?", keepDaily: "number?", diff --git a/app/server/modules/lifecycle/config-import.ts b/app/server/modules/lifecycle/config-import.ts index 08e18073..0b450510 100644 --- a/app/server/modules/lifecycle/config-import.ts +++ b/app/server/modules/lifecycle/config-import.ts @@ -1,6 +1,7 @@ import { eq } from "drizzle-orm"; import fs from "node:fs/promises"; import slugify from "slugify"; +import { type } from "arktype"; import { db } from "../../db/db"; import { usersTable, @@ -15,33 +16,20 @@ import { volumeService } from "../volumes/volume.service"; import type { NotificationConfig } from "~/schemas/notifications"; import type { RepositoryConfig } from "~/schemas/restic"; import type { BackendConfig } from "~/schemas/volumes"; +import { + importConfigSchema, + type ImportConfig, + type VolumeImport, + type RepositoryImport, + type NotificationDestinationImport, + type BackupScheduleImport, + type UserImport, + type ScheduleNotificationAssignment as ScheduleNotificationImport, + type ScheduleMirror as ScheduleMirrorImport, +} from "~/schemas/config-import"; const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null; -const asStringArray = (value: unknown): string[] => { - if (!Array.isArray(value)) return []; - return value.filter((item): item is string => typeof item === "string"); -}; - -type RetentionPolicy = { - keepLast?: number; - keepHourly?: number; - keepDaily?: number; - keepWeekly?: number; - keepMonthly?: number; - keepYearly?: number; - keepWithinDuration?: string; -}; - -type ImportConfig = { - volumes: unknown[]; - repositories: unknown[]; - backupSchedules: unknown[]; - notificationDestinations: unknown[]; - users: unknown[]; - recoveryKey: string | null; -}; - export type ImportResult = { succeeded: number; skipped: number; @@ -68,25 +56,39 @@ function interpolateEnvVars(value: unknown): unknown { return value; } -function parseImportConfig(configRaw: unknown): ImportConfig { +export type ConfigValidationError = { + path: string; + message: string; +}; + +export type ParseConfigResult = + | { success: true; config: ImportConfig } + | { success: false; errors: ConfigValidationError[] }; + +/** + * Parse and validate import configuration using ArkType schema. + * Returns typed config on success or validation errors on failure. + */ +function parseImportConfig(configRaw: unknown): ParseConfigResult { + // Handle wrapped format: { config: { ... } } const root = isRecord(configRaw) ? configRaw : {}; - const config = isRecord(root.config) ? (root.config as Record) : root; + const configData = isRecord(root.config) ? root.config : root; - const volumes = interpolateEnvVars(config.volumes || []); - const repositories = interpolateEnvVars(config.repositories || []); - const backupSchedules = interpolateEnvVars(config.backupSchedules || []); - const notificationDestinations = interpolateEnvVars(config.notificationDestinations || []); - const users = interpolateEnvVars(config.users || []); - const recoveryKeyRaw = interpolateEnvVars(config.recoveryKey || null); + // Interpolate environment variables before validation + const interpolated = interpolateEnvVars(configData); - return { - volumes: Array.isArray(volumes) ? volumes : [], - repositories: Array.isArray(repositories) ? repositories : [], - backupSchedules: Array.isArray(backupSchedules) ? backupSchedules : [], - notificationDestinations: Array.isArray(notificationDestinations) ? notificationDestinations : [], - users: Array.isArray(users) ? users : [], - recoveryKey: typeof recoveryKeyRaw === "string" ? recoveryKeyRaw : null, - }; + // Validate against ArkType schema + const result = importConfigSchema(interpolated); + + if (result instanceof type.errors) { + const errors: ConfigValidationError[] = result.map((error) => ({ + path: error.path.join(".") || "(root)", + message: error.message, + })); + return { success: false, errors }; + } + + return { success: true, config: result }; } function mergeResults(target: ImportResult, source: ImportResult): void { @@ -118,7 +120,7 @@ async function isDatabaseEmpty(): Promise { } async function writeRecoveryKeyFromConfig( - recoveryKey: string | null, + recoveryKey: string | undefined, overwriteRecoveryKey: boolean, ): Promise { const result: ImportResult = { succeeded: 0, skipped: 0, warnings: 0, errors: 0 }; @@ -127,9 +129,6 @@ async function writeRecoveryKeyFromConfig( const { RESTIC_PASS_FILE } = await import("../../core/constants.js"); if (!recoveryKey) return result; - if (typeof recoveryKey !== "string" || recoveryKey.length !== 64 || !/^[a-fA-F0-9]{64}$/.test(recoveryKey)) { - throw new Error("Recovery key must be a 64-character hex string"); - } const passFileExists = await fs.stat(RESTIC_PASS_FILE).then( () => true, () => false, @@ -175,7 +174,7 @@ async function writeRecoveryKeyFromConfig( return result; } -async function importVolumes(volumes: unknown[]): Promise { +async function importVolumes(volumes: VolumeImport[]): Promise { const result: ImportResult = { succeeded: 0, skipped: 0, warnings: 0, errors: 0 }; // Get existing volumes to check for duplicates @@ -184,10 +183,6 @@ async function importVolumes(volumes: unknown[]): Promise { 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"); - } - // The service uses slugify to normalize the name, so we check against stored names const slugifiedName = slugify(v.name, { lower: true, strict: true }); if (existingNames.has(slugifiedName)) { @@ -197,8 +192,7 @@ async function importVolumes(volumes: unknown[]): Promise { } // Pass shortId from config if provided (for IaC reproducibility) - const shortId = typeof v.shortId === "string" ? v.shortId : undefined; - await volumeService.createVolume(v.name, v.config as BackendConfig, shortId); + await volumeService.createVolume(v.name, v.config as BackendConfig, v.shortId); logger.info(`Initialized volume from config: ${v.name}`); result.succeeded++; @@ -208,8 +202,7 @@ async function importVolumes(volumes: unknown[]): Promise { logger.info(`Set autoRemount=false for volume: ${v.name}`); } } catch (e) { - const volumeName = isRecord(v) && typeof v.name === "string" ? v.name : "unknown"; - logger.warn(`Volume '${volumeName}' not created: ${toError(e).message}`); + logger.warn(`Volume '${v.name}' not created: ${toError(e).message}`); result.warnings++; } } @@ -217,7 +210,7 @@ async function importVolumes(volumes: unknown[]): Promise { return result; } -async function importRepositories(repositories: unknown[]): Promise { +async function importRepositories(repositories: RepositoryImport[]): Promise { 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"); @@ -239,10 +232,6 @@ async function importRepositories(repositories: unknown[]): Promise { +async function importNotificationDestinations( + notificationDestinations: NotificationDestinationImport[], +): Promise { const result: ImportResult = { succeeded: 0, skipped: 0, warnings: 0, errors: 0 }; const notificationsServiceModule = await import("../notifications/notifications.service"); @@ -318,10 +302,6 @@ async function importNotificationDestinations(notificationDestinations: unknown[ 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 const slugifiedName = slugify(n.name, { lower: true, strict: true }); if (existingNames.has(slugifiedName)) { @@ -343,8 +323,7 @@ async function importNotificationDestinations(notificationDestinations: unknown[ logger.info(`Set enabled=false for notification destination: ${n.name}`); } } catch (e) { - const destName = isRecord(n) && typeof n.name === "string" ? n.name : "unknown"; - logger.warn(`Notification destination '${destName}' not created: ${toError(e).message}`); + logger.warn(`Notification destination '${n.name}' not created: ${toError(e).message}`); result.warnings++; } } @@ -352,22 +331,6 @@ async function importNotificationDestinations(notificationDestinations: unknown[ return result; } -function getScheduleVolumeName(schedule: Record): string | null { - return typeof schedule.volume === "string" - ? schedule.volume - : typeof schedule.volumeName === "string" - ? schedule.volumeName - : null; -} - -function getScheduleRepositoryName(schedule: Record): string | null { - return typeof schedule.repository === "string" - ? schedule.repository - : typeof schedule.repositoryName === "string" - ? schedule.repositoryName - : null; -} - type ScheduleNotificationAssignment = { destinationId: number; destinationName: string; @@ -379,19 +342,15 @@ type ScheduleNotificationAssignment = { function buildScheduleNotificationAssignments( scheduleName: string, - notifications: unknown[], + notifications: ScheduleNotificationImport[], destinationBySlug: Map, ): { 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 '${scheduleName}'`); - warnings++; - continue; - } + // Handle both string (name only) and object (with settings) formats + const destName = typeof notif === "string" ? notif : notif.name; const destSlug = slugify(destName, { lower: true, strict: true }); const dest = destinationBySlug.get(destSlug); if (!dest) { @@ -402,10 +361,10 @@ function buildScheduleNotificationAssignments( 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, - notifyOnFailure: isRecord(notif) && typeof notif.notifyOnFailure === "boolean" ? notif.notifyOnFailure : true, + notifyOnStart: typeof notif === "object" && notif.notifyOnStart !== undefined ? notif.notifyOnStart : true, + notifyOnSuccess: typeof notif === "object" && notif.notifyOnSuccess !== undefined ? notif.notifyOnSuccess : true, + notifyOnWarning: typeof notif === "object" && notif.notifyOnWarning !== undefined ? notif.notifyOnWarning : true, + notifyOnFailure: typeof notif === "object" && notif.notifyOnFailure !== undefined ? notif.notifyOnFailure : true, }); } @@ -415,7 +374,7 @@ function buildScheduleNotificationAssignments( async function attachScheduleNotifications( scheduleId: number, scheduleName: string, - notifications: unknown[], + notifications: ScheduleNotificationImport[], destinationBySlug: Map, notificationsServiceModule: typeof import("../notifications/notifications.service"), ): Promise { @@ -467,9 +426,9 @@ async function attachScheduleNotifications( return result; } -async function importBackupSchedules(backupSchedules: unknown[]): Promise { +async function importBackupSchedules(backupSchedules: BackupScheduleImport[]): Promise { const result: ImportResult = { succeeded: 0, skipped: 0, warnings: 0, errors: 0 }; - if (!Array.isArray(backupSchedules) || backupSchedules.length === 0) return result; + if (backupSchedules.length === 0) return result; const backupServiceModule = await import("../backups/backups.service"); const notificationsServiceModule = await import("../notifications/notifications.service"); @@ -485,44 +444,23 @@ async function importBackupSchedules(backupSchedules: unknown[]): Promise [s.name, s] as const)); for (const s of backupSchedules) { - if (!isRecord(s)) { - continue; - } - const volumeName = getScheduleVolumeName(s); - if (typeof volumeName !== "string" || volumeName.length === 0) { - logger.warn("Backup schedule not processed: Missing volume name"); - result.warnings++; - continue; - } - // Volume names are stored slugified - const volumeSlug = slugify(volumeName, { lower: true, strict: true }); + const volumeSlug = slugify(s.volume, { lower: true, strict: true }); const volume = volumeByName.get(volumeSlug); if (!volume) { - logger.warn(`Backup schedule not processed: Volume '${volumeName}' not found`); + logger.warn(`Backup schedule not processed: Volume '${s.volume}' not found`); result.warnings++; continue; } - const repositoryName = getScheduleRepositoryName(s); - if (typeof repositoryName !== "string" || repositoryName.length === 0) { - logger.warn("Backup schedule not processed: Missing repository name"); - result.warnings++; - continue; - } // Repository names are stored trimmed - const repository = repoByName.get(repositoryName.trim()); + const repository = repoByName.get(s.repository.trim()); if (!repository) { - logger.warn(`Backup schedule not processed: Repository '${repositoryName}' not found`); + logger.warn(`Backup schedule not processed: Repository '${s.repository}' not found`); result.warnings++; continue; } - const scheduleName = typeof s.name === "string" && s.name.length > 0 ? s.name : `${volumeName}-${repositoryName}`; - if (typeof s.cronExpression !== "string" || s.cronExpression.length === 0) { - logger.warn(`Backup schedule not processed: Missing cronExpression for '${scheduleName}'`); - result.warnings++; - continue; - } + const scheduleName = s.name && s.name.length > 0 ? s.name : `${s.volume}-${s.repository}`; // Check if schedule already exists - if so, skip creation but still try attachments const existingSchedule = scheduleByName.get(scheduleName); @@ -547,23 +485,20 @@ async function importBackupSchedules(backupSchedules: unknown[]): Promise undefined + excludePatterns: s.excludePatterns ?? [], + excludeIfPresent: s.excludeIfPresent ?? [], + includePatterns: s.includePatterns ?? [], + oneFileSystem: s.oneFileSystem, }, - providedShortId, + s.shortId, ); logger.info(`Initialized backup schedule from config: ${scheduleName}`); result.succeeded++; @@ -606,7 +541,7 @@ async function importBackupSchedules(backupSchedules: unknown[]): Promise, backupServiceModule: typeof import("../backups/backups.service"), ): Promise { @@ -622,34 +557,18 @@ async function attachScheduleMirrors( }> = []; 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 for schedule '${scheduleName}'`); - result.warnings++; - continue; - } - - // Repository names are stored trimmed - const repo = repoByName.get(repoName.trim()); + // Schema ensures repository is a non-empty string + const repo = repoByName.get(m.repository.trim()); if (!repo) { - logger.warn(`Mirror repository '${repoName}' not found for schedule '${scheduleName}'`); + logger.warn(`Mirror repository '${m.repository}' not found for schedule '${scheduleName}'`); result.warnings++; continue; } mirrorConfigs.push({ repositoryId: repo.id, - repositoryName: repo.name, - enabled: typeof m.enabled === "boolean" ? m.enabled : true, + repositoryName: m.repository, + enabled: m.enabled ?? true, }); } @@ -685,20 +604,20 @@ async function attachScheduleMirrors( return result; } -async function importUsers(users: unknown[], recoveryKey: string | null): Promise { +async function importUsers(users: UserImport[], recoveryKey: string | undefined): 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) { - if (Array.isArray(users) && users.length > 0) { + if (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 === 0) return result; if (users.length > 1) { logger.warn( @@ -708,24 +627,12 @@ async function importUsers(users: unknown[], recoveryKey: string | null): Promis } for (const u of users) { - 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) { + if (u.passwordHash) { try { await db.insert(usersTable).values({ username: u.username, passwordHash: u.passwordHash, - hasDownloadedResticPassword: - typeof u.hasDownloadedResticPassword === "boolean" ? u.hasDownloadedResticPassword : Boolean(recoveryKey), + hasDownloadedResticPassword: u.hasDownloadedResticPassword ?? Boolean(recoveryKey), }); logger.info(`User '${u.username}' imported with password hash from config.`); result.succeeded++; @@ -738,11 +645,10 @@ async function importUsers(users: unknown[], recoveryKey: string | null): Promis continue; } - if (typeof u.password === "string" && u.password.length > 0) { + if (u.password) { try { const { user } = await authService.register(u.username, u.password); - const hasDownloadedResticPassword = - typeof u.hasDownloadedResticPassword === "boolean" ? u.hasDownloadedResticPassword : Boolean(recoveryKey); + const hasDownloadedResticPassword = u.hasDownloadedResticPassword ?? Boolean(recoveryKey); if (hasDownloadedResticPassword) { await db.update(usersTable).set({ hasDownloadedResticPassword }).where(eq(usersTable.id, user.id)); } @@ -783,11 +689,11 @@ async function runImport(config: ImportConfig, options: ImportOptions = {}): Pro 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)); + 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; } @@ -809,13 +715,34 @@ function logImportSummary(result: ImportResult): void { } } +export type ApplyConfigResult = + | { success: true; result: ImportResult } + | { success: false; validationErrors: ConfigValidationError[] }; + /** * Import configuration from a raw config object (used by CLI) + * Returns validation errors upfront if the config doesn't match the schema. */ -export async function applyConfigImport(configRaw: unknown, options: ImportOptions = {}): Promise { +export async function applyConfigImport(configRaw: unknown, options: ImportOptions = {}): Promise { logger.info("Starting config import..."); - const config = parseImportConfig(configRaw); - const result = await runImport(config, options); + + const parseResult = parseImportConfig(configRaw); + if (!parseResult.success) { + for (const error of parseResult.errors) { + logger.error(`Validation error at ${error.path}: ${error.message}`); + } + return { success: false, validationErrors: parseResult.errors }; + } + + const result = await runImport(parseResult.config, options); logImportSummary(result); - return result; + return { success: true, result }; +} + +/** + * Validate configuration without importing (used by CLI --dry-run) + * Returns validation errors if the config doesn't match the schema. + */ +export function validateConfig(configRaw: unknown): ParseConfigResult { + return parseImportConfig(configRaw); } diff --git a/examples/config-file-import/README.md b/examples/config-file-import/README.md index 978b7a82..497d9c5b 100644 --- a/examples/config-file-import/README.md +++ b/examples/config-file-import/README.md @@ -416,7 +416,7 @@ Note: The `path` must point directly to the restic repository root (the director "notifications": ["slack-alerts", "email-admin"], "mirrors": [ { "repository": "s3-repo" }, - { "repository": "lo2" } + { "repository": "azure-repo" } ] } ``` @@ -459,7 +459,7 @@ Each mirror references a repository by name: ```json "mirrors": [ { "repository": "s3-repo" }, - { "repository": "lo2", "enabled": false } + { "repository": "azure-repo", "enabled": false } ] ```