feat: add option to overwrite recovery key during config import and prevent overwrite if database is not empty
This commit is contained in:
parent
cc9982b764
commit
88bb174423
2 changed files with 64 additions and 11 deletions
|
|
@ -69,6 +69,7 @@ export const importConfigCommand = new Command("import-config")
|
||||||
.option("--dry-run", "Validate the config without importing")
|
.option("--dry-run", "Validate the config without importing")
|
||||||
.option("--json", "Output results in JSON format")
|
.option("--json", "Output results in JSON format")
|
||||||
.option("--log-level <level>", "Set log level (debug, info, warn, error)")
|
.option("--log-level <level>", "Set log level (debug, info, warn, error)")
|
||||||
|
.option("--overwrite-recovery-key", "Overwrite existing recovery key (only allowed if database is empty)")
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
const jsonOutput = options.json;
|
const jsonOutput = options.json;
|
||||||
const out = createOutput(jsonOutput);
|
const out = createOutput(jsonOutput);
|
||||||
|
|
@ -135,7 +136,7 @@ export const importConfigCommand = new Command("import-config")
|
||||||
runDbMigrations();
|
runDbMigrations();
|
||||||
|
|
||||||
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, { overwriteRecoveryKey: options.overwriteRecoveryKey });
|
||||||
|
|
||||||
out.json({ ...result, success: result.errors === 0 });
|
out.json({ ...result, success: result.errors === 0 });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,13 @@ import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
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,
|
||||||
|
volumesTable,
|
||||||
|
repositoriesTable,
|
||||||
|
backupSchedulesTable,
|
||||||
|
notificationDestinationsTable,
|
||||||
|
} from "../../db/schema";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
import { volumeService } from "../volumes/volume.service";
|
import { volumeService } from "../volumes/volume.service";
|
||||||
import type { NotificationConfig } from "~/schemas/notifications";
|
import type { NotificationConfig } from "~/schemas/notifications";
|
||||||
|
|
@ -109,7 +115,31 @@ function mergeResults(target: ImportResult, source: ImportResult): void {
|
||||||
target.errors += source.errors;
|
target.errors += source.errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeRecoveryKeyFromConfig(recoveryKey: string | null): Promise<ImportResult> {
|
/**
|
||||||
|
* Check if the database has any records in the main tables.
|
||||||
|
* Used to prevent recovery key overwrite when data already exists.
|
||||||
|
*/
|
||||||
|
async function isDatabaseEmpty(): Promise<boolean> {
|
||||||
|
const [volumes, repositories, schedules, notifications, users] = await Promise.all([
|
||||||
|
db.select({ id: volumesTable.id }).from(volumesTable).limit(1),
|
||||||
|
db.select({ id: repositoriesTable.id }).from(repositoriesTable).limit(1),
|
||||||
|
db.select({ id: backupSchedulesTable.id }).from(backupSchedulesTable).limit(1),
|
||||||
|
db.select({ id: notificationDestinationsTable.id }).from(notificationDestinationsTable).limit(1),
|
||||||
|
db.select({ id: usersTable.id }).from(usersTable).limit(1),
|
||||||
|
]);
|
||||||
|
return (
|
||||||
|
volumes.length === 0 &&
|
||||||
|
repositories.length === 0 &&
|
||||||
|
schedules.length === 0 &&
|
||||||
|
notifications.length === 0 &&
|
||||||
|
users.length === 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeRecoveryKeyFromConfig(
|
||||||
|
recoveryKey: string | null,
|
||||||
|
overwriteRecoveryKey: boolean,
|
||||||
|
): Promise<ImportResult> {
|
||||||
const result: ImportResult = { succeeded: 0, skipped: 0, warnings: 0, errors: 0 };
|
const result: ImportResult = { succeeded: 0, skipped: 0, warnings: 0, errors: 0 };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -129,11 +159,29 @@ async function writeRecoveryKeyFromConfig(recoveryKey: string | null): Promise<I
|
||||||
if (existingKey.trim() === recoveryKey) {
|
if (existingKey.trim() === recoveryKey) {
|
||||||
logger.info("Recovery key already configured with matching value");
|
logger.info("Recovery key already configured with matching value");
|
||||||
result.skipped++;
|
result.skipped++;
|
||||||
} else {
|
return result;
|
||||||
logger.error("Recovery key already exists with different value; cannot overwrite");
|
|
||||||
result.errors++;
|
|
||||||
}
|
}
|
||||||
return result;
|
|
||||||
|
// Key exists with different value - check if overwrite is allowed
|
||||||
|
if (!overwriteRecoveryKey) {
|
||||||
|
logger.error("Recovery key already exists with different value; use --overwrite-recovery-key to replace");
|
||||||
|
result.errors++;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overwrite requested - verify database is empty for safety
|
||||||
|
const dbEmpty = await isDatabaseEmpty();
|
||||||
|
if (!dbEmpty) {
|
||||||
|
logger.error(
|
||||||
|
"Cannot overwrite recovery key: database contains existing records. " +
|
||||||
|
"Overwriting the recovery key would make existing backups unrecoverable.",
|
||||||
|
);
|
||||||
|
result.errors++;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe to overwrite - database is empty
|
||||||
|
logger.warn("Overwriting existing recovery key (database is empty)");
|
||||||
}
|
}
|
||||||
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}`);
|
||||||
|
|
@ -740,10 +788,14 @@ async function importUsers(users: unknown[], recoveryKey: string | null): Promis
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runImport(config: ImportConfig): Promise<ImportResult> {
|
type ImportOptions = {
|
||||||
|
overwriteRecoveryKey?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function runImport(config: ImportConfig, options: ImportOptions = {}): Promise<ImportResult> {
|
||||||
const result: ImportResult = { succeeded: 0, skipped: 0, warnings: 0, errors: 0 };
|
const result: ImportResult = { succeeded: 0, skipped: 0, warnings: 0, errors: 0 };
|
||||||
|
|
||||||
mergeResults(result, await writeRecoveryKeyFromConfig(config.recoveryKey));
|
mergeResults(result, await writeRecoveryKeyFromConfig(config.recoveryKey, options.overwriteRecoveryKey ?? false));
|
||||||
|
|
||||||
// Stop immediately if recovery key has errors (e.g., mismatch with existing key)
|
// Stop immediately if recovery key has errors (e.g., mismatch with existing key)
|
||||||
if (result.errors > 0) {
|
if (result.errors > 0) {
|
||||||
|
|
@ -779,10 +831,10 @@ function logImportSummary(result: ImportResult): void {
|
||||||
/**
|
/**
|
||||||
* Import configuration from a raw config object (used by CLI)
|
* Import configuration from a raw config object (used by CLI)
|
||||||
*/
|
*/
|
||||||
export async function applyConfigImport(configRaw: unknown): Promise<ImportResult> {
|
export async function applyConfigImport(configRaw: unknown, options: ImportOptions = {}): Promise<ImportResult> {
|
||||||
logger.info("Starting config import...");
|
logger.info("Starting config import...");
|
||||||
const config = parseImportConfig(configRaw);
|
const config = parseImportConfig(configRaw);
|
||||||
const result = await runImport(config);
|
const result = await runImport(config, options);
|
||||||
logImportSummary(result);
|
logImportSummary(result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue