refactor: improve config import logging, add CLI JSON output and idempotent re-runs
- Add `--json` flag for machine-readable JSON output - Add `--log-level` flag to control logging verbosity - Add `skipped` counter to ImportResult for idempotent operations - Change "already exists" conditions from warnings to skipped (info logs) - Recovery key mismatch now errors and stops import early - Pre-check volumes and notification destinations before creation - Attachment functions merge missing items instead of overwriting - Add toError() and mergeResults() helpers to reduce code duplication - Extract readConfigJson() and createOutput() for cleaner CLI code - Move fs/path imports to top level in config-import.ts
This commit is contained in:
parent
16ef4b4861
commit
60c0778b74
2 changed files with 345 additions and 162 deletions
|
|
@ -2,6 +2,10 @@ import { Command } from "commander";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
|
||||||
|
const toError = (e: unknown): Error => (e instanceof Error ? e : new Error(String(e)));
|
||||||
|
|
||||||
|
type Output = ReturnType<typeof createOutput>;
|
||||||
|
|
||||||
async function readStdin(): Promise<string> {
|
async function readStdin(): Promise<string> {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
for await (const chunk of process.stdin) {
|
for await (const chunk of process.stdin) {
|
||||||
|
|
@ -10,85 +14,118 @@ async function readStdin(): Promise<string> {
|
||||||
return Buffer.concat(chunks).toString("utf-8");
|
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<string> {
|
||||||
|
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")
|
export const importConfigCommand = new Command("import-config")
|
||||||
.description("Import configuration from a JSON file or stdin")
|
.description("Import configuration from a JSON file or stdin")
|
||||||
.option("-c, --config <path>", "Path to the configuration file")
|
.option("-c, --config <path>", "Path to the configuration file")
|
||||||
.option("--stdin", "Read configuration from stdin")
|
.option("--stdin", "Read configuration from stdin")
|
||||||
.option("--dry-run", "Validate the config without importing")
|
.option("--dry-run", "Validate the config without importing")
|
||||||
|
.option("--json", "Output results in JSON format")
|
||||||
|
.option("--log-level <level>", "Set log level (debug, info, warn, error)")
|
||||||
.action(async (options) => {
|
.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) {
|
if (!options.config && !options.stdin) {
|
||||||
console.error("❌ Either --config <path> or --stdin is required");
|
if (!jsonOutput) {
|
||||||
console.log("\nUsage:");
|
console.log("\nUsage:");
|
||||||
console.log(" zerobyte import-config --config /path/to/config.json");
|
console.log(" zerobyte import-config --config /path/to/config.json");
|
||||||
console.log(" cat config.json | zerobyte import-config --stdin");
|
console.log(" cat config.json | zerobyte import-config --stdin");
|
||||||
process.exit(1);
|
}
|
||||||
|
out.error("Either --config <path> or --stdin is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.config && options.stdin) {
|
if (options.config && options.stdin) {
|
||||||
console.error("❌ Cannot use both --config and --stdin");
|
out.error("Cannot use both --config and --stdin");
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let configJson: string;
|
const configJson = await readConfigJson(options, out);
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse and validate JSON
|
// Parse and validate JSON
|
||||||
let config: unknown;
|
let config: unknown;
|
||||||
try {
|
try {
|
||||||
config = JSON.parse(configJson);
|
config = JSON.parse(configJson);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e instanceof Error ? e : new Error(String(e));
|
out.error(`Invalid JSON: ${toError(e).message}`);
|
||||||
console.error(`❌ Invalid JSON: ${err.message}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.dryRun) {
|
if (options.dryRun) {
|
||||||
console.log("🔍 Dry run mode - validating config only\n");
|
|
||||||
|
|
||||||
const root = typeof config === "object" && config !== null ? config : {};
|
const root = typeof config === "object" && config !== null ? config : {};
|
||||||
const configObj =
|
const configObj =
|
||||||
"config" in root && typeof root.config === "object" && root.config !== null ? root.config : root;
|
"config" in root && typeof root.config === "object" && root.config !== null ? root.config : root;
|
||||||
|
|
||||||
const sections = ["volumes", "repositories", "backupSchedules", "notificationDestinations", "users"];
|
const sections = ["volumes", "repositories", "backupSchedules", "notificationDestinations", "users"];
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
for (const section of sections) {
|
for (const section of sections) {
|
||||||
const items = (configObj as Record<string, unknown>)[section] || [];
|
const items = (configObj as Record<string, unknown>)[section] || [];
|
||||||
const count = Array.isArray(items) ? items.length : 0;
|
counts[section] = Array.isArray(items) ? items.length : 0;
|
||||||
console.log(` ${section}: ${count} item(s)`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasRecoveryKey = !!(configObj as Record<string, unknown>).recoveryKey;
|
const hasRecoveryKey = !!(configObj as Record<string, unknown>).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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,13 +137,13 @@ export const importConfigCommand = new Command("import-config")
|
||||||
const { applyConfigImport } = await import("../../modules/lifecycle/config-import");
|
const { applyConfigImport } = await import("../../modules/lifecycle/config-import");
|
||||||
const result = await applyConfigImport(config);
|
const result = await applyConfigImport(config);
|
||||||
|
|
||||||
|
out.json({ ...result, success: result.errors === 0 });
|
||||||
|
|
||||||
// Exit with error code if there were errors
|
// Exit with error code if there were errors
|
||||||
if (result.errors > 0) {
|
if (result.errors > 0) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e instanceof Error ? e : new Error(String(e));
|
out.error(`Import failed: ${toError(e).message}`);
|
||||||
console.error(`❌ Import failed: ${err.message}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
import slugify from "slugify";
|
import slugify from "slugify";
|
||||||
import { db } from "../../db/db";
|
import { db } from "../../db/db";
|
||||||
import { usersTable } from "../../db/schema";
|
import { usersTable } from "../../db/schema";
|
||||||
|
|
@ -10,6 +12,8 @@ import type { BackendConfig } from "~/schemas/volumes";
|
||||||
|
|
||||||
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;
|
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;
|
||||||
|
|
||||||
|
const toError = (e: unknown): Error => (e instanceof Error ? e : new Error(String(e)));
|
||||||
|
|
||||||
const asStringArray = (value: unknown): string[] => {
|
const asStringArray = (value: unknown): string[] => {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
return value.filter((item): item is string => typeof item === "string");
|
return value.filter((item): item is string => typeof item === "string");
|
||||||
|
|
@ -36,6 +40,7 @@ type ImportConfig = {
|
||||||
|
|
||||||
export type ImportResult = {
|
export type ImportResult = {
|
||||||
succeeded: number;
|
succeeded: number;
|
||||||
|
skipped: number;
|
||||||
warnings: number;
|
warnings: number;
|
||||||
errors: number;
|
errors: number;
|
||||||
};
|
};
|
||||||
|
|
@ -62,8 +67,6 @@ function interpolateEnvVars(value: unknown): unknown {
|
||||||
async function loadConfigFromFile(): Promise<unknown | null> {
|
async function loadConfigFromFile(): Promise<unknown | null> {
|
||||||
try {
|
try {
|
||||||
const configPath = process.env.ZEROBYTE_CONFIG_PATH || "zerobyte.config.json";
|
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);
|
const configFullPath = path.resolve(process.cwd(), configPath);
|
||||||
try {
|
try {
|
||||||
const raw = await fs.readFile(configFullPath, "utf-8");
|
const raw = await fs.readFile(configFullPath, "utf-8");
|
||||||
|
|
@ -73,8 +76,7 @@ async function loadConfigFromFile(): Promise<unknown | null> {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error instanceof Error ? error : new Error(String(error));
|
logger.warn(`No config file loaded or error parsing config: ${toError(error).message}`);
|
||||||
logger.warn(`No config file loaded or error parsing config: ${err.message}`);
|
|
||||||
return null;
|
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<ImportResult> {
|
async function writeRecoveryKeyFromConfig(recoveryKey: string | null): Promise<ImportResult> {
|
||||||
const result: ImportResult = { succeeded: 0, warnings: 0, errors: 0 };
|
const result: ImportResult = { succeeded: 0, skipped: 0, warnings: 0, errors: 0 };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fs = await import("node:fs/promises");
|
|
||||||
const { RESTIC_PASS_FILE } = await import("../../core/constants.js");
|
const { RESTIC_PASS_FILE } = await import("../../core/constants.js");
|
||||||
if (!recoveryKey) return result;
|
if (!recoveryKey) return result;
|
||||||
|
|
||||||
|
|
@ -116,16 +124,22 @@ async function writeRecoveryKeyFromConfig(recoveryKey: string | null): Promise<I
|
||||||
() => false,
|
() => false,
|
||||||
);
|
);
|
||||||
if (passFileExists) {
|
if (passFileExists) {
|
||||||
logger.warn(`Restic passfile already exists at ${RESTIC_PASS_FILE}; skipping config recovery key write`);
|
// Check if existing key matches the one being imported
|
||||||
result.warnings++;
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
await fs.writeFile(RESTIC_PASS_FILE, recoveryKey, { mode: 0o600 });
|
await fs.writeFile(RESTIC_PASS_FILE, recoveryKey, { mode: 0o600 });
|
||||||
logger.info(`Recovery key written from config to ${RESTIC_PASS_FILE}`);
|
logger.info(`Recovery key written from config to ${RESTIC_PASS_FILE}`);
|
||||||
result.succeeded++;
|
result.succeeded++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err instanceof Error ? err : new Error(String(err));
|
logger.error(`Failed to write recovery key from config: ${toError(err).message}`);
|
||||||
logger.error(`Failed to write recovery key from config: ${e.message}`);
|
|
||||||
result.errors++;
|
result.errors++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,13 +147,24 @@ async function writeRecoveryKeyFromConfig(recoveryKey: string | null): Promise<I
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importVolumes(volumes: unknown[]): Promise<ImportResult> {
|
async function importVolumes(volumes: unknown[]): Promise<ImportResult> {
|
||||||
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) {
|
for (const v of volumes) {
|
||||||
try {
|
try {
|
||||||
if (!isRecord(v) || typeof v.name !== "string" || !isRecord(v.config) || typeof v.config.backend !== "string") {
|
if (!isRecord(v) || typeof v.name !== "string" || !isRecord(v.config) || typeof v.config.backend !== "string") {
|
||||||
throw new Error("Invalid volume entry");
|
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);
|
await volumeService.createVolume(v.name, v.config as BackendConfig);
|
||||||
logger.info(`Initialized volume from config: ${v.name}`);
|
logger.info(`Initialized volume from config: ${v.name}`);
|
||||||
result.succeeded++;
|
result.succeeded++;
|
||||||
|
|
@ -150,8 +175,8 @@ async function importVolumes(volumes: unknown[]): Promise<ImportResult> {
|
||||||
logger.info(`Set autoRemount=false for volume: ${v.name}`);
|
logger.info(`Set autoRemount=false for volume: ${v.name}`);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e instanceof Error ? e : new Error(String(e));
|
const volumeName = isRecord(v) && typeof v.name === "string" ? v.name : "unknown";
|
||||||
logger.warn(`Volume not created: ${err.message}`);
|
logger.warn(`Volume '${volumeName}' not created: ${toError(e).message}`);
|
||||||
result.warnings++;
|
result.warnings++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -160,7 +185,7 @@ async function importVolumes(volumes: unknown[]): Promise<ImportResult> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importRepositories(repositories: unknown[]): Promise<ImportResult> {
|
async function importRepositories(repositories: unknown[]): Promise<ImportResult> {
|
||||||
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 repoServiceModule = await import("../repositories/repositories.service");
|
||||||
const { buildRepoUrl, restic } = await import("../../utils/restic");
|
const { buildRepoUrl, restic } = await import("../../utils/restic");
|
||||||
|
|
||||||
|
|
@ -175,8 +200,7 @@ async function importRepositories(repositories: unknown[]): Promise<ImportResult
|
||||||
const url = buildRepoUrl(repo.config as RepositoryConfig);
|
const url = buildRepoUrl(repo.config as RepositoryConfig);
|
||||||
existingUrls.add(url);
|
existingUrls.add(url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e instanceof Error ? e : new Error(String(e));
|
logger.warn(`Could not build URL for existing repository '${repo.name}': ${toError(e).message}`);
|
||||||
logger.warn(`Could not build URL for existing repository '${repo.name}': ${err.message}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,13 +214,12 @@ async function importRepositories(repositories: unknown[]): Promise<ImportResult
|
||||||
try {
|
try {
|
||||||
const incomingUrl = buildRepoUrl(r.config as RepositoryConfig);
|
const incomingUrl = buildRepoUrl(r.config as RepositoryConfig);
|
||||||
if (existingUrls.has(incomingUrl)) {
|
if (existingUrls.has(incomingUrl)) {
|
||||||
logger.warn(`Skipping '${r.name}': another repository is already registered for location ${incomingUrl}`);
|
logger.info(`Repository '${r.name}': another repository already registered for location ${incomingUrl}`);
|
||||||
result.warnings++;
|
result.skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e instanceof Error ? e : new Error(String(e));
|
logger.warn(`Could not build URL for '${r.name}' to check duplicates: ${toError(e).message}`);
|
||||||
logger.warn(`Could not build URL for '${r.name}' to check duplicates: ${err.message}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For local repos without isExistingRepository, check if the provided path is already a restic repo
|
// For local repos without isExistingRepository, check if the provided path is already a restic repo
|
||||||
|
|
@ -206,8 +229,7 @@ async function importRepositories(repositories: unknown[]): Promise<ImportResult
|
||||||
.snapshots({ ...r.config, isExistingRepository: true } as RepositoryConfig)
|
.snapshots({ ...r.config, isExistingRepository: true } as RepositoryConfig)
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
const err = e instanceof Error ? e : new Error(String(e));
|
logger.debug(`Repo existence check for '${r.name}': ${toError(e).message}`);
|
||||||
logger.debug(`Repo existence check for '${r.name}': ${err.message}`);
|
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -223,8 +245,8 @@ async function importRepositories(repositories: unknown[]): Promise<ImportResult
|
||||||
|
|
||||||
// Skip if a repository with the same name already exists (fallback for repos without deterministic paths)
|
// Skip if a repository with the same name already exists (fallback for repos without deterministic paths)
|
||||||
if (existingNames.has(r.name)) {
|
if (existingNames.has(r.name)) {
|
||||||
logger.warn(`Skipping '${r.name}': a repository with this name already exists`);
|
logger.info(`Repository '${r.name}': a repository with this name already exists`);
|
||||||
result.warnings++;
|
result.skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -240,8 +262,8 @@ async function importRepositories(repositories: unknown[]): Promise<ImportResult
|
||||||
logger.info(`Initialized repository from config: ${r.name}`);
|
logger.info(`Initialized repository from config: ${r.name}`);
|
||||||
result.succeeded++;
|
result.succeeded++;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e instanceof Error ? e : new Error(String(e));
|
const repoName = isRecord(r) && typeof r.name === "string" ? r.name : "unknown";
|
||||||
logger.warn(`Repository not created: ${err.message}`);
|
logger.warn(`Repository '${repoName}' not created: ${toError(e).message}`);
|
||||||
result.warnings++;
|
result.warnings++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -250,13 +272,26 @@ async function importRepositories(repositories: unknown[]): Promise<ImportResult
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importNotificationDestinations(notificationDestinations: unknown[]): Promise<ImportResult> {
|
async function importNotificationDestinations(notificationDestinations: unknown[]): Promise<ImportResult> {
|
||||||
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");
|
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) {
|
for (const n of notificationDestinations) {
|
||||||
try {
|
try {
|
||||||
if (!isRecord(n) || typeof n.name !== "string" || !isRecord(n.config) || typeof n.config.type !== "string") {
|
if (!isRecord(n) || typeof n.name !== "string" || !isRecord(n.config) || typeof n.config.type !== "string") {
|
||||||
throw new Error("Invalid notification destination entry");
|
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(
|
const created = await notificationsServiceModule.notificationsService.createDestination(
|
||||||
n.name,
|
n.name,
|
||||||
n.config as NotificationConfig,
|
n.config as NotificationConfig,
|
||||||
|
|
@ -270,8 +305,8 @@ async function importNotificationDestinations(notificationDestinations: unknown[
|
||||||
logger.info(`Set enabled=false for notification destination: ${n.name}`);
|
logger.info(`Set enabled=false for notification destination: ${n.name}`);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e instanceof Error ? e : new Error(String(e));
|
const destName = isRecord(n) && typeof n.name === "string" ? n.name : "unknown";
|
||||||
logger.warn(`Notification destination not created: ${err.message}`);
|
logger.warn(`Notification destination '${destName}' not created: ${toError(e).message}`);
|
||||||
result.warnings++;
|
result.warnings++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -297,6 +332,7 @@ function getScheduleRepositoryName(schedule: Record<string, unknown>): string |
|
||||||
|
|
||||||
type ScheduleNotificationAssignment = {
|
type ScheduleNotificationAssignment = {
|
||||||
destinationId: number;
|
destinationId: number;
|
||||||
|
destinationName: string;
|
||||||
notifyOnStart: boolean;
|
notifyOnStart: boolean;
|
||||||
notifyOnSuccess: boolean;
|
notifyOnSuccess: boolean;
|
||||||
notifyOnWarning: boolean;
|
notifyOnWarning: boolean;
|
||||||
|
|
@ -304,25 +340,30 @@ type ScheduleNotificationAssignment = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildScheduleNotificationAssignments(
|
function buildScheduleNotificationAssignments(
|
||||||
|
scheduleName: string,
|
||||||
notifications: unknown[],
|
notifications: unknown[],
|
||||||
destinationBySlug: Map<string, { id: number; name: string }>,
|
destinationBySlug: Map<string, { id: number; name: string }>,
|
||||||
): ScheduleNotificationAssignment[] {
|
): { assignments: ScheduleNotificationAssignment[]; warnings: number } {
|
||||||
const assignments: ScheduleNotificationAssignment[] = [];
|
const assignments: ScheduleNotificationAssignment[] = [];
|
||||||
|
let warnings = 0;
|
||||||
|
|
||||||
for (const notif of notifications) {
|
for (const notif of notifications) {
|
||||||
const destName = typeof notif === "string" ? notif : isRecord(notif) ? notif.name : null;
|
const destName = typeof notif === "string" ? notif : isRecord(notif) ? notif.name : null;
|
||||||
if (typeof destName !== "string" || destName.length === 0) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
const destSlug = slugify(destName, { lower: true, strict: true });
|
const destSlug = slugify(destName, { lower: true, strict: true });
|
||||||
const dest = destinationBySlug.get(destSlug);
|
const dest = destinationBySlug.get(destSlug);
|
||||||
if (!dest) {
|
if (!dest) {
|
||||||
logger.warn(`Notification destination '${destName}' not found for schedule`);
|
logger.warn(`Notification destination '${destName}' not found for schedule '${scheduleName}'`);
|
||||||
|
warnings++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
assignments.push({
|
assignments.push({
|
||||||
destinationId: dest.id,
|
destinationId: dest.id,
|
||||||
|
destinationName: dest.name,
|
||||||
notifyOnStart: isRecord(notif) && typeof notif.notifyOnStart === "boolean" ? notif.notifyOnStart : true,
|
notifyOnStart: isRecord(notif) && typeof notif.notifyOnStart === "boolean" ? notif.notifyOnStart : true,
|
||||||
notifyOnSuccess: isRecord(notif) && typeof notif.notifyOnSuccess === "boolean" ? notif.notifyOnSuccess : true,
|
notifyOnSuccess: isRecord(notif) && typeof notif.notifyOnSuccess === "boolean" ? notif.notifyOnSuccess : true,
|
||||||
notifyOnWarning: isRecord(notif) && typeof notif.notifyOnWarning === "boolean" ? notif.notifyOnWarning : 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(
|
async function attachScheduleNotifications(
|
||||||
scheduleId: number,
|
scheduleId: number,
|
||||||
|
scheduleName: string,
|
||||||
notifications: unknown[],
|
notifications: unknown[],
|
||||||
destinationBySlug: Map<string, { id: number; name: string }>,
|
destinationBySlug: Map<string, { id: number; name: string }>,
|
||||||
notificationsServiceModule: typeof import("../notifications/notifications.service"),
|
notificationsServiceModule: typeof import("../notifications/notifications.service"),
|
||||||
): Promise<void> {
|
): Promise<ImportResult> {
|
||||||
|
const result: ImportResult = { succeeded: 0, skipped: 0, warnings: 0, errors: 0 };
|
||||||
try {
|
try {
|
||||||
const assignments = buildScheduleNotificationAssignments(notifications, destinationBySlug);
|
const existingNotifications =
|
||||||
if (assignments.length === 0) return;
|
await notificationsServiceModule.notificationsService.getScheduleNotifications(scheduleId);
|
||||||
|
const existingDestIds = new Set(existingNotifications.map((n) => n.destinationId));
|
||||||
|
|
||||||
await notificationsServiceModule.notificationsService.updateScheduleNotifications(scheduleId, assignments);
|
const { assignments, warnings } = buildScheduleNotificationAssignments(
|
||||||
logger.info(`Assigned ${assignments.length} notification(s) to backup schedule`);
|
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) {
|
} catch (e) {
|
||||||
const err = e instanceof Error ? e : new Error(String(e));
|
logger.warn(`Failed to assign notifications to schedule '${scheduleName}': ${toError(e).message}`);
|
||||||
logger.warn(`Failed to assign notifications to schedule: ${err.message}`);
|
result.warnings++;
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importBackupSchedules(backupSchedules: unknown[]): Promise<ImportResult> {
|
async function importBackupSchedules(backupSchedules: unknown[]): Promise<ImportResult> {
|
||||||
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;
|
if (!Array.isArray(backupSchedules) || backupSchedules.length === 0) return result;
|
||||||
|
|
||||||
const backupServiceModule = await import("../backups/backups.service");
|
const backupServiceModule = await import("../backups/backups.service");
|
||||||
|
|
@ -361,10 +439,12 @@ async function importBackupSchedules(backupSchedules: unknown[]): Promise<Import
|
||||||
const volumes = await db.query.volumesTable.findMany();
|
const volumes = await db.query.volumesTable.findMany();
|
||||||
const repositories = await db.query.repositoriesTable.findMany();
|
const repositories = await db.query.repositoriesTable.findMany();
|
||||||
const destinations = await db.query.notificationDestinationsTable.findMany();
|
const destinations = await db.query.notificationDestinationsTable.findMany();
|
||||||
|
const existingSchedules = await db.query.backupSchedulesTable.findMany();
|
||||||
|
|
||||||
const volumeByName = new Map(volumes.map((v) => [v.name, v] as const));
|
const volumeByName = new Map(volumes.map((v) => [v.name, v] as const));
|
||||||
const repoByName = new Map(repositories.map((r) => [r.name, r] 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 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) {
|
for (const s of backupSchedules) {
|
||||||
if (!isRecord(s)) {
|
if (!isRecord(s)) {
|
||||||
|
|
@ -372,85 +452,105 @@ async function importBackupSchedules(backupSchedules: unknown[]): Promise<Import
|
||||||
}
|
}
|
||||||
const volumeName = getScheduleVolumeName(s);
|
const volumeName = getScheduleVolumeName(s);
|
||||||
if (typeof volumeName !== "string" || volumeName.length === 0) {
|
if (typeof volumeName !== "string" || volumeName.length === 0) {
|
||||||
logger.warn("Backup schedule not created: Missing volume name");
|
logger.warn("Backup schedule not processed: Missing volume name");
|
||||||
result.warnings++;
|
result.warnings++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const volume = volumeByName.get(volumeName);
|
const volume = volumeByName.get(volumeName);
|
||||||
if (!volume) {
|
if (!volume) {
|
||||||
logger.warn(`Backup schedule not created: Volume '${volumeName}' not found`);
|
logger.warn(`Backup schedule not processed: Volume '${volumeName}' not found`);
|
||||||
result.warnings++;
|
result.warnings++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const repositoryName = getScheduleRepositoryName(s);
|
const repositoryName = getScheduleRepositoryName(s);
|
||||||
if (typeof repositoryName !== "string" || repositoryName.length === 0) {
|
if (typeof repositoryName !== "string" || repositoryName.length === 0) {
|
||||||
logger.warn("Backup schedule not created: Missing repository name");
|
logger.warn("Backup schedule not processed: Missing repository name");
|
||||||
result.warnings++;
|
result.warnings++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const repository = repoByName.get(repositoryName);
|
const repository = repoByName.get(repositoryName);
|
||||||
if (!repository) {
|
if (!repository) {
|
||||||
logger.warn(`Backup schedule not created: Repository '${repositoryName}' not found`);
|
logger.warn(`Backup schedule not processed: Repository '${repositoryName}' not found`);
|
||||||
result.warnings++;
|
result.warnings++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduleName = typeof s.name === "string" && s.name.length > 0 ? s.name : `${volumeName}-${repositoryName}`;
|
const scheduleName = typeof s.name === "string" && s.name.length > 0 ? s.name : `${volumeName}-${repositoryName}`;
|
||||||
if (typeof s.cronExpression !== "string" || s.cronExpression.length === 0) {
|
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++;
|
result.warnings++;
|
||||||
continue;
|
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 {
|
try {
|
||||||
await volumeService.mountVolume(volume.name);
|
const retentionPolicy = isRecord(s.retentionPolicy) ? (s.retentionPolicy as RetentionPolicy) : undefined;
|
||||||
volumeByName.set(volume.name, { ...volume, status: "mounted" });
|
const createdSchedule = await backupServiceModule.backupsService.createSchedule({
|
||||||
logger.info(`Mounted volume ${volume.name} for backup schedule`);
|
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) {
|
} catch (e) {
|
||||||
const err = e instanceof Error ? e : new Error(String(e));
|
logger.warn(`Backup schedule '${scheduleName}' not created: ${toError(e).message}`);
|
||||||
logger.warn(`Could not mount volume ${volume.name}: ${err.message}`);
|
|
||||||
result.warnings++;
|
result.warnings++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let createdSchedule: { id: number } | null = null;
|
// Attach notifications (checks if already attached)
|
||||||
try {
|
if (Array.isArray(s.notifications) && s.notifications.length > 0) {
|
||||||
const retentionPolicy = isRecord(s.retentionPolicy) ? (s.retentionPolicy as RetentionPolicy) : undefined;
|
const notifResult = await attachScheduleNotifications(
|
||||||
createdSchedule = await backupServiceModule.backupsService.createSchedule({
|
scheduleId,
|
||||||
name: scheduleName,
|
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,
|
|
||||||
s.notifications,
|
s.notifications,
|
||||||
destinationBySlug,
|
destinationBySlug,
|
||||||
notificationsServiceModule,
|
notificationsServiceModule,
|
||||||
);
|
);
|
||||||
|
mergeResults(result, notifResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (createdSchedule && Array.isArray(s.mirrors) && s.mirrors.length > 0) {
|
// Attach mirrors (checks if already attached)
|
||||||
await attachScheduleMirrors(createdSchedule.id, s.mirrors, repoByName, backupServiceModule);
|
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<Import
|
||||||
|
|
||||||
async function attachScheduleMirrors(
|
async function attachScheduleMirrors(
|
||||||
scheduleId: number,
|
scheduleId: number,
|
||||||
|
scheduleName: string,
|
||||||
mirrors: unknown[],
|
mirrors: unknown[],
|
||||||
repoByName: Map<string, { id: string; name: string }>,
|
repoByName: Map<string, { id: string; name: string }>,
|
||||||
backupServiceModule: typeof import("../backups/backups.service"),
|
backupServiceModule: typeof import("../backups/backups.service"),
|
||||||
): Promise<void> {
|
): Promise<ImportResult> {
|
||||||
|
const result: ImportResult = { succeeded: 0, skipped: 0, warnings: 0, errors: 0 };
|
||||||
try {
|
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) {
|
for (const m of mirrors) {
|
||||||
if (!isRecord(m)) continue;
|
if (!isRecord(m)) continue;
|
||||||
|
|
@ -478,39 +583,70 @@ async function attachScheduleMirrors(
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!repoName) {
|
if (!repoName) {
|
||||||
logger.warn("Mirror missing repository name; skipping");
|
logger.warn(`Mirror missing repository name for schedule '${scheduleName}'`);
|
||||||
|
result.warnings++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const repo = repoByName.get(repoName);
|
const repo = repoByName.get(repoName);
|
||||||
if (!repo) {
|
if (!repo) {
|
||||||
logger.warn(`Mirror repository '${repoName}' not found; skipping`);
|
logger.warn(`Mirror repository '${repoName}' not found for schedule '${scheduleName}'`);
|
||||||
|
result.warnings++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
mirrorConfigs.push({
|
mirrorConfigs.push({
|
||||||
repositoryId: repo.id,
|
repositoryId: repo.id,
|
||||||
|
repositoryName: repo.name,
|
||||||
enabled: typeof m.enabled === "boolean" ? m.enabled : true,
|
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 });
|
// Merge existing with new (strip repositoryName for API call)
|
||||||
logger.info(`Assigned ${mirrorConfigs.length} mirror(s) to backup schedule`);
|
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) {
|
} catch (e) {
|
||||||
const err = e instanceof Error ? e : new Error(String(e));
|
logger.warn(`Failed to assign mirrors to schedule '${scheduleName}': ${toError(e).message}`);
|
||||||
logger.warn(`Failed to assign mirrors to schedule: ${err.message}`);
|
result.warnings++;
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setupInitialUser(users: unknown[], recoveryKey: string | null): Promise<ImportResult> {
|
async function importUsers(users: unknown[], recoveryKey: string | null): Promise<ImportResult> {
|
||||||
const result: ImportResult = { succeeded: 0, warnings: 0, errors: 0 };
|
const result: ImportResult = { succeeded: 0, skipped: 0, warnings: 0, errors: 0 };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { authService } = await import("../auth/auth.service");
|
const { authService } = await import("../auth/auth.service");
|
||||||
const hasUsers = await authService.hasUsers();
|
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 (!Array.isArray(users) || users.length === 0) return result;
|
||||||
|
|
||||||
if (users.length > 1) {
|
if (users.length > 1) {
|
||||||
|
|
@ -521,8 +657,16 @@ async function setupInitialUser(users: unknown[], recoveryKey: string | null): P
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const u of users) {
|
for (const u of users) {
|
||||||
if (!isRecord(u)) continue;
|
if (!isRecord(u)) {
|
||||||
if (typeof u.username !== "string" || u.username.length === 0) continue;
|
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 (typeof u.passwordHash === "string" && u.passwordHash.length > 0) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -575,33 +719,36 @@ async function setupInitialUser(users: unknown[], recoveryKey: string | null): P
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runImport(config: ImportConfig): Promise<ImportResult> {
|
async function runImport(config: ImportConfig): Promise<ImportResult> {
|
||||||
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);
|
mergeResults(result, 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);
|
|
||||||
|
|
||||||
for (const r of [recoveryKeyResult, volumeResult, repoResult, notifResult, scheduleResult, userResult]) {
|
// Stop immediately if recovery key has errors (e.g., mismatch with existing key)
|
||||||
result.succeeded += r.succeeded;
|
if (result.errors > 0) {
|
||||||
result.warnings += r.warnings;
|
return result;
|
||||||
result.errors += r.errors;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function logImportSummary(result: ImportResult): void {
|
function logImportSummary(result: ImportResult): void {
|
||||||
|
const skippedMsg = result.skipped > 0 ? `, ${result.skipped} skipped` : "";
|
||||||
if (result.errors > 0) {
|
if (result.errors > 0) {
|
||||||
logger.error(
|
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) {
|
} else if (result.warnings > 0) {
|
||||||
logger.warn(`Config import completed with ${result.warnings} warning(s), ${result.succeeded} item(s) imported`);
|
logger.warn(
|
||||||
} else if (result.succeeded > 0) {
|
`Config import completed with ${result.warnings} warning(s), ${result.succeeded} imported${skippedMsg}`,
|
||||||
logger.info(`Config import completed successfully: ${result.succeeded} item(s) imported`);
|
);
|
||||||
|
} else if (result.succeeded > 0 || result.skipped > 0) {
|
||||||
|
logger.info(`Config import completed: ${result.succeeded} imported${skippedMsg}`);
|
||||||
} else {
|
} else {
|
||||||
logger.info("Config import completed: no items to import");
|
logger.info("Config import completed: no items to import");
|
||||||
}
|
}
|
||||||
|
|
@ -632,7 +779,6 @@ export async function applyConfigImportFromFile(): Promise<void> {
|
||||||
const result = await runImport(config);
|
const result = await runImport(config);
|
||||||
logImportSummary(result);
|
logImportSummary(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e instanceof Error ? e : new Error(String(e));
|
logger.error(`Config import failed: ${toError(e).message}`);
|
||||||
logger.error(`Config import failed: ${err.message}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue