-
Notifications
You must be signed in to change notification settings - Fork 142
feat: Add JSON configuration import for Infrastructure as Code support #93
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ebaebe0
8f54016
d767346
34123be
a4d1cc0
3c7dd85
1de82fa
abe6eb7
1d42630
ea9003c
0b2576a
7f124f8
6e1cc89
8c3e85c
7b2bb25
883ee49
9e9edd7
7476897
45b5c0d
5cdec5c
e4f66de
5a4f464
a71009f
e5d8827
f908883
3c32b3e
e9424e3
16ef4b4
60c0778
3c7419f
0be65dc
37d04d3
cc9982b
88bb174
b25ed60
04cbb58
4889b85
24f850b
8591432
88d5e17
b0d9985
08f7dca
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| import { type } from "arktype"; | ||
| import { volumeConfigSchema } from "./volumes"; | ||
| import { repositoryConfigSchema } from "./restic"; | ||
| import { notificationConfigSchema } from "./notifications"; | ||
| import { retentionPolicySchema } from "../server/modules/backups/backups.dto"; | ||
|
|
||
| /** | ||
| * ArkType schemas for validating config import JSON files. | ||
| * These provide runtime validation with detailed error messages. | ||
| */ | ||
|
|
||
| // Short ID format: 8 character base64url string | ||
| const shortIdSchema = type(/^[A-Za-z0-9_-]{8}$/); | ||
|
|
||
| // Volume entry schema for import | ||
| export const volumeImportSchema = type({ | ||
| name: "string>=1", | ||
| shortId: shortIdSchema.optional(), | ||
| autoRemount: "boolean?", | ||
| config: volumeConfigSchema, | ||
| }).onUndeclaredKey("delete"); | ||
|
|
||
| // Repository entry schema for import | ||
| export const repositoryImportSchema = type({ | ||
| name: "string>=1", | ||
| shortId: shortIdSchema.optional(), | ||
| compressionMode: type("'auto' | 'off' | 'max'").optional(), | ||
| config: repositoryConfigSchema, | ||
| }).onUndeclaredKey("delete"); | ||
|
|
||
| // Notification destination entry schema for import | ||
| export const notificationDestinationImportSchema = type({ | ||
| name: "string>=1", | ||
| enabled: "boolean?", | ||
| config: notificationConfigSchema, | ||
| }).onUndeclaredKey("delete"); | ||
|
|
||
| // Schedule notification assignment (either string name or object with settings) | ||
| const scheduleNotificationObjectSchema = type({ | ||
| name: "string>=1", | ||
| notifyOnStart: "boolean?", | ||
| notifyOnSuccess: "boolean?", | ||
| notifyOnWarning: "boolean?", | ||
| notifyOnFailure: "boolean?", | ||
| }).onUndeclaredKey("delete"); | ||
|
|
||
| export const scheduleNotificationAssignmentSchema = type("string>=1").or(scheduleNotificationObjectSchema); | ||
|
|
||
| // Schedule mirror assignment | ||
| export const scheduleMirrorSchema = type({ | ||
| repository: "string>=1", | ||
| enabled: "boolean?", | ||
| }).onUndeclaredKey("delete"); | ||
|
|
||
| // Array types for complex schemas | ||
| const scheduleNotificationsArray = scheduleNotificationAssignmentSchema.array(); | ||
| const scheduleMirrorsArray = scheduleMirrorSchema.array(); | ||
|
|
||
| // Backup schedule entry schema for import | ||
| export const backupScheduleImportSchema = type({ | ||
| name: "string?", | ||
| shortId: shortIdSchema.optional(), | ||
| volume: "string>=1", | ||
| repository: "string>=1", | ||
| cronExpression: "string", | ||
| enabled: "boolean?", | ||
| retentionPolicy: retentionPolicySchema.or("null").optional(), | ||
| excludePatterns: "string[]?", | ||
| excludeIfPresent: "string[]?", | ||
| includePatterns: "string[]?", | ||
| oneFileSystem: "boolean?", | ||
| notifications: scheduleNotificationsArray.optional(), | ||
| mirrors: scheduleMirrorsArray.optional(), | ||
| }).onUndeclaredKey("delete"); | ||
|
|
||
| // User entry schema for import | ||
| export const userImportSchema = type({ | ||
| username: "string>=1", | ||
| password: "(string>=1)?", | ||
| passwordHash: "(string>=1)?", | ||
| hasDownloadedResticPassword: "boolean?", | ||
| }).onUndeclaredKey("delete"); | ||
|
|
||
| // Recovery key format: 64-character hex string | ||
| const recoveryKeySchema = type(/^[a-fA-F0-9]{64}$/); | ||
|
|
||
| // Array types for root config | ||
| const volumesArray = volumeImportSchema.array(); | ||
| const repositoriesArray = repositoryImportSchema.array(); | ||
| const backupSchedulesArray = backupScheduleImportSchema.array(); | ||
| const notificationDestinationsArray = notificationDestinationImportSchema.array(); | ||
| const usersArray = userImportSchema.array(); | ||
|
|
||
| // Root config schema | ||
| export const importConfigSchema = type({ | ||
| volumes: volumesArray.optional(), | ||
| repositories: repositoriesArray.optional(), | ||
| backupSchedules: backupSchedulesArray.optional(), | ||
| notificationDestinations: notificationDestinationsArray.optional(), | ||
| users: usersArray.optional(), | ||
| recoveryKey: recoveryKeySchema.optional(), | ||
| }).onUndeclaredKey("delete"); | ||
|
|
||
| // Type exports | ||
| export type VolumeImport = typeof volumeImportSchema.infer; | ||
| export type RepositoryImport = typeof repositoryImportSchema.infer; | ||
| export type NotificationDestinationImport = typeof notificationDestinationImportSchema.infer; | ||
| export type BackupScheduleImport = typeof backupScheduleImportSchema.infer; | ||
| export type UserImport = typeof userImportSchema.infer; | ||
| export type ImportConfig = typeof importConfigSchema.infer; | ||
| export type ScheduleNotificationAssignment = typeof scheduleNotificationAssignmentSchema.infer; | ||
| export type ScheduleMirror = typeof scheduleMirrorSchema.infer; | ||
| // RetentionPolicy type is re-exported from backups.dto.ts | ||
| export type { RetentionPolicy } from "../server/modules/backups/backups.dto"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,176 @@ | ||
| import { Command } from "commander"; | ||
| import path from "node:path"; | ||
| import fs from "node:fs/promises"; | ||
| import { toError } from "../../utils/errors"; | ||
|
|
||
| type Output = ReturnType<typeof createOutput>; | ||
|
|
||
| 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"); | ||
| } | ||
|
|
||
| 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") | ||
| .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") | ||
| .option("--json", "Output results in JSON format") | ||
| .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) => { | ||
| 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 (!jsonOutput) { | ||
| console.log("\nUsage:"); | ||
| console.log(" zerobyte import-config --config /path/to/config.json"); | ||
| console.log(" cat config.json | zerobyte import-config --stdin"); | ||
| } | ||
| out.error("Either --config <path> or --stdin is required"); | ||
| } | ||
|
|
||
| if (options.config && options.stdin) { | ||
| out.error("Cannot use both --config and --stdin"); | ||
| } | ||
|
|
||
| const configJson = await readConfigJson(options, out); | ||
|
|
||
| // Parse and validate JSON | ||
| let config: unknown; | ||
| try { | ||
| config = JSON.parse(configJson); | ||
| } catch (e) { | ||
| out.error(`Invalid JSON: ${toError(e).message}`); | ||
| } | ||
|
|
||
| if (options.dryRun) { | ||
| const { validateConfig } = await import("../../modules/lifecycle/config-import"); | ||
| const validation = validateConfig(config); | ||
|
|
||
| if (!validation.success) { | ||
| if (jsonOutput) { | ||
| out.json({ dryRun: true, valid: false, validationErrors: validation.errors }); | ||
| } else { | ||
| console.log("🔍 Dry run mode - validating config\n"); | ||
| console.log("❌ Validation errors:"); | ||
| for (const error of validation.errors) { | ||
| console.log(` • ${error.path}: ${error.message}`); | ||
| } | ||
| } | ||
| process.exit(1); | ||
| } | ||
|
|
||
| const { config: validConfig } = validation; | ||
| const counts = { | ||
| volumes: validConfig.volumes?.length ?? 0, | ||
| repositories: validConfig.repositories?.length ?? 0, | ||
| backupSchedules: validConfig.backupSchedules?.length ?? 0, | ||
| notificationDestinations: validConfig.notificationDestinations?.length ?? 0, | ||
| users: validConfig.users?.length ?? 0, | ||
| }; | ||
| const hasRecoveryKey = !!validConfig.recoveryKey; | ||
|
|
||
| if (jsonOutput) { | ||
| out.json({ dryRun: true, valid: true, counts, hasRecoveryKey }); | ||
| } else { | ||
| console.log("🔍 Dry run mode - validating config\n"); | ||
| for (const [section, count] of Object.entries(counts)) { | ||
| console.log(` ${section}: ${count} item(s)`); | ||
| } | ||
| console.log(` recoveryKey: ${hasRecoveryKey ? "provided" : "not provided"}`); | ||
| console.log("\n✅ Config is valid"); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| // Ensure database is initialized with migrations | ||
| const { runDbMigrations } = await import("../../db/db"); | ||
| runDbMigrations(); | ||
|
|
||
| const { applyConfigImport } = await import("../../modules/lifecycle/config-import"); | ||
tvarohohlavy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| const importResult = await applyConfigImport(config, { overwriteRecoveryKey: options.overwriteRecoveryKey }); | ||
|
|
||
| if (!importResult.success) { | ||
| if (jsonOutput) { | ||
| out.json({ success: false, validationErrors: importResult.validationErrors }); | ||
| } else { | ||
| console.log("❌ Validation errors:"); | ||
| for (const error of importResult.validationErrors) { | ||
| console.log(` • ${error.path}: ${error.message}`); | ||
| } | ||
| } | ||
| process.exit(1); | ||
| } | ||
|
|
||
| const { result } = importResult; | ||
| out.json({ ...result, success: result.errors === 0 }); | ||
|
|
||
| // Exit with error code if there were errors | ||
| if (result.errors > 0) { | ||
| process.exit(1); | ||
| } | ||
| } catch (e) { | ||
| out.error(`Import failed: ${toError(e).message}`); | ||
| } | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,11 @@ | ||
| import { Command } from "commander"; | ||
| import { importConfigCommand } from "./commands/import-config"; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
echo "=== Checking package.json for type: module ==="
if [ -f package.json ]; then
cat package.json | jq '.type // "not set"'
else
echo "package.json not found"
fi
echo -e "\n=== Checking tsconfig.json module resolution ==="
if [ -f tsconfig.json ]; then
cat tsconfig.json | jq '.compilerOptions.module, .compilerOptions.moduleResolution'
else
echo "tsconfig.json not found"
fi
echo -e "\n=== Checking import patterns in TypeScript files (sample) ==="
rg -n 'from\s+["\x27]\./[^"\']*["\x27]' --type ts --max-count=20
echo -e "\n=== Checking app/server/cli/index.ts specifically ==="
cat -n app/server/cli/index.ts | head -15Repository: nicotsx/zerobyte Length of output: 334 🏁 Script executed: #!/bin/bash
echo "=== Checking app/server/cli/index.ts ==="
cat -n app/server/cli/index.ts
echo -e "\n=== Checking import patterns (first 30 lines of TS files with imports) ==="
rg -n 'from.*["\x27]\.' --type ts --max-count=15
echo -e "\n=== Checking if any imports use file extensions ==="
rg -n 'from.*\.(ts|js)["\x27]' --type ts --max-count=15Repository: nicotsx/zerobyte Length of output: 50372 Add file extension to import statement. The import on line 2 must include a file extension per the coding guideline: "All imports must include file extensions when targeting Node/Bun, as the project uses Note: While the rest of the codebase omits extensions, this guideline is explicit and one file (openapi-ts.config.ts) demonstrates compliance by including 🤖 Prompt for AI Agents |
||
| 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> { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.