refactor the import logic
- Updated README to reflect changes in config file structure and usage. - Modified error messages for user registration to be more generic. - Cleaned up startup logic to utilize the new config import functionality when requested. - Adjusted JSON config structure to support user definitions.
This commit is contained in:
parent
0b2576af91
commit
7f124f81cb
7 changed files with 455 additions and 243 deletions
51
README.md
51
README.md
|
|
@ -40,7 +40,9 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o
|
|||
### Configure Zerobyte via Config File
|
||||
|
||||
|
||||
You can pre-configure backup sources (volumes), destinations (repositories), backup schedules, notification destinations and admin user using a config file (`zerobyte.config.json` by default (mounted in /app dir), or set `ZEROBYTE_CONFIG_PATH`).
|
||||
You can pre-configure backup sources (volumes), destinations (repositories), backup schedules, notification destinations and initial users using a config file (`zerobyte.config.json` by default (mounted in /app dir), or set `ZEROBYTE_CONFIG_PATH`).
|
||||
|
||||
Config import is opt-in. Enable it by setting `ZEROBYTE_CONFIG_IMPORT=true`.
|
||||
|
||||
Secrets/credentials in the config file can reference environment variables using `${VAR_NAME}` syntax for secure injection.
|
||||
|
||||
|
|
@ -58,6 +60,7 @@ Secrets/credentials in the config file can reference environment variables using
|
|||
|
||||
```json
|
||||
{
|
||||
"recoveryKey": "${RECOVERY_KEY}",
|
||||
"volumes": [
|
||||
// Array of volume objects. Each must have a unique "name" and a "config" matching one of the types below.
|
||||
],
|
||||
|
|
@ -70,6 +73,10 @@ Secrets/credentials in the config file can reference environment variables using
|
|||
],
|
||||
"notificationDestinations": [
|
||||
// Array of notification destination objects as described below.
|
||||
],
|
||||
"users": [
|
||||
// Array of user objects. Each must have a unique "username".
|
||||
// Note: Zerobyte currently supports a single user; only the first entry is applied.
|
||||
]
|
||||
}
|
||||
```
|
||||
|
|
@ -215,6 +222,7 @@ Secrets/credentials in the config file can reference environment variables using
|
|||
- **Example:**
|
||||
```json
|
||||
{
|
||||
"name": "local-volume-local-repo",
|
||||
"volume": "local-volume",
|
||||
"repository": "local-repo",
|
||||
"cronExpression": "0 2 * * *",
|
||||
|
|
@ -226,6 +234,7 @@ Secrets/credentials in the config file can reference environment variables using
|
|||
}
|
||||
```
|
||||
- **Fields:**
|
||||
- `name`: Unique name of the schedule
|
||||
- `volume`: Name of the source volume
|
||||
- `repository`: Name of the destination repository
|
||||
- `cronExpression`: Cron string for schedule
|
||||
|
|
@ -234,7 +243,7 @@ Secrets/credentials in the config file can reference environment variables using
|
|||
- `enabled`: Boolean
|
||||
- `notifications`: Array of notification destination names (strings) or detailed objects:
|
||||
- Simple: `["slack-alerts", "email-admin"]`
|
||||
- Detailed: `[{"name": "slack-alerts", "notifyOnStart": false, "notifyOnSuccess": true, "notifyOnFailure": true}]`
|
||||
- Detailed: `[{"name": "slack-alerts", "notifyOnStart": false, "notifyOnSuccess": true, "notifyOnWarning": true, "notifyOnFailure": true}]`
|
||||
|
||||
##### Notification Destinations
|
||||
|
||||
|
|
@ -348,39 +357,47 @@ Secrets/credentials in the config file can reference environment variables using
|
|||
- `config.type`: Notification type (email, slack, discord, gotify, ntfy, pushover, telegram, custom)
|
||||
- `config`: Type-specific config with `type` field, secrets via `${ENV_VAR}`
|
||||
|
||||
##### Admin Setup (Automated)
|
||||
##### User Setup (Automated)
|
||||
|
||||
Zerobyte currently supports a **single user**. If multiple entries are provided in `users[]`, only the first one will be applied.
|
||||
|
||||
- **Example (new instance):**
|
||||
```json
|
||||
{
|
||||
"admin": {
|
||||
"username": "admin",
|
||||
"password": "${ADMIN_PASSWORD}",
|
||||
"recoveryKey": "${RECOVERY_KEY}"
|
||||
}
|
||||
"recoveryKey": "${RECOVERY_KEY}",
|
||||
"users": [
|
||||
{
|
||||
"username": "my-user",
|
||||
"password": "${ADMIN_PASSWORD}"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- **Example (migration from another instance):**
|
||||
```json
|
||||
{
|
||||
"admin": {
|
||||
"username": "admin",
|
||||
"passwordHash": "$argon2id$v=19$m=19456,t=2,p=1$...",
|
||||
"recoveryKey": "${RECOVERY_KEY}"
|
||||
}
|
||||
"recoveryKey": "${RECOVERY_KEY}",
|
||||
"users": [
|
||||
{
|
||||
"username": "my-user",
|
||||
"passwordHash": "$argon2id$v=19$m=19456,t=2,p=1$..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- **Fields:**
|
||||
- `username`: Admin username to create on first startup
|
||||
- `password`: Admin password for new instances (can use `${ENV_VAR}`)
|
||||
- `passwordHash`: Pre-hashed password for migration (exported from another instance)
|
||||
- `recoveryKey`: Optional recovery key (can use `${ENV_VAR}`) - if provided, the UI prompt to download recovery key will be skipped
|
||||
- `users[]`: List of users to create on first startup (create-only). Only the first user is applied.
|
||||
- `users[].username`: Username
|
||||
- `users[].password`: Plaintext password for new instances (can use `${ENV_VAR}`)
|
||||
- `users[].passwordHash`: Pre-hashed password for migration (exported from another instance)
|
||||
- `users[].hasDownloadedResticPassword`: Optional boolean; defaults to `true` when `recoveryKey` is provided
|
||||
|
||||
> **Note:** Use either `password` OR `passwordHash`, not both. The `passwordHash` option is useful when migrating from another Zerobyte instance using an exported config with `includePasswordHash=true`.
|
||||
|
||||
**On first startup, Zerobyte will automatically create the admin user from the config file.**
|
||||
**On first startup, Zerobyte will automatically create users from the config file.**
|
||||
|
||||
> **⚠️ About the Recovery Key**
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -88,17 +88,6 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
|
|||
throw new NotFoundError("Repository not found");
|
||||
}
|
||||
|
||||
const existingSchedule = await db.query.backupSchedulesTable.findFirst({
|
||||
where: and(
|
||||
eq(backupSchedulesTable.volumeId, volume.id),
|
||||
eq(backupSchedulesTable.repositoryId, repository.id)
|
||||
),
|
||||
});
|
||||
|
||||
if (existingSchedule) {
|
||||
throw new ConflictError(`A backup schedule for volume '${volume.name}' and repository '${repository.name}' already exists`);
|
||||
}
|
||||
|
||||
const nextBackupAt = calculateNextRun(data.cronExpression);
|
||||
|
||||
const [newSchedule] = await db
|
||||
|
|
|
|||
404
app/server/modules/lifecycle/config-import.ts
Normal file
404
app/server/modules/lifecycle/config-import.ts
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import slugify from "slugify";
|
||||
import { db } from "../../db/db";
|
||||
import { usersTable } from "../../db/schema";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { volumeService } from "../volumes/volume.service";
|
||||
import type { NotificationConfig } from "~/schemas/notifications";
|
||||
import type { RepositoryConfig } from "~/schemas/restic";
|
||||
import type { BackendConfig } from "~/schemas/volumes";
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;
|
||||
|
||||
const asStringArray = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((item): item is string => typeof item === "string");
|
||||
};
|
||||
|
||||
type RetentionPolicy = {
|
||||
keepLast?: number;
|
||||
keepHourly?: number;
|
||||
keepDaily?: number;
|
||||
keepWeekly?: number;
|
||||
keepMonthly?: number;
|
||||
keepYearly?: number;
|
||||
keepWithinDuration?: string;
|
||||
};
|
||||
|
||||
type ImportConfig = {
|
||||
volumes: unknown[];
|
||||
repositories: unknown[];
|
||||
backupSchedules: unknown[];
|
||||
notificationDestinations: unknown[];
|
||||
users: unknown[];
|
||||
recoveryKey: string | null;
|
||||
};
|
||||
|
||||
function interpolateEnvVars(value: unknown): unknown {
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\$\{([^}]+)\}/g, (_, v) => {
|
||||
if (process.env[v] === undefined) {
|
||||
logger.warn(`Environment variable '${v}' is not defined. Replacing with empty string.`);
|
||||
return "";
|
||||
}
|
||||
return process.env[v];
|
||||
});
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(interpolateEnvVars);
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, interpolateEnvVars(v)]));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function loadConfigFromFile(): Promise<unknown | null> {
|
||||
try {
|
||||
const configPath = process.env.ZEROBYTE_CONFIG_PATH || "zerobyte.config.json";
|
||||
const fs = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
const configFullPath = path.resolve(process.cwd(), configPath);
|
||||
try {
|
||||
const raw = await fs.readFile(configFullPath, "utf-8");
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
if (isRecord(error) && error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
logger.warn(`No config file loaded or error parsing config: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseImportConfig(configRaw: unknown): ImportConfig {
|
||||
const root = isRecord(configRaw) ? configRaw : {};
|
||||
const config = isRecord(root.config) ? (root.config as Record<string, unknown>) : root;
|
||||
|
||||
const volumes = interpolateEnvVars(config.volumes || []);
|
||||
const repositories = interpolateEnvVars(config.repositories || []);
|
||||
const backupSchedules = interpolateEnvVars(config.backupSchedules || []);
|
||||
const notificationDestinations = interpolateEnvVars(config.notificationDestinations || []);
|
||||
const users = interpolateEnvVars(config.users || []);
|
||||
const recoveryKeyRaw = interpolateEnvVars(config.recoveryKey || null);
|
||||
|
||||
return {
|
||||
volumes: Array.isArray(volumes) ? volumes : [],
|
||||
repositories: Array.isArray(repositories) ? repositories : [],
|
||||
backupSchedules: Array.isArray(backupSchedules) ? backupSchedules : [],
|
||||
notificationDestinations: Array.isArray(notificationDestinations) ? notificationDestinations : [],
|
||||
users: Array.isArray(users) ? users : [],
|
||||
recoveryKey: typeof recoveryKeyRaw === "string" ? recoveryKeyRaw : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeRecoveryKeyFromConfig(recoveryKey: string | null): Promise<void> {
|
||||
try {
|
||||
const fs = await import("node:fs/promises");
|
||||
const { RESTIC_PASS_FILE } = await import("../../core/constants.js");
|
||||
if (!recoveryKey) return;
|
||||
|
||||
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");
|
||||
}
|
||||
const passFileExists = await fs.stat(RESTIC_PASS_FILE).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (passFileExists) {
|
||||
logger.info(`Restic passfile already exists at ${RESTIC_PASS_FILE}; skipping config recovery key write`);
|
||||
return;
|
||||
}
|
||||
await fs.writeFile(RESTIC_PASS_FILE, recoveryKey, { mode: 0o600 });
|
||||
logger.info(`Recovery key written from config to ${RESTIC_PASS_FILE}`);
|
||||
} catch (err) {
|
||||
const e = err instanceof Error ? err : new Error(String(err));
|
||||
logger.error(`Failed to write recovery key from config: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function importVolumes(volumes: unknown[]): Promise<void> {
|
||||
for (const v of volumes) {
|
||||
try {
|
||||
if (!isRecord(v) || typeof v.name !== "string" || !isRecord(v.config) || typeof v.config.backend !== "string") {
|
||||
throw new Error("Invalid volume entry");
|
||||
}
|
||||
await volumeService.createVolume(v.name, v.config as BackendConfig);
|
||||
logger.info(`Initialized volume from config: ${v.name}`);
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
logger.warn(`Volume not created: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function importRepositories(repositories: unknown[]): Promise<void> {
|
||||
const repoServiceModule = await import("../repositories/repositories.service");
|
||||
for (const r of repositories) {
|
||||
try {
|
||||
if (!isRecord(r) || typeof r.name !== "string" || !isRecord(r.config) || typeof r.config.backend !== "string") {
|
||||
throw new Error("Invalid repository entry");
|
||||
}
|
||||
const compressionMode =
|
||||
r.compressionMode === "auto" || r.compressionMode === "off" || r.compressionMode === "max"
|
||||
? r.compressionMode
|
||||
: undefined;
|
||||
await repoServiceModule.repositoriesService.createRepository(
|
||||
r.name,
|
||||
r.config as RepositoryConfig,
|
||||
compressionMode,
|
||||
);
|
||||
logger.info(`Initialized repository from config: ${r.name}`);
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
logger.warn(`Repository not created: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function importNotificationDestinations(notificationDestinations: unknown[]): Promise<void> {
|
||||
const notificationsServiceModule = await import("../notifications/notifications.service");
|
||||
for (const n of notificationDestinations) {
|
||||
try {
|
||||
if (!isRecord(n) || typeof n.name !== "string" || !isRecord(n.config) || typeof n.config.type !== "string") {
|
||||
throw new Error("Invalid notification destination entry");
|
||||
}
|
||||
await notificationsServiceModule.notificationsService.createDestination(n.name, n.config as NotificationConfig);
|
||||
logger.info(`Initialized notification destination from config: ${n.name}`);
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
logger.warn(`Notification destination not created: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getScheduleVolumeName(schedule: Record<string, unknown>): string | null {
|
||||
return typeof schedule.volume === "string" ? schedule.volume : typeof schedule.volumeName === "string" ? schedule.volumeName : null;
|
||||
}
|
||||
|
||||
function getScheduleRepositoryName(schedule: Record<string, unknown>): string | null {
|
||||
return typeof schedule.repository === "string"
|
||||
? schedule.repository
|
||||
: typeof schedule.repositoryName === "string"
|
||||
? schedule.repositoryName
|
||||
: null;
|
||||
}
|
||||
|
||||
type ScheduleNotificationAssignment = {
|
||||
destinationId: number;
|
||||
notifyOnStart: boolean;
|
||||
notifyOnSuccess: boolean;
|
||||
notifyOnWarning: boolean;
|
||||
notifyOnFailure: boolean;
|
||||
};
|
||||
|
||||
function buildScheduleNotificationAssignments(
|
||||
notifications: unknown[],
|
||||
destinationBySlug: Map<string, { id: number; name: string }>,
|
||||
): ScheduleNotificationAssignment[] {
|
||||
const assignments: ScheduleNotificationAssignment[] = [];
|
||||
|
||||
for (const notif of notifications) {
|
||||
const destName = typeof notif === "string" ? notif : isRecord(notif) ? notif.name : null;
|
||||
if (typeof destName !== "string" || destName.length === 0) {
|
||||
logger.warn("Notification destination missing name for schedule");
|
||||
continue;
|
||||
}
|
||||
const destSlug = slugify(destName, { lower: true, strict: true });
|
||||
const dest = destinationBySlug.get(destSlug);
|
||||
if (!dest) {
|
||||
logger.warn(`Notification destination '${destName}' not found for schedule`);
|
||||
continue;
|
||||
}
|
||||
assignments.push({
|
||||
destinationId: dest.id,
|
||||
notifyOnStart: isRecord(notif) && typeof notif.notifyOnStart === "boolean" ? notif.notifyOnStart : true,
|
||||
notifyOnSuccess: isRecord(notif) && typeof notif.notifyOnSuccess === "boolean" ? notif.notifyOnSuccess : true,
|
||||
notifyOnWarning: isRecord(notif) && typeof notif.notifyOnWarning === "boolean" ? notif.notifyOnWarning : true,
|
||||
notifyOnFailure: isRecord(notif) && typeof notif.notifyOnFailure === "boolean" ? notif.notifyOnFailure : true,
|
||||
});
|
||||
}
|
||||
|
||||
return assignments;
|
||||
}
|
||||
|
||||
async function attachScheduleNotifications(
|
||||
scheduleId: number,
|
||||
notifications: unknown[],
|
||||
destinationBySlug: Map<string, { id: number; name: string }>,
|
||||
notificationsServiceModule: typeof import("../notifications/notifications.service"),
|
||||
): Promise<void> {
|
||||
try {
|
||||
const assignments = buildScheduleNotificationAssignments(notifications, destinationBySlug);
|
||||
if (assignments.length === 0) return;
|
||||
|
||||
await notificationsServiceModule.notificationsService.updateScheduleNotifications(scheduleId, assignments);
|
||||
logger.info(`Assigned ${assignments.length} notification(s) to backup schedule`);
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
logger.warn(`Failed to assign notifications to schedule: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function importBackupSchedules(backupSchedules: unknown[]): Promise<void> {
|
||||
const backupServiceModule = await import("../backups/backups.service");
|
||||
const notificationsServiceModule = await import("../notifications/notifications.service");
|
||||
if (!Array.isArray(backupSchedules) || backupSchedules.length === 0) return;
|
||||
|
||||
const volumes = await db.query.volumesTable.findMany();
|
||||
const repositories = await db.query.repositoriesTable.findMany();
|
||||
const destinations = await db.query.notificationDestinationsTable.findMany();
|
||||
|
||||
const volumeByName = new Map(volumes.map((v) => [v.name, v] as const));
|
||||
const repoByName = new Map(repositories.map((r) => [r.name, r] as const));
|
||||
const destinationBySlug = new Map(destinations.map((d) => [d.name, d] as const));
|
||||
|
||||
for (const s of backupSchedules) {
|
||||
if (!isRecord(s)) {
|
||||
continue;
|
||||
}
|
||||
const volumeName = getScheduleVolumeName(s);
|
||||
if (typeof volumeName !== "string" || volumeName.length === 0) {
|
||||
logger.warn("Backup schedule not created: Missing volume name");
|
||||
continue;
|
||||
}
|
||||
const volume = volumeByName.get(volumeName);
|
||||
if (!volume) {
|
||||
logger.warn(`Backup schedule not created: Volume '${volumeName}' not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const repositoryName = getScheduleRepositoryName(s);
|
||||
if (typeof repositoryName !== "string" || repositoryName.length === 0) {
|
||||
logger.warn("Backup schedule not created: Missing repository name");
|
||||
continue;
|
||||
}
|
||||
const repository = repoByName.get(repositoryName);
|
||||
if (!repository) {
|
||||
logger.warn(`Backup schedule not created: Repository '${repositoryName}' not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const scheduleName = typeof s.name === "string" && s.name.length > 0 ? s.name : `${volumeName}-${repositoryName}`;
|
||||
if (typeof s.cronExpression !== "string" || s.cronExpression.length === 0) {
|
||||
logger.warn(`Backup schedule not created: Missing cronExpression for '${scheduleName}'`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (volume.status !== "mounted") {
|
||||
try {
|
||||
await volumeService.mountVolume(volume.name);
|
||||
volumeByName.set(volume.name, { ...volume, status: "mounted" });
|
||||
logger.info(`Mounted volume ${volume.name} for backup schedule`);
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
logger.warn(`Could not mount volume ${volume.name}: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let createdSchedule: { id: number } | null = null;
|
||||
try {
|
||||
const retentionPolicy = isRecord(s.retentionPolicy) ? (s.retentionPolicy as RetentionPolicy) : undefined;
|
||||
createdSchedule = await backupServiceModule.backupsService.createSchedule({
|
||||
name: scheduleName,
|
||||
volumeId: volume.id,
|
||||
repositoryId: repository.id,
|
||||
enabled: typeof s.enabled === "boolean" ? s.enabled : true,
|
||||
cronExpression: s.cronExpression,
|
||||
retentionPolicy,
|
||||
excludePatterns: asStringArray(s.excludePatterns),
|
||||
excludeIfPresent: asStringArray(s.excludeIfPresent),
|
||||
includePatterns: asStringArray(s.includePatterns),
|
||||
});
|
||||
logger.info(`Initialized backup schedule from config: ${scheduleName}`);
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
logger.warn(`Backup schedule not created: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (createdSchedule && Array.isArray(s.notifications) && s.notifications.length > 0) {
|
||||
await attachScheduleNotifications(createdSchedule.id, s.notifications, destinationBySlug, notificationsServiceModule);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setupInitialUser(users: unknown[], recoveryKey: string | null): Promise<void> {
|
||||
try {
|
||||
const { authService } = await import("../auth/auth.service");
|
||||
const hasUsers = await authService.hasUsers();
|
||||
if (hasUsers) return;
|
||||
if (!Array.isArray(users) || users.length === 0) return;
|
||||
|
||||
if (users.length > 1) {
|
||||
logger.warn(
|
||||
"Multiple users provided in config. Zerobyte currently supports a single initial user; extra entries will be ignored.",
|
||||
);
|
||||
}
|
||||
|
||||
for (const u of users) {
|
||||
if (!isRecord(u)) continue;
|
||||
if (typeof u.username !== "string" || u.username.length === 0) continue;
|
||||
|
||||
if (typeof u.passwordHash === "string" && u.passwordHash.length > 0) {
|
||||
try {
|
||||
await db.insert(usersTable).values({
|
||||
username: u.username,
|
||||
passwordHash: u.passwordHash,
|
||||
hasDownloadedResticPassword:
|
||||
typeof u.hasDownloadedResticPassword === "boolean" ? u.hasDownloadedResticPassword : Boolean(recoveryKey),
|
||||
});
|
||||
logger.info(`User '${u.username}' imported with password hash from config.`);
|
||||
break;
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
logger.warn(`User '${u.username}' not imported: ${err.message}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof u.password === "string" && u.password.length > 0) {
|
||||
try {
|
||||
const { user } = await authService.register(u.username, u.password);
|
||||
const hasDownloadedResticPassword =
|
||||
typeof u.hasDownloadedResticPassword === "boolean" ? u.hasDownloadedResticPassword : Boolean(recoveryKey);
|
||||
if (hasDownloadedResticPassword) {
|
||||
await db.update(usersTable).set({ hasDownloadedResticPassword }).where(eq(usersTable.id, user.id));
|
||||
}
|
||||
logger.info(`User '${u.username}' created from config.`);
|
||||
break;
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
logger.warn(`User '${u.username}' not created: ${err.message}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.warn(`User '${u.username}' missing passwordHash/password; skipping`);
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err instanceof Error ? err : new Error(String(err));
|
||||
logger.error(`Automated user setup failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
logger.error(`Failed to initialize from config: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { Scheduler } from "../../core/scheduler";
|
||||
import { and, eq, or } from "drizzle-orm";
|
||||
import { db } from "../../db/db";
|
||||
import { volumesTable, usersTable, repositoriesTable, notificationDestinationsTable } from "../../db/schema";
|
||||
import { volumesTable } from "../../db/schema";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { restic } from "../../utils/restic";
|
||||
import { volumeService } from "../volumes/volume.service";
|
||||
|
|
@ -10,224 +10,23 @@ import { VolumeHealthCheckJob } from "../../jobs/healthchecks";
|
|||
import { RepositoryHealthCheckJob } from "../../jobs/repository-healthchecks";
|
||||
import { BackupExecutionJob } from "../../jobs/backup-execution";
|
||||
import { CleanupSessionsJob } from "../../jobs/cleanup-sessions";
|
||||
import { applyConfigImportFromFile } from "./config-import";
|
||||
|
||||
export const startup = async () => {
|
||||
let configFileVolumes = [];
|
||||
let configFileRepositories = [];
|
||||
let configFileBackupSchedules = [];
|
||||
let configFileNotificationDestinations = [];
|
||||
let configFileAdmin = null;
|
||||
try {
|
||||
const configPath = process.env.ZEROBYTE_CONFIG_PATH || "zerobyte.config.json";
|
||||
const fs = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
const configFullPath = path.resolve(process.cwd(), configPath);
|
||||
if (await fs.stat(configFullPath).then(() => true, () => false)) {
|
||||
const raw = await fs.readFile(configFullPath, "utf-8");
|
||||
const config = JSON.parse(raw);
|
||||
|
||||
function interpolate(obj) {
|
||||
if (typeof obj === "string") {
|
||||
return obj.replace(/\$\{([^}]+)\}/g, (_, v) => {
|
||||
if (process.env[v] === undefined) {
|
||||
logger.warn(`Environment variable '${v}' is not defined. Replacing with empty string.`);
|
||||
return "";
|
||||
}
|
||||
return process.env[v];
|
||||
});
|
||||
} else if (Array.isArray(obj)) {
|
||||
return obj.map(interpolate);
|
||||
} else if (obj && typeof obj === "object") {
|
||||
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, interpolate(v)]));
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
configFileVolumes = interpolate(config.volumes || []);
|
||||
configFileRepositories = interpolate(config.repositories || []);
|
||||
configFileBackupSchedules = interpolate(config.backupSchedules || []);
|
||||
configFileNotificationDestinations = interpolate(config.notificationDestinations || []);
|
||||
configFileAdmin = interpolate(config.admin || null);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(`No config file loaded or error parsing config: ${e.message}`);
|
||||
}
|
||||
|
||||
await Scheduler.start();
|
||||
await Scheduler.clear();
|
||||
|
||||
try {
|
||||
const fs = await import("node:fs/promises");
|
||||
const { RESTIC_PASS_FILE } = await import("../../core/constants.js");
|
||||
if (configFileAdmin && configFileAdmin.recoveryKey) {
|
||||
const recoveryKey = configFileAdmin.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");
|
||||
}
|
||||
await fs.writeFile(RESTIC_PASS_FILE, recoveryKey, { mode: 0o600 });
|
||||
logger.info(`Recovery key written from config to ${RESTIC_PASS_FILE}`);
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err instanceof Error ? err : new Error(String(err));
|
||||
logger.error(`Failed to write recovery key from config: ${e.message}`);
|
||||
if (process.env.ZEROBYTE_CONFIG_IMPORT === "true") {
|
||||
logger.info("Config import enabled (ZEROBYTE_CONFIG_IMPORT=true)");
|
||||
await applyConfigImportFromFile();
|
||||
} else {
|
||||
logger.info("Config import skipped (set ZEROBYTE_CONFIG_IMPORT=true to enable)");
|
||||
}
|
||||
|
||||
await restic.ensurePassfile().catch((err) => {
|
||||
logger.error(`Error ensuring restic passfile exists: ${err.message}`);
|
||||
});
|
||||
|
||||
try {
|
||||
for (const v of configFileVolumes) {
|
||||
try {
|
||||
await volumeService.createVolume(v.name, v.config);
|
||||
logger.info(`Initialized volume from config: ${v.name}`);
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
logger.warn(`Volume ${v.name} not created: ${err.message}`);
|
||||
}
|
||||
}
|
||||
const repoServiceModule = await import("../repositories/repositories.service");
|
||||
for (const r of configFileRepositories) {
|
||||
try {
|
||||
await repoServiceModule.repositoriesService.createRepository(r.name, r.config, r.compressionMode);
|
||||
logger.info(`Initialized repository from config: ${r.name}`);
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
logger.warn(`Repository ${r.name} not created: ${err.message}`);
|
||||
}
|
||||
}
|
||||
const notificationsServiceModule = await import("../notifications/notifications.service");
|
||||
for (const n of configFileNotificationDestinations) {
|
||||
try {
|
||||
await notificationsServiceModule.notificationsService.createDestination(n.name, n.config);
|
||||
logger.info(`Initialized notification destination from config: ${n.name}`);
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
logger.warn(`Notification destination ${n.name} not created: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const backupServiceModule = await import("../backups/backups.service");
|
||||
for (const s of configFileBackupSchedules) {
|
||||
const volumeName = s.volume || s.volumeName;
|
||||
const volume = await db.query.volumesTable.findFirst({
|
||||
where: eq(volumesTable.name, volumeName),
|
||||
});
|
||||
if (!volume) {
|
||||
logger.warn(`Backup schedule not created: Volume '${volumeName}' not found`);
|
||||
continue;
|
||||
}
|
||||
const repositoryName = s.repository || s.repositoryName;
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.name, repositoryName),
|
||||
});
|
||||
if (!repository) {
|
||||
logger.warn(`Backup schedule not created: Repository '${repositoryName}' not found`);
|
||||
continue;
|
||||
}
|
||||
if (volume.status !== "mounted") {
|
||||
try {
|
||||
await volumeService.mountVolume(volume.name);
|
||||
logger.info(`Mounted volume ${volume.name} for backup schedule`);
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
logger.warn(`Could not mount volume ${volume.name}: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let createdSchedule;
|
||||
try {
|
||||
createdSchedule = await backupServiceModule.backupsService.createSchedule({
|
||||
...s,
|
||||
volumeId: volume.id,
|
||||
repositoryId: repository.id,
|
||||
});
|
||||
logger.info(`Initialized backup schedule from config: ${s.cronExpression || s.name}`);
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
logger.warn(`Backup schedule not created: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (createdSchedule && s.notifications && Array.isArray(s.notifications) && s.notifications.length > 0) {
|
||||
try {
|
||||
const assignments: Array<{
|
||||
destinationId: number;
|
||||
notifyOnStart: boolean;
|
||||
notifyOnSuccess: boolean;
|
||||
notifyOnFailure: boolean;
|
||||
}> = [];
|
||||
for (const notif of s.notifications) {
|
||||
const destName = typeof notif === 'string' ? notif : notif.name;
|
||||
const dest = await db.query.notificationDestinationsTable.findFirst({
|
||||
where: eq(notificationDestinationsTable.name, destName),
|
||||
});
|
||||
if (dest) {
|
||||
assignments.push({
|
||||
destinationId: dest.id,
|
||||
notifyOnStart: typeof notif === 'object' ? (notif.notifyOnStart ?? true) : true,
|
||||
notifyOnSuccess: typeof notif === 'object' ? (notif.notifyOnSuccess ?? true) : true,
|
||||
notifyOnFailure: typeof notif === 'object' ? (notif.notifyOnFailure ?? true) : true,
|
||||
});
|
||||
} else {
|
||||
logger.warn(`Notification destination '${destName}' not found for schedule`);
|
||||
}
|
||||
}
|
||||
if (assignments.length > 0) {
|
||||
await notificationsServiceModule.notificationsService.updateScheduleNotifications(createdSchedule.id, assignments);
|
||||
logger.info(`Assigned ${assignments.length} notification(s) to backup schedule`);
|
||||
}
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
logger.warn(`Failed to assign notifications to schedule: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
try {
|
||||
const { authService } = await import("../auth/auth.service");
|
||||
if (configFileAdmin && configFileAdmin.username && (configFileAdmin.password || configFileAdmin.passwordHash)) {
|
||||
if (configFileAdmin.password && configFileAdmin.passwordHash) {
|
||||
logger.error("Config error: Both 'password' and 'passwordHash' provided for admin user. Use only one.");
|
||||
throw new Error("Invalid admin configuration");
|
||||
}
|
||||
const hasUsers = await authService.hasUsers();
|
||||
if (!hasUsers) {
|
||||
let userId: number;
|
||||
if (configFileAdmin.passwordHash) {
|
||||
// Import with existing password hash (migration from another instance)
|
||||
const [user] = await db.insert(usersTable).values({
|
||||
username: configFileAdmin.username,
|
||||
passwordHash: configFileAdmin.passwordHash,
|
||||
}).returning();
|
||||
userId = user.id;
|
||||
logger.info(`Admin user '${configFileAdmin.username}' imported with password hash from config.`);
|
||||
} else {
|
||||
// Create new user with plaintext password
|
||||
const { user } = await authService.register(configFileAdmin.username, configFileAdmin.password);
|
||||
userId = user.id;
|
||||
logger.info(`Admin user '${configFileAdmin.username}' created from config.`);
|
||||
}
|
||||
if (configFileAdmin.recoveryKey) {
|
||||
await db.update(usersTable).set({ hasDownloadedResticPassword: true }).where(eq(usersTable.id, userId));
|
||||
}
|
||||
}
|
||||
} else if (configFileAdmin) {
|
||||
logger.warn("Admin config missing required fields (username, password or passwordHash). Skipping automated admin setup.");
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err instanceof Error ? err : new Error(String(err));
|
||||
logger.error(`Automated admin setup failed: ${e.message}`);
|
||||
}
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
logger.error(`Failed to initialize from config: ${err.message}`);
|
||||
}
|
||||
|
||||
const volumes = await db.query.volumesTable.findMany({
|
||||
where: or(
|
||||
eq(volumesTable.status, "mounted"),
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ services:
|
|||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /var/lib/zerobyte:/var/lib/zerobyte
|
||||
|
||||
|
||||
- ./app:/app/app
|
||||
- ~/.config/rclone:/root/.config/rclone
|
||||
# - /run/docker/plugins:/run/docker/plugins
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@
|
|||
],
|
||||
"backupSchedules": [
|
||||
{
|
||||
"name": "local-volume-local-repo",
|
||||
"volume": "local-volume",
|
||||
"repository": "local-repo",
|
||||
"cronExpression": "0 2 * * *",
|
||||
|
|
@ -209,9 +210,11 @@
|
|||
}
|
||||
}
|
||||
],
|
||||
"admin": {
|
||||
"username": "admin",
|
||||
"password": "${ADMIN_PASSWORD}",
|
||||
"recoveryKey": "${RECOVERY_KEY}"
|
||||
}
|
||||
"recoveryKey": "${RECOVERY_KEY}",
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "${ADMIN_PASSWORD}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue