Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
ebaebe0
config import via json
tvarohohlavy Nov 30, 2025
8f54016
avoid conflict with export PR
tvarohohlavy Dec 1, 2025
d767346
admin passwordHash import support
tvarohohlavy Dec 1, 2025
34123be
examples corrected
tvarohohlavy Dec 2, 2025
a4d1cc0
enhance config interpolation and validation for admin user setup
tvarohohlavy Dec 2, 2025
3c7dd85
add missing env vars to docker-compose.yml
tvarohohlavy Dec 2, 2025
1de82fa
better handle importing of existing local repository config + documen…
tvarohohlavy Dec 2, 2025
abe6eb7
removed duplicate isencrypted check as its handled inside encrypt alr…
tvarohohlavy Dec 3, 2025
1d42630
enhance repository creation logic to check for existing repositories …
tvarohohlavy Dec 4, 2025
ea9003c
Merge branch 'main' into config-import-json
tvarohohlavy Dec 5, 2025
0b2576a
Merge remote-tracking branch 'origin/main' into config-import-json
tvarohohlavy Dec 17, 2025
7f124f8
refactor the import logic
tvarohohlavy Dec 18, 2025
6e1cc89
Merge origin/main into config-import-json
tvarohohlavy Dec 19, 2025
8c3e85c
Merge remote-tracking branch 'origin/main' into config-import-json
tvarohohlavy Dec 21, 2025
7b2bb25
moved documentation into separate example sub directory
tvarohohlavy Dec 21, 2025
883ee49
Merge remote-tracking branch 'origin/main' into config-import-json
tvarohohlavy Dec 22, 2025
9e9edd7
Merge remote-tracking branch 'origin/main' into config-import-json
tvarohohlavy Dec 23, 2025
7476897
feat(config-import): support mirrors, oneFileSystem, and optional flags
tvarohohlavy Dec 23, 2025
45b5c0d
feat(cli): add import-config command for manual config import
tvarohohlavy Dec 29, 2025
5cdec5c
Merge remote-tracking branch 'origin/main' into config-import-json
tvarohohlavy Dec 29, 2025
e4f66de
linting and SFTP volume support
tvarohohlavy Dec 29, 2025
5a4f464
feat: improve config import with result tracking and idempotent repos…
tvarohohlavy Dec 29, 2025
a71009f
Increment warning count on volume mount failure
tvarohohlavy Dec 29, 2025
e5d8827
Enhance error logging for repo existence check
tvarohohlavy Dec 29, 2025
f908883
Fix error handling in repository existence check
tvarohohlavy Dec 29, 2025
3c32b3e
Merge branch 'main' into config-import-json
tvarohohlavy Dec 29, 2025
e9424e3
Merge branch 'main' into config-import-json
tvarohohlavy Dec 29, 2025
16ef4b4
docs: add host-side environment variable interpolation examples for C…
tvarohohlavy Dec 30, 2025
60c0778
refactor: improve config import logging, add CLI JSON output and idem…
tvarohohlavy Dec 30, 2025
3c7419f
make the isexisting repo in location check for all repos
tvarohohlavy Dec 30, 2025
0be65dc
fix: normalize notification destination names before checking for exi…
tvarohohlavy Dec 30, 2025
37d04d3
fix: normalize names for existence checks
tvarohohlavy Dec 31, 2025
cc9982b
feat: add support for provided shortId in volume, repository and back…
tvarohohlavy Jan 2, 2026
88bb174
feat: add option to overwrite recovery key during config import and p…
tvarohohlavy Jan 2, 2026
b25ed60
fix: replace Error with BadRequestError for invalid shortId format in…
tvarohohlavy Jan 2, 2026
04cbb58
refactor: remove config import from startup and update README for CLI…
tvarohohlavy Jan 2, 2026
4889b85
fix: remove startup import type from README
tvarohohlavy Jan 2, 2026
24f850b
refactor: move toError function to utils/errors for reuse across modules
tvarohohlavy Jan 2, 2026
8591432
refactor: add ArkType schema validation for config import
tvarohohlavy Jan 2, 2026
88d5e17
Merge branch 'main' into config-import-json
tvarohohlavy Jan 2, 2026
b0d9985
Merge branch 'main' into config-import-json
tvarohohlavy Jan 3, 2026
08f7dca
Merge branch 'main' into config-import-json
tvarohohlavy Jan 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@ If you need remote mount capabilities, keep the original configuration with `cap

See [examples/README.md](examples/README.md) for runnable, copy/paste-friendly examples.

### Config file import (Infrastructure as Code)

If you want Zerobyte to create volumes, repositories, schedules, notification destinations, and an initial user from a JSON file, check the following example:

- [examples/config-file-import/README.md](examples/config-file-import/README.md)

## Adding your first volume

Zerobyte supports multiple volume backends including NFS, SMB, WebDAV, SFTP, and local directories. A volume represents the source data you want to back up and monitor.
Expand Down
114 changes: 114 additions & 0 deletions app/schemas/config-import.ts
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";
176 changes: 176 additions & 0 deletions app/server/cli/commands/import-config.ts
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");
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}`);
}
});
2 changes: 2 additions & 0 deletions app/server/cli/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Command } from "commander";
import { importConfigCommand } from "./commands/import-config";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 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 -15

Repository: 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=15

Repository: 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 "type": "module"". Change to import { importConfigCommand } from "./commands/import-config.js";

Note: While the rest of the codebase omits extensions, this guideline is explicit and one file (openapi-ts.config.ts) demonstrates compliance by including .js.

🤖 Prompt for AI Agents
In @app/server/cli/index.ts around line 2, Update the import statement that
brings in importConfigCommand in index.ts to use a Node-compatible module
specifier by adding the .js extension to the imported module path; locate the
line importing importConfigCommand and change its module specifier to include
the .js extension so the import conforms to "type: module" ESM rules.

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> {
Expand Down
2 changes: 1 addition & 1 deletion app/server/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export class AuthService {
const [existingUser] = await db.select().from(usersTable);

if (existingUser) {
throw new Error("Admin user already exists");
throw new Error("A user already exists");
}

const passwordHash = await Bun.password.hash(password, {
Expand Down
2 changes: 1 addition & 1 deletion app/server/modules/backups/backups.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { describeRoute, resolver } from "hono-openapi";
import { volumeSchema } from "../volumes/volume.dto";
import { repositorySchema } from "../repositories/repositories.dto";

const retentionPolicySchema = type({
export const retentionPolicySchema = type({
keepLast: "number?",
keepHourly: "number?",
keepDaily: "number?",
Expand Down
25 changes: 22 additions & 3 deletions app/server/modules/backups/backups.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { notificationsService } from "../notifications/notifications.service";
import { repoMutex } from "../../core/repository-mutex";
import { checkMirrorCompatibility, getIncompatibleMirrorError } from "~/server/utils/backend-compatibility";
import path from "node:path";
import { generateShortId } from "~/server/utils/id";
import { generateShortId, isValidShortId } from "~/server/utils/id";

const runningBackups = new Map<number, AbortController>();

Expand Down Expand Up @@ -83,7 +83,7 @@ const getSchedule = async (scheduleId: number) => {
return schedule;
};

const createSchedule = async (data: CreateBackupScheduleBody) => {
const createSchedule = async (data: CreateBackupScheduleBody, providedShortId?: string) => {
if (!cron.validate(data.cronExpression)) {
throw new BadRequestError("Invalid cron expression");
}
Expand All @@ -96,6 +96,25 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
throw new ConflictError("A backup schedule with this name already exists");
}

// Use provided shortId if valid, otherwise generate a new one
let shortId: string;
if (providedShortId) {
if (!isValidShortId(providedShortId)) {
throw new BadRequestError(`Invalid shortId format: '${providedShortId}'. Must be 8 base64url characters.`);
}
const shortIdInUse = await db.query.backupSchedulesTable.findFirst({
where: eq(backupSchedulesTable.shortId, providedShortId),
});
if (shortIdInUse) {
throw new ConflictError(
`Schedule shortId '${providedShortId}' is already in use by schedule '${shortIdInUse.name}'`,
);
}
shortId = providedShortId;
} else {
shortId = generateShortId();
}

const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.id, data.volumeId),
});
Expand Down Expand Up @@ -128,7 +147,7 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
includePatterns: data.includePatterns ?? [],
oneFileSystem: data.oneFileSystem,
nextBackupAt: nextBackupAt,
shortId: generateShortId(),
shortId,
})
.returning();

Expand Down
Loading