diff --git a/app/client/components/volume-icon.tsx b/app/client/components/volume-icon.tsx
index d2936c3e..b68d32e7 100644
--- a/app/client/components/volume-icon.tsx
+++ b/app/client/components/volume-icon.tsx
@@ -1,4 +1,4 @@
-import { Cloud, Folder, Server, Share2 } from "lucide-react";
+import { Cloud, Database, Folder, Server, Share2 } from "lucide-react";
import type { BackendType } from "~/schemas/volumes";
type VolumeIconProps = {
@@ -32,6 +32,30 @@ const getIconAndColor = (backend: BackendType) => {
color: "text-green-600 dark:text-green-400",
label: "WebDAV",
};
+ case "mariadb":
+ return {
+ icon: Database,
+ color: "text-teal-600 dark:text-teal-400",
+ label: "MariaDB",
+ };
+ case "mysql":
+ return {
+ icon: Database,
+ color: "text-cyan-600 dark:text-cyan-400",
+ label: "MySQL",
+ };
+ case "postgres":
+ return {
+ icon: Database,
+ color: "text-indigo-600 dark:text-indigo-400",
+ label: "PostgreSQL",
+ };
+ case "sqlite":
+ return {
+ icon: Database,
+ color: "text-slate-600 dark:text-slate-400",
+ label: "SQLite",
+ };
default:
return {
icon: Folder,
diff --git a/app/client/modules/volumes/routes/volume-details.tsx b/app/client/modules/volumes/routes/volume-details.tsx
index ed803345..25052764 100644
--- a/app/client/modules/volumes/routes/volume-details.tsx
+++ b/app/client/modules/volumes/routes/volume-details.tsx
@@ -118,6 +118,7 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
const { volume, statfs } = data;
const dockerAvailable = capabilities.docker;
+ const isDatabaseVolume = ["mariadb", "mysql", "postgres", "sqlite"].includes(volume.config.backend);
return (
<>
@@ -152,7 +153,7 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
setSearchParams({ tab: value })} className="mt-4">
Configuration
- Files
+ {!isDatabaseVolume && Files}
@@ -167,9 +168,11 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
-
-
-
+ {!isDatabaseVolume && (
+
+
+
+ )}
{dockerAvailable && (
diff --git a/app/client/modules/volumes/routes/volumes.tsx b/app/client/modules/volumes/routes/volumes.tsx
index 2f903852..20885c1f 100644
--- a/app/client/modules/volumes/routes/volumes.tsx
+++ b/app/client/modules/volumes/routes/volumes.tsx
@@ -109,6 +109,11 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
Directory
NFS
SMB
+ WebDAV
+ MariaDB
+ MySQL
+ PostgreSQL
+ SQLite
{(searchQuery || statusFilter || backendFilter) && (
diff --git a/app/schemas/volumes.ts b/app/schemas/volumes.ts
index dedc75f9..e76b7222 100644
--- a/app/schemas/volumes.ts
+++ b/app/schemas/volumes.ts
@@ -5,6 +5,9 @@ export const BACKEND_TYPES = {
smb: "smb",
directory: "directory",
webdav: "webdav",
+ mariadb: "mariadb",
+ mysql: "mysql",
+ postgres: "postgres",
} as const;
export type BackendType = keyof typeof BACKEND_TYPES;
@@ -47,7 +50,44 @@ export const webdavConfigSchema = type({
ssl: "boolean?",
});
-export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(webdavConfigSchema).or(directoryConfigSchema);
+export const mariadbConfigSchema = type({
+ backend: "'mariadb'",
+ host: "string",
+ port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(3306),
+ username: "string",
+ password: "string",
+ database: "string",
+ dumpOptions: "string[]?",
+});
+
+export const mysqlConfigSchema = type({
+ backend: "'mysql'",
+ host: "string",
+ port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(3306),
+ username: "string",
+ password: "string",
+ database: "string",
+ dumpOptions: "string[]?",
+});
+
+export const postgresConfigSchema = type({
+ backend: "'postgres'",
+ host: "string",
+ port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(5432),
+ username: "string",
+ password: "string",
+ database: "string",
+ dumpFormat: type("'plain' | 'custom' | 'directory'").default("custom"),
+ dumpOptions: "string[]?",
+});
+
+export const volumeConfigSchema = nfsConfigSchema
+ .or(smbConfigSchema)
+ .or(webdavConfigSchema)
+ .or(directoryConfigSchema)
+ .or(mariadbConfigSchema)
+ .or(mysqlConfigSchema)
+ .or(postgresConfigSchema);
export type BackendConfig = typeof volumeConfigSchema.infer;
diff --git a/app/server/jobs/cleanup-dangling.ts b/app/server/jobs/cleanup-dangling.ts
index 2051b027..1cd5febc 100644
--- a/app/server/jobs/cleanup-dangling.ts
+++ b/app/server/jobs/cleanup-dangling.ts
@@ -3,7 +3,7 @@ import path from "node:path";
import fs from "node:fs/promises";
import { volumeService } from "../modules/volumes/volume.service";
import { readMountInfo } from "../utils/mountinfo";
-import { getVolumePath } from "../modules/volumes/helpers";
+import { createVolumeBackend } from "../modules/backends/backend";
import { logger } from "../utils/logger";
import { executeUnmount } from "../modules/backends/utils/backend-utils";
import { toMessage } from "../utils/errors";
@@ -36,7 +36,10 @@ export class CleanupDanglingMountsJob extends Job {
for (const dir of allZerobyteDirs) {
const volumePath = `${VOLUME_MOUNT_BASE}/${dir}/_data`;
- const matchingVolume = allVolumes.find((v) => getVolumePath(v) === volumePath);
+ const matchingVolume = allVolumes.find((v) => {
+ const backend = createVolumeBackend(v);
+ return backend.getVolumePath() === volumePath;
+ });
if (!matchingVolume) {
const fullPath = path.join(VOLUME_MOUNT_BASE, dir);
logger.info(`Found dangling mount directory at ${fullPath}, attempting to remove...`);
diff --git a/app/server/modules/backends/backend.ts b/app/server/modules/backends/backend.ts
index c3431d5f..25267b9d 100644
--- a/app/server/modules/backends/backend.ts
+++ b/app/server/modules/backends/backend.ts
@@ -1,10 +1,13 @@
import type { BackendStatus } from "~/schemas/volumes";
import type { Volume } from "../../db/schema";
-import { getVolumePath } from "../volumes/helpers";
+import { VOLUME_MOUNT_BASE } from "../../core/constants";
import { makeDirectoryBackend } from "./directory/directory-backend";
import { makeNfsBackend } from "./nfs/nfs-backend";
import { makeSmbBackend } from "./smb/smb-backend";
import { makeWebdavBackend } from "./webdav/webdav-backend";
+import { makeMariaDBBackend } from "./mariadb/mariadb-backend";
+import { makeMySQLBackend } from "./mysql/mysql-backend";
+import { makePostgresBackend } from "./postgres/postgres-backend";
type OperationResult = {
error?: string;
@@ -15,23 +18,41 @@ export type VolumeBackend = {
mount: () => Promise;
unmount: () => Promise;
checkHealth: () => Promise;
+ getVolumePath: () => string;
+ isDatabaseBackend: () => boolean;
+ getDumpPath: () => string | null;
+ getDumpFilePath: (timestamp: number) => string | null;
};
export const createVolumeBackend = (volume: Volume): VolumeBackend => {
- const path = getVolumePath(volume);
+ const path = volume.config.backend === "directory"
+ ? volume.config.path
+ : `${VOLUME_MOUNT_BASE}/${volume.name}/_data`;
switch (volume.config.backend) {
case "nfs": {
- return makeNfsBackend(volume.config, path);
+ return makeNfsBackend(volume.config, volume.name, path);
}
case "smb": {
- return makeSmbBackend(volume.config, path);
+ return makeSmbBackend(volume.config, volume.name, path);
}
case "directory": {
- return makeDirectoryBackend(volume.config, path);
+ return makeDirectoryBackend(volume.config, volume.name, path);
}
case "webdav": {
- return makeWebdavBackend(volume.config, path);
+ return makeWebdavBackend(volume.config, volume.name, path);
+ }
+ case "mariadb": {
+ return makeMariaDBBackend(volume.config, volume.name, path);
+ }
+ case "mysql": {
+ return makeMySQLBackend(volume.config, volume.name, path);
+ }
+ case "postgres": {
+ return makePostgresBackend(volume.config, volume.name, path);
+ }
+ default: {
+ throw new Error(`Unsupported backend type: ${(volume.config as any).backend}`);
}
}
};
diff --git a/app/server/modules/backends/directory/directory-backend.ts b/app/server/modules/backends/directory/directory-backend.ts
index 67d69cc9..85b1c728 100644
--- a/app/server/modules/backends/directory/directory-backend.ts
+++ b/app/server/modules/backends/directory/directory-backend.ts
@@ -46,8 +46,12 @@ const checkHealth = async (config: BackendConfig) => {
}
};
-export const makeDirectoryBackend = (config: BackendConfig, volumePath: string): VolumeBackend => ({
+export const makeDirectoryBackend = (config: BackendConfig, volumeName: string, volumePath: string): VolumeBackend => ({
mount: () => mount(config, volumePath),
unmount,
checkHealth: () => checkHealth(config),
+ getVolumePath: () => config.backend === "directory" ? config.path : volumePath,
+ isDatabaseBackend: () => false,
+ getDumpPath: () => null,
+ getDumpFilePath: () => null,
});
diff --git a/app/server/modules/backends/mariadb/mariadb-backend.ts b/app/server/modules/backends/mariadb/mariadb-backend.ts
new file mode 100644
index 00000000..ef266992
--- /dev/null
+++ b/app/server/modules/backends/mariadb/mariadb-backend.ts
@@ -0,0 +1,65 @@
+import * as fs from "node:fs/promises";
+import { toMessage } from "../../../utils/errors";
+import { logger } from "../../../utils/logger";
+import { testMariaDBConnection } from "../../../utils/database-dump";
+import type { VolumeBackend } from "../backend";
+import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
+import { VOLUME_MOUNT_BASE } from "../../../core/constants";
+
+const mount = async (config: BackendConfig, volumePath: string) => {
+ if (config.backend !== "mariadb") {
+ return { status: BACKEND_STATUS.error, error: "Invalid backend type" };
+ }
+
+ logger.info(`Testing MariaDB connection to: ${config.host}:${config.port}`);
+
+ try {
+ await testMariaDBConnection(config);
+ await fs.mkdir(volumePath, { recursive: true });
+
+ logger.info("MariaDB connection successful");
+ return { status: BACKEND_STATUS.mounted };
+ } catch (error) {
+ logger.error("Failed to connect to MariaDB:", error);
+ return { status: BACKEND_STATUS.error, error: toMessage(error) };
+ }
+};
+
+const unmount = async (volumePath: string) => {
+ logger.info("Cleaning up MariaDB dump directory");
+
+ try {
+ await fs.rm(volumePath, { recursive: true, force: true });
+ return { status: BACKEND_STATUS.unmounted };
+ } catch (error) {
+ logger.warn(`Failed to clean up MariaDB dump directory: ${toMessage(error)}`);
+ return { status: BACKEND_STATUS.unmounted };
+ }
+};
+
+const checkHealth = async (config: BackendConfig) => {
+ if (config.backend !== "mariadb") {
+ return { status: BACKEND_STATUS.error, error: "Invalid backend type" };
+ }
+
+ try {
+ await testMariaDBConnection(config);
+ return { status: BACKEND_STATUS.mounted };
+ } catch (error) {
+ logger.error("MariaDB health check failed:", error);
+ return { status: BACKEND_STATUS.error, error: toMessage(error) };
+ }
+};
+
+export const makeMariaDBBackend = (config: BackendConfig, volumeName: string, volumePath: string): VolumeBackend => ({
+ mount: () => mount(config, volumePath),
+ unmount: () => unmount(volumePath),
+ checkHealth: () => checkHealth(config),
+ getVolumePath: () => volumePath,
+ isDatabaseBackend: () => true,
+ getDumpPath: () => `${VOLUME_MOUNT_BASE}/${volumeName}/dumps`,
+ getDumpFilePath: (timestamp: number) => {
+ const dumpDir = `${VOLUME_MOUNT_BASE}/${volumeName}/dumps`;
+ return `${dumpDir}/${volumeName}-${timestamp}.sql`;
+ },
+});
\ No newline at end of file
diff --git a/app/server/modules/backends/mysql/mysql-backend.ts b/app/server/modules/backends/mysql/mysql-backend.ts
new file mode 100644
index 00000000..724ac626
--- /dev/null
+++ b/app/server/modules/backends/mysql/mysql-backend.ts
@@ -0,0 +1,65 @@
+import * as fs from "node:fs/promises";
+import { toMessage } from "../../../utils/errors";
+import { logger } from "../../../utils/logger";
+import { testMySQLConnection } from "../../../utils/database-dump";
+import type { VolumeBackend } from "../backend";
+import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
+import { VOLUME_MOUNT_BASE } from "../../../core/constants";
+
+const mount = async (config: BackendConfig, volumePath: string) => {
+ if (config.backend !== "mysql") {
+ return { status: BACKEND_STATUS.error, error: "Invalid backend type" };
+ }
+
+ logger.info(`Testing MySQL connection to: ${config.host}:${config.port}`);
+
+ try {
+ await testMySQLConnection(config);
+ await fs.mkdir(volumePath, { recursive: true });
+
+ logger.info("MySQL connection successful");
+ return { status: BACKEND_STATUS.mounted };
+ } catch (error) {
+ logger.error("Failed to connect to MySQL:", error);
+ return { status: BACKEND_STATUS.error, error: toMessage(error) };
+ }
+};
+
+const unmount = async (volumePath: string) => {
+ logger.info("Cleaning up MySQL dump directory");
+
+ try {
+ await fs.rm(volumePath, { recursive: true, force: true });
+ return { status: BACKEND_STATUS.unmounted };
+ } catch (error) {
+ logger.warn(`Failed to clean up MySQL dump directory: ${toMessage(error)}`);
+ return { status: BACKEND_STATUS.unmounted };
+ }
+};
+
+const checkHealth = async (config: BackendConfig) => {
+ if (config.backend !== "mysql") {
+ return { status: BACKEND_STATUS.error, error: "Invalid backend type" };
+ }
+
+ try {
+ await testMySQLConnection(config);
+ return { status: BACKEND_STATUS.mounted };
+ } catch (error) {
+ logger.error("MySQL health check failed:", error);
+ return { status: BACKEND_STATUS.error, error: toMessage(error) };
+ }
+};
+
+export const makeMySQLBackend = (config: BackendConfig, volumeName: string, volumePath: string): VolumeBackend => ({
+ mount: () => mount(config, volumePath),
+ unmount: () => unmount(volumePath),
+ checkHealth: () => checkHealth(config),
+ getVolumePath: () => volumePath,
+ isDatabaseBackend: () => true,
+ getDumpPath: () => `${VOLUME_MOUNT_BASE}/${volumeName}/dumps`,
+ getDumpFilePath: (timestamp: number) => {
+ const dumpDir = `${VOLUME_MOUNT_BASE}/${volumeName}/dumps`;
+ return `${dumpDir}/${volumeName}-${timestamp}.sql`;
+ },
+});
\ No newline at end of file
diff --git a/app/server/modules/backends/nfs/nfs-backend.ts b/app/server/modules/backends/nfs/nfs-backend.ts
index ec1af0fb..d012cbf4 100644
--- a/app/server/modules/backends/nfs/nfs-backend.ts
+++ b/app/server/modules/backends/nfs/nfs-backend.ts
@@ -110,8 +110,13 @@ const checkHealth = async (path: string) => {
}
};
-export const makeNfsBackend = (config: BackendConfig, path: string): VolumeBackend => ({
+export const makeNfsBackend = (config: BackendConfig, volumeName: string, path: string): VolumeBackend => ({
mount: () => mount(config, path),
unmount: () => unmount(path),
+ checkHealth: () => checkHealth(path, config.readOnly ?? false),
+ getVolumePath: () => path,
+ isDatabaseBackend: () => false,
+ getDumpPath: () => null,
+ getDumpFilePath: () => null,
checkHealth: () => checkHealth(path),
});
diff --git a/app/server/modules/backends/postgres/postgres-backend.ts b/app/server/modules/backends/postgres/postgres-backend.ts
new file mode 100644
index 00000000..8e1e19dd
--- /dev/null
+++ b/app/server/modules/backends/postgres/postgres-backend.ts
@@ -0,0 +1,69 @@
+import * as fs from "node:fs/promises";
+import { toMessage } from "../../../utils/errors";
+import { logger } from "../../../utils/logger";
+import { testPostgresConnection } from "../../../utils/database-dump";
+import type { VolumeBackend } from "../backend";
+import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
+import { VOLUME_MOUNT_BASE } from "../../../core/constants";
+
+const mount = async (config: BackendConfig, volumePath: string) => {
+ if (config.backend !== "postgres") {
+ return { status: BACKEND_STATUS.error, error: "Invalid backend type" };
+ }
+
+ logger.info(`Testing PostgreSQL connection to: ${config.host}:${config.port}`);
+
+ try {
+ await testPostgresConnection(config);
+ await fs.mkdir(volumePath, { recursive: true });
+
+ logger.info("PostgreSQL connection successful");
+ return { status: BACKEND_STATUS.mounted };
+ } catch (error) {
+ logger.error("Failed to connect to PostgreSQL:", error);
+ return { status: BACKEND_STATUS.error, error: toMessage(error) };
+ }
+};
+
+const unmount = async (volumePath: string) => {
+ logger.info("Cleaning up PostgreSQL dump directory");
+
+ try {
+ await fs.rm(volumePath, { recursive: true, force: true });
+ return { status: BACKEND_STATUS.unmounted };
+ } catch (error) {
+ logger.warn(`Failed to clean up PostgreSQL dump directory: ${toMessage(error)}`);
+ return { status: BACKEND_STATUS.unmounted };
+ }
+};
+
+const checkHealth = async (config: BackendConfig) => {
+ if (config.backend !== "postgres") {
+ return { status: BACKEND_STATUS.error, error: "Invalid backend type" };
+ }
+
+ try {
+ await testPostgresConnection(config);
+ return { status: BACKEND_STATUS.mounted };
+ } catch (error) {
+ logger.error("PostgreSQL health check failed:", error);
+ return { status: BACKEND_STATUS.error, error: toMessage(error) };
+ }
+};
+
+export const makePostgresBackend = (config: BackendConfig, volumeName: string, volumePath: string): VolumeBackend => ({
+ mount: () => mount(config, volumePath),
+ unmount: () => unmount(volumePath),
+ checkHealth: () => checkHealth(config),
+ getVolumePath: () => volumePath,
+ isDatabaseBackend: () => true,
+ getDumpPath: () => `${VOLUME_MOUNT_BASE}/${volumeName}/dumps`,
+ getDumpFilePath: (timestamp: number) => {
+ const dumpDir = `${VOLUME_MOUNT_BASE}/${volumeName}/dumps`;
+ const extension = config.backend === "postgres" &&
+ (config as Extract).dumpFormat !== "plain"
+ ? "dump"
+ : "sql";
+ return `${dumpDir}/${volumeName}-${timestamp}.${extension}`;
+ },
+});
\ No newline at end of file
diff --git a/app/server/modules/backends/smb/smb-backend.ts b/app/server/modules/backends/smb/smb-backend.ts
index cdc112ab..b356b1c3 100644
--- a/app/server/modules/backends/smb/smb-backend.ts
+++ b/app/server/modules/backends/smb/smb-backend.ts
@@ -123,8 +123,13 @@ const checkHealth = async (path: string) => {
}
};
-export const makeSmbBackend = (config: BackendConfig, path: string): VolumeBackend => ({
+export const makeSmbBackend = (config: BackendConfig, volumeName: string, path: string): VolumeBackend => ({
mount: () => mount(config, path),
unmount: () => unmount(path),
+ checkHealth: () => checkHealth(path, config.readOnly ?? false),
+ getVolumePath: () => path,
+ isDatabaseBackend: () => false,
+ getDumpPath: () => null,
+ getDumpFilePath: () => null,
checkHealth: () => checkHealth(path),
});
diff --git a/app/server/modules/backends/webdav/webdav-backend.ts b/app/server/modules/backends/webdav/webdav-backend.ts
index 1e9b72f6..d731aae9 100644
--- a/app/server/modules/backends/webdav/webdav-backend.ts
+++ b/app/server/modules/backends/webdav/webdav-backend.ts
@@ -157,8 +157,13 @@ const checkHealth = async (path: string) => {
}
};
-export const makeWebdavBackend = (config: BackendConfig, path: string): VolumeBackend => ({
+export const makeWebdavBackend = (config: BackendConfig, volumeName: string, path: string): VolumeBackend => ({
mount: () => mount(config, path),
unmount: () => unmount(path),
+ checkHealth: () => checkHealth(path, config.readOnly ?? false),
+ getVolumePath: () => path,
+ isDatabaseBackend: () => false,
+ getDumpPath: () => null,
+ getDumpFilePath: () => null,
checkHealth: () => checkHealth(path),
});
diff --git a/app/server/modules/backups/backups.service.ts b/app/server/modules/backups/backups.service.ts
index 9e984593..45089b20 100644
--- a/app/server/modules/backups/backups.service.ts
+++ b/app/server/modules/backups/backups.service.ts
@@ -2,14 +2,16 @@ import { eq } from "drizzle-orm";
import cron from "node-cron";
import { CronExpressionParser } from "cron-parser";
import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced";
+import * as fs from "node:fs/promises";
import { db } from "../../db/db";
import { backupSchedulesTable, repositoriesTable, volumesTable } from "../../db/schema";
import { restic } from "../../utils/restic";
import { logger } from "../../utils/logger";
-import { getVolumePath } from "../volumes/helpers";
+import { createVolumeBackend } from "../backends/backend";
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backups.dto";
import { toMessage } from "../../utils/errors";
import { serverEvents } from "../../core/events";
+import { executeDatabaseDump, type DatabaseConfig } from "../../utils/database-dump";
const runningBackups = new Map();
@@ -206,7 +208,33 @@ const executeBackup = async (scheduleId: number, manual = false) => {
runningBackups.set(scheduleId, abortController);
try {
- const volumePath = getVolumePath(volume);
+ const backend = createVolumeBackend(volume);
+ let backupPath: string;
+ let dumpFilePath: string | null = null;
+ const isDatabase = backend.isDatabaseBackend();
+
+ if (isDatabase) {
+ logger.info(`Creating database dump for volume ${volume.name}`);
+
+ const timestamp = Date.now();
+ dumpFilePath = backend.getDumpFilePath(timestamp);
+
+ if (!dumpFilePath) {
+ throw new Error("Failed to get dump file path for database volume");
+ }
+
+ try {
+ await executeDatabaseDump(volume.config as DatabaseConfig, dumpFilePath);
+ logger.info(`Database dump created at: ${dumpFilePath}`);
+ } catch (error) {
+ logger.error(`Failed to create database dump: ${toMessage(error)}`);
+ throw error;
+ }
+
+ backupPath = dumpFilePath;
+ } else {
+ backupPath = backend.getVolumePath();
+ }
const backupOptions: {
exclude?: string[];
@@ -226,7 +254,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
backupOptions.include = schedule.includePatterns;
}
- await restic.backup(repository.config, volumePath, {
+ await restic.backup(repository.config, backupPath, {
...backupOptions,
onProgress: (progress) => {
serverEvents.emit("backup:progress", {
@@ -242,6 +270,16 @@ const executeBackup = async (scheduleId: number, manual = false) => {
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });
}
+ // Clean up dump file if it was created
+ if (dumpFilePath) {
+ try {
+ await fs.unlink(dumpFilePath);
+ logger.info(`Cleaned up dump file: ${dumpFilePath}`);
+ } catch (error) {
+ logger.warn(`Failed to clean up dump file: ${toMessage(error)}`);
+ }
+ }
+
const nextBackupAt = calculateNextRun(schedule.cronExpression);
await db
.update(backupSchedulesTable)
diff --git a/app/server/modules/driver/driver.controller.ts b/app/server/modules/driver/driver.controller.ts
index 6418d95f..eadd13ec 100644
--- a/app/server/modules/driver/driver.controller.ts
+++ b/app/server/modules/driver/driver.controller.ts
@@ -1,6 +1,7 @@
import { Hono } from "hono";
import { volumeService } from "../volumes/volume.service";
-import { getVolumePath } from "../volumes/helpers";
+import { createVolumeBackend } from "../backends/backend";
+import { VOLUME_MOUNT_BASE } from "../../core/constants";
export const driverController = new Hono()
.post("/VolumeDriver.Capabilities", (c) => {
@@ -30,10 +31,13 @@ export const driverController = new Hono()
return c.json({ Err: "Volume name is required" }, 400);
}
+ const volumeName = body.Name.replace(/^im-/, "");
+ const { volume } = await volumeService.getVolume(volumeName);
+ const backend = createVolumeBackend(volume);
const volumeName = body.Name.replace(/^zb-/, "");
return c.json({
- Mountpoint: getVolumePath(volumeName),
+ Mountpoint: backend.getVolumePath(),
});
})
.post("/VolumeDriver.Unmount", (c) => {
@@ -48,10 +52,12 @@ export const driverController = new Hono()
return c.json({ Err: "Volume name is required" }, 400);
}
+ const { volume } = await volumeService.getVolume(body.Name.replace(/^im-/, ""));
+ const backend = createVolumeBackend(volume);
const { volume } = await volumeService.getVolume(body.Name.replace(/^zb-/, ""));
return c.json({
- Mountpoint: getVolumePath(volume),
+ Mountpoint: backend.getVolumePath(),
});
})
.post("/VolumeDriver.Get", async (c) => {
@@ -61,6 +67,13 @@ export const driverController = new Hono()
return c.json({ Err: "Volume name is required" }, 400);
}
+ const { volume } = await volumeService.getVolume(body.Name.replace(/^im-/, ""));
+ const backend = createVolumeBackend(volume);
+
+ return c.json({
+ Volume: {
+ Name: `im-${volume.name}`,
+ Mountpoint: backend.getVolumePath(),
const { volume } = await volumeService.getVolume(body.Name.replace(/^zb-/, ""));
return c.json({
@@ -75,6 +88,14 @@ export const driverController = new Hono()
.post("/VolumeDriver.List", async (c) => {
const volumes = await volumeService.listVolumes();
+ const res = volumes.map((volume) => {
+ const backend = createVolumeBackend(volume);
+ return {
+ Name: `im-${volume.name}`,
+ Mountpoint: backend.getVolumePath(),
+ Status: {},
+ };
+ });
const res = volumes.map((volume) => ({
Name: `zb-${volume.name}`,
Mountpoint: getVolumePath(volume),
diff --git a/app/server/modules/volumes/helpers.ts b/app/server/modules/volumes/helpers.ts
deleted file mode 100644
index bb272651..00000000
--- a/app/server/modules/volumes/helpers.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { VOLUME_MOUNT_BASE } from "../../core/constants";
-import type { Volume } from "../../db/schema";
-
-export const getVolumePath = (volume: Volume) => {
- if (volume.config.backend === "directory") {
- return volume.config.path;
- }
-
- return `${VOLUME_MOUNT_BASE}/${volume.name}/_data`;
-};
diff --git a/app/server/modules/volumes/volume.controller.ts b/app/server/modules/volumes/volume.controller.ts
index 94c0818d..1882d16c 100644
--- a/app/server/modules/volumes/volume.controller.ts
+++ b/app/server/modules/volumes/volume.controller.ts
@@ -25,7 +25,7 @@ import {
type BrowseFilesystemDto,
} from "./volume.dto";
import { volumeService } from "./volume.service";
-import { getVolumePath } from "./helpers";
+import { createVolumeBackend } from "../backends/backend";
export const volumeController = new Hono()
.get("/", listVolumesDto, async (c) => {
@@ -37,9 +37,10 @@ export const volumeController = new Hono()
const body = c.req.valid("json");
const res = await volumeService.createVolume(body.name, body.config);
+ const backend = createVolumeBackend(res.volume);
const response = {
...res.volume,
- path: getVolumePath(res.volume),
+ path: backend.getVolumePath(),
};
return c.json(response, 201);
@@ -60,10 +61,11 @@ export const volumeController = new Hono()
const { name } = c.req.param();
const res = await volumeService.getVolume(name);
+ const backend = createVolumeBackend(res.volume);
const response = {
volume: {
...res.volume,
- path: getVolumePath(res.volume),
+ path: backend.getVolumePath(),
},
statfs: {
total: res.statfs.total ?? 0,
@@ -85,9 +87,10 @@ export const volumeController = new Hono()
const body = c.req.valid("json");
const res = await volumeService.updateVolume(name, body);
+ const backend = createVolumeBackend(res.volume);
const response = {
...res.volume,
- path: getVolumePath(res.volume),
+ path: backend.getVolumePath(),
};
return c.json(response, 200);
diff --git a/app/server/modules/volumes/volume.service.ts b/app/server/modules/volumes/volume.service.ts
index 269b40fc..aca35785 100644
--- a/app/server/modules/volumes/volume.service.ts
+++ b/app/server/modules/volumes/volume.service.ts
@@ -13,7 +13,6 @@ import { getStatFs, type StatFs } from "../../utils/mountinfo";
import { withTimeout } from "../../utils/timeout";
import { createVolumeBackend } from "../backends/backend";
import type { UpdateVolumeBody } from "./volume.dto";
-import { getVolumePath } from "./helpers";
import { logger } from "../../utils/logger";
import { serverEvents } from "../../core/events";
import type { BackendConfig } from "~/schemas/volumes";
@@ -129,7 +128,8 @@ const getVolume = async (name: string) => {
let statfs: Partial = {};
if (volume.status === "mounted") {
- statfs = await withTimeout(getStatFs(getVolumePath(volume)), 1000, "getStatFs").catch((error) => {
+ const backend = createVolumeBackend(volume);
+ statfs = await withTimeout(getStatFs(backend.getVolumePath()), 1000, "getStatFs").catch((error) => {
logger.warn(`Failed to get statfs for volume ${name}: ${toMessage(error)}`);
return {};
});
@@ -296,7 +296,8 @@ const listFiles = async (name: string, subPath?: string) => {
}
// For directory volumes, use the configured path directly
- const volumePath = getVolumePath(volume);
+ const backend = createVolumeBackend(volume);
+ const volumePath = backend.getVolumePath();
const requestedPath = subPath ? path.join(volumePath, subPath) : volumePath;
diff --git a/app/server/utils/database-dump.ts b/app/server/utils/database-dump.ts
new file mode 100644
index 00000000..0db91c8d
--- /dev/null
+++ b/app/server/utils/database-dump.ts
@@ -0,0 +1,282 @@
+import * as fs from "node:fs/promises";
+import * as path from "node:path";
+import { safeSpawn } from "./spawn";
+import { logger } from "./logger";
+import { toMessage } from "./errors";
+import type { BackendConfig } from "~/schemas/volumes";
+
+export type DatabaseConfig = Extract<
+ BackendConfig,
+ { backend: "mariadb" | "mysql" | "postgres" | "sqlite" }
+>;
+
+// MariaDB
+export const dumpMariaDB = async (config: DatabaseConfig, outputPath: string): Promise => {
+ if (config.backend !== "mariadb") {
+ throw new Error("Invalid backend type for MariaDB dump");
+ }
+
+ logger.info(`Starting MariaDB dump for database: ${config.database}`);
+
+ const args = [
+ `--host=${config.host}`,
+ `--port=${config.port}`,
+ `--user=${config.username}`,
+ `--skip-ssl`,
+ `--single-transaction`,
+ `--quick`,
+ `--lock-tables=false`,
+ ...(config.dumpOptions || []),
+ config.database,
+ ];
+
+ const env = {
+ ...process.env,
+ MYSQL_PWD: config.password,
+ };
+
+ try {
+ const result = await safeSpawn({ command: "mariadb-dump", args, env });
+ await fs.writeFile(outputPath, result.stdout);
+ logger.info(`MariaDB dump completed: ${outputPath}`);
+ } catch (error) {
+ logger.error(`MariaDB dump failed: ${toMessage(error)}`);
+ throw error;
+ }
+};
+
+export const testMariaDBConnection = async (config: DatabaseConfig): Promise => {
+ if (config.backend !== "mariadb") {
+ throw new Error("Invalid backend type for MariaDB connection test");
+ }
+
+ logger.debug(`Testing MariaDB connection to: ${config.host}:${config.port}`);
+
+ const args = [
+ `--host=${config.host}`,
+ `--port=${config.port}`,
+ `--user=${config.username}`,
+ `--database=${config.database}`,
+ "--skip-ssl",
+ "--execute=SELECT 1",
+ ];
+
+ const env = {
+ ...process.env,
+ MYSQL_PWD: config.password,
+ };
+
+ try {
+ await safeSpawn({ command: "mariadb", args, env, timeout: 10000 });
+ logger.debug("MariaDB connection test successful");
+ } catch (error) {
+ logger.error(`MariaDB connection test failed: ${toMessage(error)}`);
+ throw error;
+ }
+};
+
+// MySQL
+export const dumpMySQL = async (config: DatabaseConfig, outputPath: string): Promise => {
+ if (config.backend !== "mysql") {
+ throw new Error("Invalid backend type for MySQL dump");
+ }
+
+ logger.info(`Starting MySQL dump for database: ${config.database}`);
+
+ const args = [
+ `--host=${config.host}`,
+ `--port=${config.port}`,
+ `--user=${config.username}`,
+ `--skip-ssl`,
+ `--single-transaction`,
+ `--quick`,
+ `--lock-tables=false`,
+ ...(config.dumpOptions || []),
+ config.database,
+ ];
+
+ const env = {
+ ...process.env,
+ MYSQL_PWD: config.password,
+ };
+
+ try {
+ const result = await safeSpawn({ command: "mysqldump", args, env });
+ await fs.writeFile(outputPath, result.stdout);
+ logger.info(`MySQL dump completed: ${outputPath}`);
+ } catch (error) {
+ logger.error(`MySQL dump failed: ${toMessage(error)}`);
+ throw error;
+ }
+};
+
+export const testMySQLConnection = async (config: DatabaseConfig): Promise => {
+ if (config.backend !== "mysql") {
+ throw new Error("Invalid backend type for MySQL connection test");
+ }
+
+ logger.debug(`Testing MySQL connection to: ${config.host}:${config.port}`);
+
+ const args = [
+ `--host=${config.host}`,
+ `--port=${config.port}`,
+ `--user=${config.username}`,
+ `--database=${config.database}`,
+ "--skip-ssl",
+ "--execute=SELECT 1",
+ ];
+
+ const env = {
+ ...process.env,
+ MYSQL_PWD: config.password,
+ };
+
+ try {
+ await safeSpawn({ command: "mysql", args, env, timeout: 10000 });
+ logger.debug("MySQL connection test successful");
+ } catch (error) {
+ logger.error(`MySQL connection test failed: ${toMessage(error)}`);
+ throw error;
+ }
+};
+
+// PostgreSQL
+export const dumpPostgres = async (config: DatabaseConfig, outputPath: string): Promise => {
+ if (config.backend !== "postgres") {
+ throw new Error("Invalid backend type for PostgreSQL dump");
+ }
+
+ logger.info(`Starting PostgreSQL dump for database: ${config.database}`);
+
+ const args = [
+ `--host=${config.host}`,
+ `--port=${config.port}`,
+ `--username=${config.username}`,
+ `--dbname=${config.database}`,
+ `--format=${config.dumpFormat}`,
+ `--file=${outputPath}`,
+ "--no-password",
+ ...(config.dumpOptions || []),
+ ];
+
+ const env = {
+ ...process.env,
+ PGPASSWORD: config.password,
+ PGSSLMODE: "disable",
+ };
+
+ try {
+ await safeSpawn({ command: "pg_dump", args, env });
+ logger.info(`PostgreSQL dump completed: ${outputPath}`);
+ } catch (error) {
+ logger.error(`PostgreSQL dump failed: ${toMessage(error)}`);
+ throw error;
+ }
+};
+
+export const testPostgresConnection = async (config: DatabaseConfig): Promise => {
+ if (config.backend !== "postgres") {
+ throw new Error("Invalid backend type for PostgreSQL connection test");
+ }
+
+ logger.debug(`Testing PostgreSQL connection to: ${config.host}:${config.port}`);
+
+ const args = [
+ `--host=${config.host}`,
+ `--port=${config.port}`,
+ `--username=${config.username}`,
+ `--dbname=${config.database}`,
+ "--command=SELECT 1",
+ "--no-password",
+ ];
+
+ const env = {
+ ...process.env,
+ PGPASSWORD: config.password,
+ PGSSLMODE: "disable",
+ };
+
+ try {
+ await safeSpawn({ command: "psql", args, env, timeout: 10000 });
+ logger.debug("PostgreSQL connection test successful");
+ } catch (error) {
+ logger.error(`PostgreSQL connection test failed: ${toMessage(error)}`);
+ throw error;
+ }
+};
+
+// SQLite
+export const dumpSQLite = async (config: DatabaseConfig, outputPath: string): Promise => {
+ if (config.backend !== "sqlite") {
+ throw new Error("Invalid backend type for SQLite dump");
+ }
+
+ logger.info(`Starting SQLite dump for database: ${config.path}`);
+
+ try {
+ await fs.access(config.path);
+
+ const result = await safeSpawn({ command: "sqlite3", args: [config.path, ".dump"] });
+ await fs.writeFile(outputPath, result.stdout);
+ logger.info(`SQLite dump completed: ${outputPath}`);
+ } catch (error) {
+ logger.error(`SQLite dump failed: ${toMessage(error)}`);
+ throw error;
+ }
+};
+
+export const testSQLiteConnection = async (config: DatabaseConfig): Promise => {
+ if (config.backend !== "sqlite") {
+ throw new Error("Invalid backend type for SQLite connection test");
+ }
+
+ logger.debug(`Testing SQLite connection to: ${config.path}`);
+
+ try {
+ await fs.access(config.path, fs.constants.R_OK);
+ const result = await safeSpawn({ command: "sqlite3", args: [config.path, "SELECT 1"] });
+
+ if (!result.stdout.includes("1")) {
+ throw new Error("SQLite database query failed");
+ }
+
+ logger.debug("SQLite connection test successful");
+ } catch (error) {
+ logger.error(`SQLite connection test failed: ${toMessage(error)}`);
+ throw error;
+ }
+};
+
+// Utils
+export const executeDatabaseDump = async (config: DatabaseConfig, outputPath: string): Promise => {
+ const outputDir = path.dirname(outputPath);
+ await fs.mkdir(outputDir, { recursive: true });
+
+ switch (config.backend) {
+ case "mariadb":
+ return dumpMariaDB(config, outputPath);
+ case "mysql":
+ return dumpMySQL(config, outputPath);
+ case "postgres":
+ return dumpPostgres(config, outputPath);
+ case "sqlite":
+ return dumpSQLite(config, outputPath);
+ default:
+ throw new Error(`Unsupported database backend: ${(config as any).backend}`);
+ }
+};
+
+export const testDatabaseConnection = async (config: DatabaseConfig): Promise => {
+ switch (config.backend) {
+ case "mariadb":
+ return testMariaDBConnection(config);
+ case "mysql":
+ return testMySQLConnection(config);
+ case "postgres":
+ return testPostgresConnection(config);
+ case "sqlite":
+ return testSQLiteConnection(config);
+ default:
+ throw new Error(`Unsupported database backend: ${(config as any).backend}`);
+ }
+};
\ No newline at end of file
diff --git a/app/server/utils/spawn.ts b/app/server/utils/spawn.ts
index fd465aa1..469f7fdf 100644
--- a/app/server/utils/spawn.ts
+++ b/app/server/utils/spawn.ts
@@ -5,6 +5,8 @@ interface Params {
args: string[];
env?: NodeJS.ProcessEnv;
signal?: AbortSignal;
+ stdin?: string;
+ timeout?: number;
onStdout?: (data: string) => void;
onStderr?: (error: string) => void;
onError?: (error: Error) => Promise | void;
@@ -19,17 +21,26 @@ type SpawnResult = {
};
export const safeSpawn = (params: Params) => {
- const { command, args, env = {}, signal, ...callbacks } = params;
+ const { command, args, env = {}, signal, stdin, timeout, ...callbacks } = params;
- return new Promise((resolve) => {
+ return new Promise((resolve, reject) => {
let stdoutData = "";
let stderrData = "";
+ let timeoutId: NodeJS.Timeout | undefined;
const child = spawn(command, args, {
env: { ...process.env, ...env },
signal: signal,
});
+ // Handle timeout if specified
+ if (timeout) {
+ timeoutId = setTimeout(() => {
+ child.kill("SIGTERM");
+ reject(new Error(`Command timed out after ${timeout}ms`));
+ }, timeout);
+ }
+
child.stdout.on("data", (data) => {
if (callbacks.onStdout) {
callbacks.onStdout(data.toString());
@@ -47,6 +58,7 @@ export const safeSpawn = (params: Params) => {
});
child.on("error", async (error) => {
+ if (timeoutId) clearTimeout(timeoutId);
if (callbacks.onError) {
await callbacks.onError(error);
}
@@ -62,6 +74,7 @@ export const safeSpawn = (params: Params) => {
});
child.on("close", async (code) => {
+ if (timeoutId) clearTimeout(timeoutId);
if (callbacks.onClose) {
await callbacks.onClose(code);
}
@@ -69,11 +82,15 @@ export const safeSpawn = (params: Params) => {
await callbacks.finally();
}
- resolve({
- exitCode: code === null ? -1 : code,
- stdout: stdoutData,
- stderr: stderrData,
- });
+ if (code !== 0 && code !== null) {
+ reject(new Error(`Command failed with exit code ${code}: ${stderrData || stdoutData}`));
+ } else {
+ resolve({
+ exitCode: code === null ? -1 : code,
+ stdout: stdoutData,
+ stderr: stderrData,
+ });
+ }
});
});
};