diff --git a/app/server/cli/commands/import-config.ts b/app/server/cli/commands/import-config.ts new file mode 100644 index 00000000..ee94ef5e --- /dev/null +++ b/app/server/cli/commands/import-config.ts @@ -0,0 +1,111 @@ +import { Command } from "commander"; +import path from "node:path"; +import fs from "node:fs/promises"; + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + return Buffer.concat(chunks).toString("utf-8"); +} + +export const importConfigCommand = new Command("import-config") + .description("Import configuration from a JSON file or stdin") + .option("-c, --config ", "Path to the configuration file") + .option("--stdin", "Read configuration from stdin") + .option("--dry-run", "Validate the config without importing") + .action(async (options) => { + console.log("\nšŸ“¦ Zerobyte Config Import\n"); + + if (!options.config && !options.stdin) { + console.error("āŒ Either --config or --stdin is required"); + console.log("\nUsage:"); + console.log(" zerobyte import-config --config /path/to/config.json"); + console.log(" cat config.json | zerobyte import-config --stdin"); + process.exit(1); + } + + if (options.config && options.stdin) { + console.error("āŒ Cannot use both --config and --stdin"); + process.exit(1); + } + + let configJson: string; + + if (options.stdin) { + console.log("šŸ“„ Reading config from stdin..."); + try { + configJson = await readStdin(); + if (!configJson.trim()) { + console.error("āŒ No input received from stdin"); + process.exit(1); + } + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + console.error(`āŒ Failed to read stdin: ${err.message}`); + process.exit(1); + } + } else { + const configPath = path.resolve(process.cwd(), options.config); + + // Check if file exists + try { + await fs.access(configPath); + } catch { + console.error(`āŒ Config file not found: ${configPath}`); + process.exit(1); + } + + console.log(`šŸ“„ Config file: ${configPath}`); + configJson = await fs.readFile(configPath, "utf-8"); + } + + // Parse and validate JSON + let config: unknown; + try { + config = JSON.parse(configJson); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + console.error(`āŒ Invalid JSON: ${err.message}`); + process.exit(1); + } + + if (options.dryRun) { + console.log("šŸ” Dry run mode - validating config only\n"); + + const root = typeof config === "object" && config !== null ? config : {}; + const configObj = + "config" in root && typeof root.config === "object" && root.config !== null ? root.config : root; + + const sections = ["volumes", "repositories", "backupSchedules", "notificationDestinations", "users"]; + for (const section of sections) { + const items = (configObj as Record)[section] || []; + const count = Array.isArray(items) ? items.length : 0; + console.log(` ${section}: ${count} item(s)`); + } + + const hasRecoveryKey = !!(configObj as Record).recoveryKey; + console.log(` recoveryKey: ${hasRecoveryKey ? "provided" : "not provided"}`); + + console.log("\nāœ… Config is valid JSON"); + return; + } + + console.log("šŸš€ Starting import...\n"); + + try { + // Ensure database is initialized with migrations + const { runDbMigrations } = await import("../../db/db"); + runDbMigrations(); + + const { applyConfigImport } = await import("../../modules/lifecycle/config-import"); + await applyConfigImport(config); + + console.log("\nāœ… Import completed successfully"); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + console.error(`\nāŒ Import failed: ${err.message}`); + process.exit(1); + } + }); diff --git a/app/server/cli/index.ts b/app/server/cli/index.ts index 128631e6..5e974390 100644 --- a/app/server/cli/index.ts +++ b/app/server/cli/index.ts @@ -1,9 +1,11 @@ import { Command } from "commander"; +import { importConfigCommand } from "./commands/import-config"; import { resetPasswordCommand } from "./commands/reset-password"; const program = new Command(); program.name("zerobyte").description("Zerobyte CLI - Backup automation tool built on top of Restic").version("1.0.0"); +program.addCommand(importConfigCommand); program.addCommand(resetPasswordCommand); export async function runCLI(argv: string[]): Promise { diff --git a/app/server/modules/lifecycle/config-import.ts b/app/server/modules/lifecycle/config-import.ts index 7a458326..1f3024ba 100644 --- a/app/server/modules/lifecycle/config-import.ts +++ b/app/server/modules/lifecycle/config-import.ts @@ -171,7 +171,10 @@ async function importNotificationDestinations(notificationDestinations: unknown[ if (!isRecord(n) || typeof n.name !== "string" || !isRecord(n.config) || typeof n.config.type !== "string") { throw new Error("Invalid notification destination entry"); } - const created = await notificationsServiceModule.notificationsService.createDestination(n.name, n.config as NotificationConfig); + const created = await notificationsServiceModule.notificationsService.createDestination( + n.name, + n.config as NotificationConfig, + ); logger.info(`Initialized notification destination from config: ${n.name}`); // If enabled is explicitly false, update the destination (default is true) @@ -187,7 +190,11 @@ async function importNotificationDestinations(notificationDestinations: unknown[ } function getScheduleVolumeName(schedule: Record): string | null { - return typeof schedule.volume === "string" ? schedule.volume : typeof schedule.volumeName === "string" ? schedule.volumeName : null; + return typeof schedule.volume === "string" + ? schedule.volume + : typeof schedule.volumeName === "string" + ? schedule.volumeName + : null; } function getScheduleRepositoryName(schedule: Record): string | null { @@ -335,7 +342,12 @@ async function importBackupSchedules(backupSchedules: unknown[]): Promise } if (createdSchedule && Array.isArray(s.notifications) && s.notifications.length > 0) { - await attachScheduleNotifications(createdSchedule.id, s.notifications, destinationBySlug, notificationsServiceModule); + await attachScheduleNotifications( + createdSchedule.id, + s.notifications, + destinationBySlug, + notificationsServiceModule, + ); } if (createdSchedule && Array.isArray(s.mirrors) && s.mirrors.length > 0) { @@ -450,18 +462,33 @@ async function setupInitialUser(users: unknown[], recoveryKey: string | null): P } } +async function runImport(config: ImportConfig): Promise { + await writeRecoveryKeyFromConfig(config.recoveryKey); + + await importVolumes(config.volumes); + await importRepositories(config.repositories); + await importNotificationDestinations(config.notificationDestinations); + await importBackupSchedules(config.backupSchedules); + await setupInitialUser(config.users, config.recoveryKey); +} + +/** + * Import configuration from a raw config object (used by CLI) + */ +export async function applyConfigImport(configRaw: unknown): Promise { + const config = parseImportConfig(configRaw); + await runImport(config); +} + +/** + * Import configuration from a file (used by env var startup) + */ export async function applyConfigImportFromFile(): Promise { const configRaw = await loadConfigFromFile(); const config = parseImportConfig(configRaw); - await writeRecoveryKeyFromConfig(config.recoveryKey); - try { - await importVolumes(config.volumes); - await importRepositories(config.repositories); - await importNotificationDestinations(config.notificationDestinations); - await importBackupSchedules(config.backupSchedules); - await setupInitialUser(config.users, config.recoveryKey); + await runImport(config); } catch (e) { const err = e instanceof Error ? e : new Error(String(e)); logger.error(`Failed to initialize from config: ${err.message}`); diff --git a/examples/config-file-import/README.md b/examples/config-file-import/README.md index a16e0f0a..a36c1671 100644 --- a/examples/config-file-import/README.md +++ b/examples/config-file-import/README.md @@ -52,15 +52,52 @@ docker compose up -d ## Notes -### Enabling import +### Import methods -Config import is opt-in and only runs when: +Zerobyte supports two ways to import configuration: -- `ZEROBYTE_CONFIG_IMPORT=true` +#### Method 1: Environment variable (automatic on startup) -The config path defaults to `/app/zerobyte.config.json`, but you can override it via: +Set `ZEROBYTE_CONFIG_IMPORT=true` and the import runs automatically when the container starts: -- `ZEROBYTE_CONFIG_PATH=/app/your-config.json` +```yaml +services: + zerobyte: + environment: + - ZEROBYTE_CONFIG_IMPORT=true + - ZEROBYTE_CONFIG_PATH=/app/zerobyte.config.json # optional, this is the default +``` + +This is ideal for automated deployments where you want `docker compose up` to fully configure the instance. + +#### Method 2: CLI command (manual control) + +Run the import explicitly using the CLI: + +```bash +# Import from a mounted config file (starts a new temporary container) +docker compose run --rm zerobyte bun run cli import-config --config /app/zerobyte.config.json + +# Import from a mounted config file into an already-running container +docker compose exec zerobyte bun run cli import-config --config /app/zerobyte.config.json + +# Import from stdin (into running container) +cat zerobyte.config.json | docker compose exec -T zerobyte bun run cli import-config --stdin + +# Import from stdin in PowerShell (into running container) +Get-Content zerobyte.config.json | docker compose exec -T zerobyte bun run cli import-config --stdin + +# Validate config without importing (dry run) +docker compose run --rm zerobyte bun run cli import-config --config /app/zerobyte.config.json --dry-run +``` + +The `--stdin` option is useful when you don't want to mount the config file - just pipe it directly. + +This is useful when you want to: +- See import output directly in your terminal +- Re-run import after fixing issues +- Test config files before applying them +- Import without modifying your docker-compose.yml ### Secrets via env vars