feat: improve config import with result tracking and idempotent repository detection
- Add ImportResult type to track succeeded/warnings/errors counts across all import functions - Implement multi-layer repository duplicate detection: - URL-based check (same path/bucket/endpoint already registered) - Restic repo check (path is already a restic repository) - Name-based fallback check - Standardize logging between CLI and env var import methods with logImportSummary() - CLI exits with code 1 when import has errors - Remove shortId override for local repo migrations (use full path with isExistingRepository instead) - Update example JSON and README: - Document that local repo path is optional (defaults to /var/lib/zerobyte/repositories) - Add existing-local-repo example with isExistingRepository: true - Add S3 endpoint field to example - Expand config behavior docs to explain all repository skip conditions - Improve .gitignore to exclude all JSON except example template
This commit is contained in:
parent
e4f66de447
commit
5a4f4641b4
6 changed files with 197 additions and 58 deletions
|
|
@ -92,20 +92,21 @@ export const importConfigCommand = new Command("import-config")
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🚀 Starting import...\n");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Ensure database is initialized with migrations
|
// Ensure database is initialized with migrations
|
||||||
const { runDbMigrations } = await import("../../db/db");
|
const { runDbMigrations } = await import("../../db/db");
|
||||||
runDbMigrations();
|
runDbMigrations();
|
||||||
|
|
||||||
const { applyConfigImport } = await import("../../modules/lifecycle/config-import");
|
const { applyConfigImport } = await import("../../modules/lifecycle/config-import");
|
||||||
await applyConfigImport(config);
|
const result = await applyConfigImport(config);
|
||||||
|
|
||||||
console.log("\n✅ Import completed successfully");
|
// Exit with error code if there were errors
|
||||||
|
if (result.errors > 0) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e instanceof Error ? e : new Error(String(e));
|
const err = e instanceof Error ? e : new Error(String(e));
|
||||||
console.error(`\n❌ Import failed: ${err.message}`);
|
console.error(`❌ Import failed: ${err.message}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,12 @@ type ImportConfig = {
|
||||||
recoveryKey: string | null;
|
recoveryKey: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ImportResult = {
|
||||||
|
succeeded: number;
|
||||||
|
warnings: number;
|
||||||
|
errors: number;
|
||||||
|
};
|
||||||
|
|
||||||
function interpolateEnvVars(value: unknown): unknown {
|
function interpolateEnvVars(value: unknown): unknown {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
return value.replace(/\$\{([^}]+)\}/g, (_, v) => {
|
return value.replace(/\$\{([^}]+)\}/g, (_, v) => {
|
||||||
|
|
@ -94,11 +100,13 @@ function parseImportConfig(configRaw: unknown): ImportConfig {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeRecoveryKeyFromConfig(recoveryKey: string | null): Promise<void> {
|
async function writeRecoveryKeyFromConfig(recoveryKey: string | null): Promise<ImportResult> {
|
||||||
|
const result: ImportResult = { succeeded: 0, warnings: 0, errors: 0 };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fs = await import("node:fs/promises");
|
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;
|
if (!recoveryKey) return result;
|
||||||
|
|
||||||
if (typeof recoveryKey !== "string" || recoveryKey.length !== 64 || !/^[a-fA-F0-9]{64}$/.test(recoveryKey)) {
|
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");
|
throw new Error("Recovery key must be a 64-character hex string");
|
||||||
|
|
@ -108,18 +116,25 @@ async function writeRecoveryKeyFromConfig(recoveryKey: string | null): Promise<v
|
||||||
() => false,
|
() => false,
|
||||||
);
|
);
|
||||||
if (passFileExists) {
|
if (passFileExists) {
|
||||||
logger.info(`Restic passfile already exists at ${RESTIC_PASS_FILE}; skipping config recovery key write`);
|
logger.warn(`Restic passfile already exists at ${RESTIC_PASS_FILE}; skipping config recovery key write`);
|
||||||
return;
|
result.warnings++;
|
||||||
|
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++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err instanceof Error ? err : new Error(String(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: ${e.message}`);
|
||||||
|
result.errors++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importVolumes(volumes: unknown[]): Promise<void> {
|
async function importVolumes(volumes: unknown[]): Promise<ImportResult> {
|
||||||
|
const result: ImportResult = { succeeded: 0, warnings: 0, errors: 0 };
|
||||||
|
|
||||||
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") {
|
||||||
|
|
@ -127,6 +142,7 @@ async function importVolumes(volumes: unknown[]): Promise<void> {
|
||||||
}
|
}
|
||||||
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++;
|
||||||
|
|
||||||
// If autoRemount is explicitly false, update the volume (default is true)
|
// If autoRemount is explicitly false, update the volume (default is true)
|
||||||
if (v.autoRemount === false) {
|
if (v.autoRemount === false) {
|
||||||
|
|
@ -136,17 +152,78 @@ async function importVolumes(volumes: unknown[]): Promise<void> {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e instanceof Error ? e : new Error(String(e));
|
const err = e instanceof Error ? e : new Error(String(e));
|
||||||
logger.warn(`Volume not created: ${err.message}`);
|
logger.warn(`Volume not created: ${err.message}`);
|
||||||
|
result.warnings++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importRepositories(repositories: unknown[]): Promise<void> {
|
async function importRepositories(repositories: unknown[]): Promise<ImportResult> {
|
||||||
|
const result: ImportResult = { succeeded: 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");
|
||||||
|
|
||||||
|
// Get existing repositories and build sets for duplicate detection
|
||||||
|
const existingRepos = await repoServiceModule.repositoriesService.listRepositories();
|
||||||
|
const existingNames = new Set(existingRepos.map((repo) => repo.name));
|
||||||
|
const existingUrls = new Set<string>();
|
||||||
|
|
||||||
|
for (const repo of existingRepos) {
|
||||||
|
try {
|
||||||
|
// Config fields used for URL (path, bucket, endpoint, etc.) are not encrypted
|
||||||
|
const url = buildRepoUrl(repo.config as RepositoryConfig);
|
||||||
|
existingUrls.add(url);
|
||||||
|
} catch (e) {
|
||||||
|
const err = e instanceof Error ? e : new Error(String(e));
|
||||||
|
logger.warn(`Could not build URL for existing repository '${repo.name}': ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const r of repositories) {
|
for (const r of repositories) {
|
||||||
try {
|
try {
|
||||||
if (!isRecord(r) || typeof r.name !== "string" || !isRecord(r.config) || typeof r.config.backend !== "string") {
|
if (!isRecord(r) || typeof r.name !== "string" || !isRecord(r.config) || typeof r.config.backend !== "string") {
|
||||||
throw new Error("Invalid repository entry");
|
throw new Error("Invalid repository entry");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip if a repository pointing to the same location is already registered in DB
|
||||||
|
try {
|
||||||
|
const incomingUrl = buildRepoUrl(r.config as RepositoryConfig);
|
||||||
|
if (existingUrls.has(incomingUrl)) {
|
||||||
|
logger.warn(`Skipping '${r.name}': another repository is already registered for location ${incomingUrl}`);
|
||||||
|
result.warnings++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const err = e instanceof Error ? e : new Error(String(e));
|
||||||
|
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
|
||||||
|
// This catches the case where user forgot to set isExistingRepository: true
|
||||||
|
if (r.config.backend === "local" && !r.config.isExistingRepository) {
|
||||||
|
const isAlreadyRepo = await restic
|
||||||
|
.snapshots({ ...r.config, isExistingRepository: true } as RepositoryConfig)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
if (isAlreadyRepo) {
|
||||||
|
logger.warn(
|
||||||
|
`Skipping '${r.name}': path '${r.config.path}' is already a restic repository. ` +
|
||||||
|
`Set "isExistingRepository": true to import it, or use a different path for a new repository.`,
|
||||||
|
);
|
||||||
|
result.warnings++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if a repository with the same name already exists (fallback for repos without deterministic paths)
|
||||||
|
if (existingNames.has(r.name)) {
|
||||||
|
logger.warn(`Skipping '${r.name}': a repository with this name already exists`);
|
||||||
|
result.warnings++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const compressionMode =
|
const compressionMode =
|
||||||
r.compressionMode === "auto" || r.compressionMode === "off" || r.compressionMode === "max"
|
r.compressionMode === "auto" || r.compressionMode === "off" || r.compressionMode === "max"
|
||||||
? r.compressionMode
|
? r.compressionMode
|
||||||
|
|
@ -157,14 +234,19 @@ async function importRepositories(repositories: unknown[]): Promise<void> {
|
||||||
compressionMode,
|
compressionMode,
|
||||||
);
|
);
|
||||||
logger.info(`Initialized repository from config: ${r.name}`);
|
logger.info(`Initialized repository from config: ${r.name}`);
|
||||||
|
result.succeeded++;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e instanceof Error ? e : new Error(String(e));
|
const err = e instanceof Error ? e : new Error(String(e));
|
||||||
logger.warn(`Repository not created: ${err.message}`);
|
logger.warn(`Repository not created: ${err.message}`);
|
||||||
|
result.warnings++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importNotificationDestinations(notificationDestinations: unknown[]): Promise<void> {
|
async function importNotificationDestinations(notificationDestinations: unknown[]): Promise<ImportResult> {
|
||||||
|
const result: ImportResult = { succeeded: 0, warnings: 0, errors: 0 };
|
||||||
const notificationsServiceModule = await import("../notifications/notifications.service");
|
const notificationsServiceModule = await import("../notifications/notifications.service");
|
||||||
for (const n of notificationDestinations) {
|
for (const n of notificationDestinations) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -176,6 +258,7 @@ async function importNotificationDestinations(notificationDestinations: unknown[
|
||||||
n.config as NotificationConfig,
|
n.config as NotificationConfig,
|
||||||
);
|
);
|
||||||
logger.info(`Initialized notification destination from config: ${n.name}`);
|
logger.info(`Initialized notification destination from config: ${n.name}`);
|
||||||
|
result.succeeded++;
|
||||||
|
|
||||||
// If enabled is explicitly false, update the destination (default is true)
|
// If enabled is explicitly false, update the destination (default is true)
|
||||||
if (n.enabled === false) {
|
if (n.enabled === false) {
|
||||||
|
|
@ -185,8 +268,11 @@ async function importNotificationDestinations(notificationDestinations: unknown[
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e instanceof Error ? e : new Error(String(e));
|
const err = e instanceof Error ? e : new Error(String(e));
|
||||||
logger.warn(`Notification destination not created: ${err.message}`);
|
logger.warn(`Notification destination not created: ${err.message}`);
|
||||||
|
result.warnings++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getScheduleVolumeName(schedule: Record<string, unknown>): string | null {
|
function getScheduleVolumeName(schedule: Record<string, unknown>): string | null {
|
||||||
|
|
@ -261,8 +347,9 @@ async function attachScheduleNotifications(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importBackupSchedules(backupSchedules: unknown[]): Promise<void> {
|
async function importBackupSchedules(backupSchedules: unknown[]): Promise<ImportResult> {
|
||||||
if (!Array.isArray(backupSchedules) || backupSchedules.length === 0) return;
|
const result: ImportResult = { succeeded: 0, warnings: 0, errors: 0 };
|
||||||
|
if (!Array.isArray(backupSchedules) || backupSchedules.length === 0) return result;
|
||||||
|
|
||||||
const backupServiceModule = await import("../backups/backups.service");
|
const backupServiceModule = await import("../backups/backups.service");
|
||||||
const notificationsServiceModule = await import("../notifications/notifications.service");
|
const notificationsServiceModule = await import("../notifications/notifications.service");
|
||||||
|
|
@ -282,28 +369,33 @@ async function importBackupSchedules(backupSchedules: unknown[]): Promise<void>
|
||||||
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 created: Missing volume name");
|
||||||
|
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 created: Volume '${volumeName}' not found`);
|
||||||
|
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 created: Missing repository name");
|
||||||
|
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 created: Repository '${repositoryName}' not found`);
|
||||||
|
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 created: Missing cronExpression for '${scheduleName}'`);
|
||||||
|
result.warnings++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -335,9 +427,11 @@ async function importBackupSchedules(backupSchedules: unknown[]): Promise<void>
|
||||||
oneFileSystem: typeof s.oneFileSystem === "boolean" ? s.oneFileSystem : undefined,
|
oneFileSystem: typeof s.oneFileSystem === "boolean" ? s.oneFileSystem : undefined,
|
||||||
});
|
});
|
||||||
logger.info(`Initialized backup schedule from config: ${scheduleName}`);
|
logger.info(`Initialized backup schedule from config: ${scheduleName}`);
|
||||||
|
result.succeeded++;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e instanceof Error ? e : new Error(String(e));
|
const err = e instanceof Error ? e : new Error(String(e));
|
||||||
logger.warn(`Backup schedule not created: ${err.message}`);
|
logger.warn(`Backup schedule not created: ${err.message}`);
|
||||||
|
result.warnings++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -354,6 +448,8 @@ async function importBackupSchedules(backupSchedules: unknown[]): Promise<void>
|
||||||
await attachScheduleMirrors(createdSchedule.id, s.mirrors, repoByName, backupServiceModule);
|
await attachScheduleMirrors(createdSchedule.id, s.mirrors, repoByName, backupServiceModule);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function attachScheduleMirrors(
|
async function attachScheduleMirrors(
|
||||||
|
|
@ -403,17 +499,20 @@ async function attachScheduleMirrors(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setupInitialUser(users: unknown[], recoveryKey: string | null): Promise<void> {
|
async function setupInitialUser(users: unknown[], recoveryKey: string | null): Promise<ImportResult> {
|
||||||
|
const result: ImportResult = { succeeded: 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;
|
if (hasUsers) return result;
|
||||||
if (!Array.isArray(users) || users.length === 0) return;
|
if (!Array.isArray(users) || users.length === 0) return result;
|
||||||
|
|
||||||
if (users.length > 1) {
|
if (users.length > 1) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
"Multiple users provided in config. Zerobyte currently supports a single initial user; extra entries will be ignored.",
|
"Multiple users provided in config. Zerobyte currently supports a single initial user; extra entries will be ignored.",
|
||||||
);
|
);
|
||||||
|
result.warnings++;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const u of users) {
|
for (const u of users) {
|
||||||
|
|
@ -429,10 +528,12 @@ async function setupInitialUser(users: unknown[], recoveryKey: string | null): P
|
||||||
typeof u.hasDownloadedResticPassword === "boolean" ? u.hasDownloadedResticPassword : Boolean(recoveryKey),
|
typeof u.hasDownloadedResticPassword === "boolean" ? u.hasDownloadedResticPassword : Boolean(recoveryKey),
|
||||||
});
|
});
|
||||||
logger.info(`User '${u.username}' imported with password hash from config.`);
|
logger.info(`User '${u.username}' imported with password hash from config.`);
|
||||||
|
result.succeeded++;
|
||||||
break;
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error instanceof Error ? error : new Error(String(error));
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
logger.warn(`User '${u.username}' not imported: ${err.message}`);
|
logger.warn(`User '${u.username}' not imported: ${err.message}`);
|
||||||
|
result.warnings++;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -446,38 +547,70 @@ async function setupInitialUser(users: unknown[], recoveryKey: string | null): P
|
||||||
await db.update(usersTable).set({ hasDownloadedResticPassword }).where(eq(usersTable.id, user.id));
|
await db.update(usersTable).set({ hasDownloadedResticPassword }).where(eq(usersTable.id, user.id));
|
||||||
}
|
}
|
||||||
logger.info(`User '${u.username}' created from config.`);
|
logger.info(`User '${u.username}' created from config.`);
|
||||||
|
result.succeeded++;
|
||||||
break;
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error instanceof Error ? error : new Error(String(error));
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
logger.warn(`User '${u.username}' not created: ${err.message}`);
|
logger.warn(`User '${u.username}' not created: ${err.message}`);
|
||||||
|
result.warnings++;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.warn(`User '${u.username}' missing passwordHash/password; skipping`);
|
logger.warn(`User '${u.username}' missing passwordHash/password; skipping`);
|
||||||
|
result.warnings++;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err instanceof Error ? err : new Error(String(err));
|
const e = err instanceof Error ? err : new Error(String(err));
|
||||||
logger.error(`Automated user setup failed: ${e.message}`);
|
logger.error(`Automated user setup failed: ${e.message}`);
|
||||||
|
result.errors++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runImport(config: ImportConfig): Promise<void> {
|
async function runImport(config: ImportConfig): Promise<ImportResult> {
|
||||||
await writeRecoveryKeyFromConfig(config.recoveryKey);
|
const result: ImportResult = { succeeded: 0, warnings: 0, errors: 0 };
|
||||||
|
|
||||||
await importVolumes(config.volumes);
|
const recoveryKeyResult = await writeRecoveryKeyFromConfig(config.recoveryKey);
|
||||||
await importRepositories(config.repositories);
|
const volumeResult = await importVolumes(config.volumes);
|
||||||
await importNotificationDestinations(config.notificationDestinations);
|
const repoResult = await importRepositories(config.repositories);
|
||||||
await importBackupSchedules(config.backupSchedules);
|
const notifResult = await importNotificationDestinations(config.notificationDestinations);
|
||||||
await setupInitialUser(config.users, config.recoveryKey);
|
const scheduleResult = await importBackupSchedules(config.backupSchedules);
|
||||||
|
const userResult = await setupInitialUser(config.users, config.recoveryKey);
|
||||||
|
|
||||||
|
for (const r of [recoveryKeyResult, volumeResult, repoResult, notifResult, scheduleResult, userResult]) {
|
||||||
|
result.succeeded += r.succeeded;
|
||||||
|
result.warnings += r.warnings;
|
||||||
|
result.errors += r.errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logImportSummary(result: ImportResult): void {
|
||||||
|
if (result.errors > 0) {
|
||||||
|
logger.error(
|
||||||
|
`Config import completed with ${result.errors} error(s) and ${result.warnings} warning(s), ${result.succeeded} item(s) imported`,
|
||||||
|
);
|
||||||
|
} 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`);
|
||||||
|
} else {
|
||||||
|
logger.info("Config import completed: no items to import");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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<void> {
|
export async function applyConfigImport(configRaw: unknown): Promise<ImportResult> {
|
||||||
|
logger.info("Starting config import...");
|
||||||
const config = parseImportConfig(configRaw);
|
const config = parseImportConfig(configRaw);
|
||||||
await runImport(config);
|
const result = await runImport(config);
|
||||||
|
logImportSummary(result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -485,12 +618,16 @@ export async function applyConfigImport(configRaw: unknown): Promise<void> {
|
||||||
*/
|
*/
|
||||||
export async function applyConfigImportFromFile(): Promise<void> {
|
export async function applyConfigImportFromFile(): Promise<void> {
|
||||||
const configRaw = await loadConfigFromFile();
|
const configRaw = await loadConfigFromFile();
|
||||||
|
if (configRaw === null) return; // No config file, nothing to do
|
||||||
|
|
||||||
|
logger.info("Starting config import from file...");
|
||||||
const config = parseImportConfig(configRaw);
|
const config = parseImportConfig(configRaw);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runImport(config);
|
const result = await runImport(config);
|
||||||
|
logImportSummary(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e instanceof Error ? e : new Error(String(e));
|
const err = e instanceof Error ? e : new Error(String(e));
|
||||||
logger.error(`Failed to initialize from config: ${err.message}`);
|
logger.error(`Config import failed: ${err.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,23 +64,7 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
|
||||||
|
|
||||||
const createRepository = async (name: string, config: RepositoryConfig, compressionMode?: CompressionMode) => {
|
const createRepository = async (name: string, config: RepositoryConfig, compressionMode?: CompressionMode) => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
|
const shortId = generateShortId();
|
||||||
// Determine shortId: use provided config.name for local repo migrations, otherwise generate
|
|
||||||
let shortId: string;
|
|
||||||
if (config.backend === "local" && config.name?.length) {
|
|
||||||
// User provided a name (migration scenario) - check for conflicts
|
|
||||||
shortId = config.name;
|
|
||||||
const existingByShortId = await db.query.repositoriesTable.findFirst({
|
|
||||||
where: eq(repositoriesTable.shortId, shortId),
|
|
||||||
});
|
|
||||||
if (existingByShortId) {
|
|
||||||
throw new ConflictError(
|
|
||||||
`A repository with shortId '${shortId}' already exists. The shortId is used as the subdirectory name for local repositories.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
shortId = generateShortId();
|
|
||||||
}
|
|
||||||
|
|
||||||
let processedConfig = config;
|
let processedConfig = config;
|
||||||
if (config.backend === "local" && !config.isExistingRepository) {
|
if (config.backend === "local" && !config.isExistingRepository) {
|
||||||
|
|
|
||||||
4
examples/config-file-import/.gitignore
vendored
4
examples/config-file-import/.gitignore
vendored
|
|
@ -1,2 +1,4 @@
|
||||||
.env
|
.env
|
||||||
zerobyte.config.json
|
|
||||||
|
*.json
|
||||||
|
!zerobyte.config.example.json
|
||||||
|
|
|
||||||
|
|
@ -160,8 +160,11 @@ See the runnable example:
|
||||||
|
|
||||||
The config file is applied on startup using a **create-only** approach:
|
The config file is applied on startup using a **create-only** approach:
|
||||||
|
|
||||||
- Resources defined in the config are only created if they don't already exist in the database
|
- **Volumes, notifications, schedules**: Skipped if a resource with the same name already exists
|
||||||
- Existing resources with the same name are **not overwritten** (a warning is logged and the config entry is skipped)
|
- **Repositories**: Skipped if any of these conditions are met:
|
||||||
|
- A repository pointing to the same location (path/bucket/endpoint) is already registered
|
||||||
|
- For local repos: the path is already a restic repository (set `isExistingRepository: true` to import it)
|
||||||
|
- A repository with the same name already exists
|
||||||
- Changes made via the UI are preserved across container restarts
|
- Changes made via the UI are preserved across container restarts
|
||||||
- To update a resource from config, either modify it via the UI or delete it first
|
- To update a resource from config, either modify it via the UI or delete it first
|
||||||
|
|
||||||
|
|
@ -303,36 +306,39 @@ For key-based authentication:
|
||||||
|
|
||||||
### Repository types
|
### Repository types
|
||||||
|
|
||||||
#### Local
|
#### Local (new repository)
|
||||||
|
|
||||||
|
Creates a new restic repository. The `path` is optional and defaults to `/var/lib/zerobyte/repositories`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "local-repo",
|
"name": "local-repo",
|
||||||
"config": {
|
"config": {
|
||||||
"backend": "local",
|
"backend": "local"
|
||||||
"path": "/var/lib/zerobyte/repositories"
|
|
||||||
},
|
},
|
||||||
"compressionMode": "auto"
|
"compressionMode": "auto"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Note for importing existing local repositories (migration):
|
The actual repository will be created at `{path}/{auto-generated-id}`.
|
||||||
|
|
||||||
- include `config.name` and set `config.isExistingRepository: true`
|
#### Local (existing repository)
|
||||||
- the actual restic repo is stored at `{path}/{name}`
|
|
||||||
|
To import an existing restic repository, set `isExistingRepository: true` and provide the **full path to the repository root**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "my-local-repo",
|
"name": "my-local-repo",
|
||||||
"config": {
|
"config": {
|
||||||
"backend": "local",
|
"backend": "local",
|
||||||
"path": "/var/lib/zerobyte/repositories",
|
"path": "/var/lib/zerobyte/repositories/abc123",
|
||||||
"name": "abc123",
|
|
||||||
"isExistingRepository": true
|
"isExistingRepository": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note: The `path` must point directly to the restic repository root (the directory containing `config`, `data/`, `keys/`, etc.).
|
||||||
|
|
||||||
#### S3-compatible
|
#### S3-compatible
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
@ -340,6 +346,7 @@ Note for importing existing local repositories (migration):
|
||||||
"name": "backup-repo",
|
"name": "backup-repo",
|
||||||
"config": {
|
"config": {
|
||||||
"backend": "s3",
|
"backend": "s3",
|
||||||
|
"endpoint": "s3.amazonaws.com",
|
||||||
"bucket": "mybucket",
|
"bucket": "mybucket",
|
||||||
"accessKeyId": "${ACCESS_KEY_ID}",
|
"accessKeyId": "${ACCESS_KEY_ID}",
|
||||||
"secretAccessKey": "${SECRET_ACCESS_KEY}"
|
"secretAccessKey": "${SECRET_ACCESS_KEY}"
|
||||||
|
|
|
||||||
|
|
@ -64,15 +64,23 @@
|
||||||
{
|
{
|
||||||
"name": "local-repo",
|
"name": "local-repo",
|
||||||
"config": {
|
"config": {
|
||||||
"backend": "local",
|
"backend": "local"
|
||||||
"path": "/var/lib/zerobyte/repositories"
|
|
||||||
},
|
},
|
||||||
"compressionMode": "auto"
|
"compressionMode": "auto"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "existing-local-repo",
|
||||||
|
"config": {
|
||||||
|
"backend": "local",
|
||||||
|
"path": "/var/lib/zerobyte/repositories/abc123",
|
||||||
|
"isExistingRepository": true
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "s3-repo",
|
"name": "s3-repo",
|
||||||
"config": {
|
"config": {
|
||||||
"backend": "s3",
|
"backend": "s3",
|
||||||
|
"endpoint": "s3.amazonaws.com",
|
||||||
"bucket": "mybucket",
|
"bucket": "mybucket",
|
||||||
"accessKeyId": "${ACCESS_KEY_ID}",
|
"accessKeyId": "${ACCESS_KEY_ID}",
|
||||||
"secretAccessKey": "${SECRET_ACCESS_KEY}"
|
"secretAccessKey": "${SECRET_ACCESS_KEY}"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue