feat(cli): add import-config command for manual config import
Add CLI command to import configuration from file or stdin, providing an alternative to the env-var-based automatic import on startup. Features: - `import-config --config <path>` to import from a mounted file - `import-config --stdin` to import from piped input (no file mount needed) - `import-config --dry-run` to validate config without importing Changes: - Add app/server/cli/commands/import-config.ts with new command - Register importConfigCommand in CLI index - Refactor config-import.ts: extract runImport() and add applyConfigImport() for direct config object import (used by CLI) - Update docs with both import methods (env var and CLI examples)
This commit is contained in:
parent
7476897f87
commit
45b5c0d752
4 changed files with 192 additions and 15 deletions
111
app/server/cli/commands/import-config.ts
Normal file
111
app/server/cli/commands/import-config.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { Command } from "commander";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
async function readStdin(): Promise<string> {
|
||||
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>", "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 <path> 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<string, unknown>)[section] || [];
|
||||
const count = Array.isArray(items) ? items.length : 0;
|
||||
console.log(` ${section}: ${count} item(s)`);
|
||||
}
|
||||
|
||||
const hasRecoveryKey = !!(configObj as Record<string, unknown>).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);
|
||||
}
|
||||
});
|
||||
|
|
@ -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<boolean> {
|
||||
|
|
|
|||
|
|
@ -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, unknown>): 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, unknown>): string | null {
|
||||
|
|
@ -335,7 +342,12 @@ async function importBackupSchedules(backupSchedules: unknown[]): Promise<void>
|
|||
}
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
const config = parseImportConfig(configRaw);
|
||||
await runImport(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import configuration from a file (used by env var startup)
|
||||
*/
|
||||
export async function applyConfigImportFromFile(): Promise<void> {
|
||||
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}`);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue