diff --git a/Dockerfile b/Dockerfile index 404599f..10a9c27 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,8 +6,7 @@ RUN apk add --no-cache \ davfs2=1.6.1-r2 \ mariadb-client \ mysql-client \ - postgresql-client \ - sqlite + postgresql-client # ------------------------------ # DEPENDENCIES diff --git a/app/client/components/volume-icon.tsx b/app/client/components/volume-icon.tsx index b68d32e..3fe34bb 100644 --- a/app/client/components/volume-icon.tsx +++ b/app/client/components/volume-icon.tsx @@ -50,12 +50,6 @@ const getIconAndColor = (backend: BackendType) => { 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 9f74a43..338bc8d 100644 --- a/app/client/modules/volumes/routes/volume-details.tsx +++ b/app/client/modules/volumes/routes/volume-details.tsx @@ -118,7 +118,8 @@ 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); + + const isDatabaseVolume = ["mariadb", "mysql", "postgres"].includes(volume.config.backend); return ( <> @@ -153,7 +154,9 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) { setSearchParams({ tab: value })} className="mt-4"> Configuration - {!isDatabaseVolume && Files} + + Files + diff --git a/app/client/modules/volumes/routes/volumes.tsx b/app/client/modules/volumes/routes/volumes.tsx index 131c7a5..4e85205 100644 --- a/app/client/modules/volumes/routes/volumes.tsx +++ b/app/client/modules/volumes/routes/volumes.tsx @@ -113,7 +113,6 @@ export default function Volumes({ loaderData }: Route.ComponentProps) { MariaDB MySQL PostgreSQL - SQLite {(searchQuery || statusFilter || backendFilter) && ( diff --git a/app/schemas/volumes.ts b/app/schemas/volumes.ts index e76b722..72593ea 100644 --- a/app/schemas/volumes.ts +++ b/app/schemas/volumes.ts @@ -58,6 +58,7 @@ export const mariadbConfigSchema = type({ password: "string", database: "string", dumpOptions: "string[]?", + readOnly: "false?", }); export const mysqlConfigSchema = type({ @@ -68,6 +69,7 @@ export const mysqlConfigSchema = type({ password: "string", database: "string", dumpOptions: "string[]?", + readOnly: "false?", }); export const postgresConfigSchema = type({ @@ -79,6 +81,7 @@ export const postgresConfigSchema = type({ database: "string", dumpFormat: type("'plain' | 'custom' | 'directory'").default("custom"), dumpOptions: "string[]?", + readOnly: "false?", }); export const volumeConfigSchema = nfsConfigSchema diff --git a/app/server/jobs/cleanup-dangling.ts b/app/server/jobs/cleanup-dangling.ts index e8f4ece..22a082c 100644 --- a/app/server/jobs/cleanup-dangling.ts +++ b/app/server/jobs/cleanup-dangling.ts @@ -20,6 +20,7 @@ export class CleanupDanglingMountsJob extends Job { const backend = createVolumeBackend(v); return backend.getVolumePath() === mount.mountPoint; }); + if (!matchingVolume) { logger.info(`Found dangling mount at ${mount.mountPoint}, attempting to unmount...`); await executeUnmount(mount.mountPoint).catch((err) => { diff --git a/app/server/modules/backends/backend.ts b/app/server/modules/backends/backend.ts index 25267b9..9aa0599 100644 --- a/app/server/modules/backends/backend.ts +++ b/app/server/modules/backends/backend.ts @@ -1,6 +1,5 @@ import type { BackendStatus } from "~/schemas/volumes"; import type { Volume } from "../../db/schema"; -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"; @@ -19,37 +18,31 @@ export type VolumeBackend = { unmount: () => Promise; checkHealth: () => Promise; getVolumePath: () => string; - isDatabaseBackend: () => boolean; - getDumpPath: () => string | null; - getDumpFilePath: (timestamp: number) => string | null; + getBackupPath: () => Promise; }; export const createVolumeBackend = (volume: Volume): VolumeBackend => { - 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, volume.name, path); + return makeNfsBackend(volume.config, volume.name); } case "smb": { - return makeSmbBackend(volume.config, volume.name, path); + return makeSmbBackend(volume.config, volume.name); } case "directory": { - return makeDirectoryBackend(volume.config, volume.name, path); + return makeDirectoryBackend(volume.config, volume.name); } case "webdav": { - return makeWebdavBackend(volume.config, volume.name, path); + return makeWebdavBackend(volume.config, volume.name); } case "mariadb": { - return makeMariaDBBackend(volume.config, volume.name, path); + return makeMariaDBBackend(volume.config); } case "mysql": { - return makeMySQLBackend(volume.config, volume.name, path); + return makeMySQLBackend(volume.config); } case "postgres": { - return makePostgresBackend(volume.config, volume.name, path); + return makePostgresBackend(volume.config); } 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 db71526..890e5fd 100644 --- a/app/server/modules/backends/directory/directory-backend.ts +++ b/app/server/modules/backends/directory/directory-backend.ts @@ -52,12 +52,18 @@ const checkHealth = async (config: BackendConfig) => { } }; -export const makeDirectoryBackend = (config: BackendConfig, volumeName: string, volumePath: string): VolumeBackend => ({ +const getVolumePath = (config: BackendConfig): string => { + if (config.backend !== "directory") { + throw new Error("Invalid backend type"); + } + + return config.path; +}; + +export const makeDirectoryBackend = (config: BackendConfig, volumePath: string): VolumeBackend => ({ mount: () => mount(config, volumePath), unmount, checkHealth: () => checkHealth(config), - getVolumePath: () => config.backend === "directory" ? config.path : volumePath, - isDatabaseBackend: () => false, - getDumpPath: () => null, - getDumpFilePath: () => null, + getVolumePath: () => getVolumePath(config), + getBackupPath: async () => getVolumePath(config), }); diff --git a/app/server/modules/backends/mariadb/mariadb-backend.ts b/app/server/modules/backends/mariadb/mariadb-backend.ts index ef26699..e148642 100644 --- a/app/server/modules/backends/mariadb/mariadb-backend.ts +++ b/app/server/modules/backends/mariadb/mariadb-backend.ts @@ -1,41 +1,9 @@ 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 }; - } -}; +import { $ } from "bun"; const checkHealth = async (config: BackendConfig) => { if (config.backend !== "mariadb") { @@ -43,7 +11,23 @@ const checkHealth = async (config: BackendConfig) => { } try { - await testMariaDBConnection(config); + 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 = { + MYSQL_PWD: config.password, + }; + + await $`mariadb ${args.join(" ")}`.env(env); + return { status: BACKEND_STATUS.mounted }; } catch (error) { logger.error("MariaDB health check failed:", error); @@ -51,15 +35,47 @@ const checkHealth = async (config: BackendConfig) => { } }; -export const makeMariaDBBackend = (config: BackendConfig, volumeName: string, volumePath: string): VolumeBackend => ({ - mount: () => mount(config, volumePath), - unmount: () => unmount(volumePath), +const getBackupPath = async (config: BackendConfig) => { + const dumpDir = await fs.mkdtemp(`/tmp/ironmount-mariadb-`); + + 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 = { + MYSQL_PWD: config.password, + }; + + const result = await $`mariadb-dump ${args}`.env(env).nothrow(); + + if (result.exitCode !== 0) { + throw new Error(`mariadb-dump failed with exit code ${result.exitCode}: ${result.stderr}`); + } + + await fs.writeFile(`${dumpDir}/dump.sql`, result.stdout); + logger.info(`MariaDB dump completed: ${dumpDir}/dump.sql`); + + return `${dumpDir}/dump.sql`; +}; + +export const makeMariaDBBackend = (config: BackendConfig): VolumeBackend => ({ + mount: () => Promise.resolve({ status: BACKEND_STATUS.mounted }), + unmount: () => Promise.resolve({ status: BACKEND_STATUS.unmounted }), 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 + getVolumePath: () => "/tmp", + getBackupPath: () => getBackupPath(config), +}); diff --git a/app/server/modules/backends/mysql/mysql-backend.ts b/app/server/modules/backends/mysql/mysql-backend.ts index 724ac62..4a2edaf 100644 --- a/app/server/modules/backends/mysql/mysql-backend.ts +++ b/app/server/modules/backends/mysql/mysql-backend.ts @@ -1,49 +1,31 @@ -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 }; - } -}; +import { $ } from "bun"; const checkHealth = async (config: BackendConfig) => { if (config.backend !== "mysql") { return { status: BACKEND_STATUS.error, error: "Invalid backend type" }; } + logger.debug(`Testing MySQL connection to: ${config.host}:${config.port}`); try { - await testMySQLConnection(config); + 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, + }; + + await $`mysql ${args.join(" ")}`.env(env); return { status: BACKEND_STATUS.mounted }; } catch (error) { logger.error("MySQL health check failed:", error); @@ -51,15 +33,44 @@ const checkHealth = async (config: BackendConfig) => { } }; -export const makeMySQLBackend = (config: BackendConfig, volumeName: string, volumePath: string): VolumeBackend => ({ - mount: () => mount(config, volumePath), - unmount: () => unmount(volumePath), +const getBackupPath = async (config: BackendConfig) => { + if (config.backend !== "mysql") { + throw new Error("Invalid backend type"); + } + + 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 = { + MYSQL_PWD: config.password, + }; + + const result = await $`mysql ${args}`.env(env).nothrow(); + + if (result.exitCode !== 0) { + throw new Error(`MySQL dump failed: ${result.stderr}`); + } + + console.log(result.stdout); + + return "Nothing for now"; +}; + +export const makeMySQLBackend = (config: BackendConfig): VolumeBackend => ({ + mount: () => Promise.resolve({ status: BACKEND_STATUS.mounted }), + unmount: () => Promise.resolve({ status: BACKEND_STATUS.unmounted }), 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 + getVolumePath: () => "/tmp", + getBackupPath: () => getBackupPath(config), +}); diff --git a/app/server/modules/backends/nfs/nfs-backend.ts b/app/server/modules/backends/nfs/nfs-backend.ts index 8f8a60e..e7b6871 100644 --- a/app/server/modules/backends/nfs/nfs-backend.ts +++ b/app/server/modules/backends/nfs/nfs-backend.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; -import { OPERATION_TIMEOUT } from "../../../core/constants"; +import { OPERATION_TIMEOUT, VOLUME_MOUNT_BASE } from "../../../core/constants"; import { toMessage } from "../../../utils/errors"; import { logger } from "../../../utils/logger"; import { getMountForPath } from "../../../utils/mountinfo"; @@ -9,7 +9,8 @@ import type { VolumeBackend } from "../backend"; import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils"; import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes"; -const mount = async (config: BackendConfig, path: string) => { +const mount = async (config: BackendConfig, name: string) => { + const path = getVolumePath(name); logger.debug(`Mounting volume ${path}...`); if (config.backend !== "nfs") { @@ -22,13 +23,13 @@ const mount = async (config: BackendConfig, path: string) => { return { status: BACKEND_STATUS.error, error: "NFS mounting is only supported on Linux hosts." }; } - const { status } = await checkHealth(path, config.readOnly ?? false); + const { status } = await checkHealth(name, config.readOnly ?? false); if (status === "mounted") { return { status: BACKEND_STATUS.mounted }; } logger.debug(`Trying to unmount any existing mounts at ${path} before mounting...`); - await unmount(path); + await unmount(name); const run = async () => { await fs.mkdir(path, { recursive: true }); @@ -57,7 +58,9 @@ const mount = async (config: BackendConfig, path: string) => { } }; -const unmount = async (path: string) => { +const unmount = async (name: string) => { + const path = getVolumePath(name); + if (os.platform() !== "linux") { logger.error("NFS unmounting is only supported on Linux hosts."); return { status: BACKEND_STATUS.error, error: "NFS unmounting is only supported on Linux hosts." }; @@ -87,7 +90,9 @@ const unmount = async (path: string) => { } }; -const checkHealth = async (path: string, readOnly: boolean) => { +const checkHealth = async (name: string, readOnly: boolean) => { + const path = getVolumePath(name); + const run = async () => { logger.debug(`Checking health of NFS volume at ${path}...`); await fs.access(path); @@ -114,12 +119,14 @@ const checkHealth = async (path: string, readOnly: boolean) => { } }; -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, +const getVolumePath = (name: string) => { + return `${VOLUME_MOUNT_BASE}/${name}/_data`; +}; + +export const makeNfsBackend = (config: BackendConfig, name: string): VolumeBackend => ({ + mount: () => mount(config, name), + unmount: () => unmount(name), + checkHealth: () => checkHealth(name, config.readOnly ?? false), + getVolumePath: () => getVolumePath(name), + getBackupPath: async () => getVolumePath(name), }); diff --git a/app/server/modules/backends/postgres/postgres-backend.ts b/app/server/modules/backends/postgres/postgres-backend.ts index 8e1e19d..c176b3d 100644 --- a/app/server/modules/backends/postgres/postgres-backend.ts +++ b/app/server/modules/backends/postgres/postgres-backend.ts @@ -1,69 +1,80 @@ 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 }; - } -}; +import { $ } from "bun"; 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) }; + 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 = { + PGPASSWORD: config.password, + PGSSLMODE: "disable", + }; + + logger.debug(`Running psql with args: ${args.join(" ")}`); + const res = await $`psql ${args}`.env(env).nothrow(); + + if (res.exitCode !== 0) { + return { status: BACKEND_STATUS.error, error: res.stderr.toString() }; + } + + return { status: BACKEND_STATUS.mounted }; }; -export const makePostgresBackend = (config: BackendConfig, volumeName: string, volumePath: string): VolumeBackend => ({ - mount: () => mount(config, volumePath), - unmount: () => unmount(volumePath), +const getBackupPath = async (config: BackendConfig) => { + if (config.backend !== "postgres") { + throw new Error("Invalid backend type for PostgreSQL dump"); + } + + const dumpDir = await fs.mkdtemp(`/tmp/ironmount-postgres-`); + const outputPath = `${dumpDir}/${config.dumpFormat === "plain" ? "dump.sql" : "dump.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 = { + PGPASSWORD: config.password, + PGSSLMODE: "disable", + }; + + await $`pg_dump ${args}`.env(env); + + return outputPath; +}; + +export const makePostgresBackend = (config: BackendConfig): VolumeBackend => ({ + mount: () => Promise.resolve({ status: "mounted" }), + unmount: () => Promise.resolve({ status: "unmounted" }), 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 + getVolumePath: () => "/tmp", + getBackupPath: () => getBackupPath(config), +}); diff --git a/app/server/modules/backends/smb/smb-backend.ts b/app/server/modules/backends/smb/smb-backend.ts index 68f84d4..c54db9e 100644 --- a/app/server/modules/backends/smb/smb-backend.ts +++ b/app/server/modules/backends/smb/smb-backend.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; -import { OPERATION_TIMEOUT } from "../../../core/constants"; +import { OPERATION_TIMEOUT, VOLUME_MOUNT_BASE } from "../../../core/constants"; import { toMessage } from "../../../utils/errors"; import { logger } from "../../../utils/logger"; import { getMountForPath } from "../../../utils/mountinfo"; @@ -9,7 +9,8 @@ import type { VolumeBackend } from "../backend"; import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils"; import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes"; -const mount = async (config: BackendConfig, path: string) => { +const mount = async (config: BackendConfig, name: string) => { + const path = getVolumePath(name); logger.debug(`Mounting SMB volume ${path}...`); if (config.backend !== "smb") { @@ -22,13 +23,13 @@ const mount = async (config: BackendConfig, path: string) => { return { status: BACKEND_STATUS.error, error: "SMB mounting is only supported on Linux hosts." }; } - const { status } = await checkHealth(path, config.readOnly ?? false); + const { status } = await checkHealth(name, config.readOnly ?? false); if (status === "mounted") { return { status: BACKEND_STATUS.mounted }; } logger.debug(`Trying to unmount any existing mounts at ${path} before mounting...`); - await unmount(path); + await unmount(name); const run = async () => { await fs.mkdir(path, { recursive: true }); @@ -70,7 +71,9 @@ const mount = async (config: BackendConfig, path: string) => { } }; -const unmount = async (path: string) => { +const unmount = async (name: string) => { + const path = getVolumePath(name); + if (os.platform() !== "linux") { logger.error("SMB unmounting is only supported on Linux hosts."); return { status: BACKEND_STATUS.error, error: "SMB unmounting is only supported on Linux hosts." }; @@ -100,7 +103,9 @@ const unmount = async (path: string) => { } }; -const checkHealth = async (path: string, readOnly: boolean) => { +const checkHealth = async (name: string, readOnly: boolean) => { + const path = getVolumePath(name); + const run = async () => { logger.debug(`Checking health of SMB volume at ${path}...`); await fs.access(path); @@ -127,12 +132,14 @@ const checkHealth = async (path: string, readOnly: boolean) => { } }; -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, +const getVolumePath = (name: string) => { + return `${VOLUME_MOUNT_BASE}/${name}/_data`; +}; + +export const makeSmbBackend = (config: BackendConfig, name: string): VolumeBackend => ({ + mount: () => mount(config, name), + unmount: () => unmount(name), + checkHealth: () => checkHealth(name, config.readOnly ?? false), + getVolumePath: () => getVolumePath(name), + getBackupPath: async () => getVolumePath(name), }); diff --git a/app/server/modules/backends/webdav/webdav-backend.ts b/app/server/modules/backends/webdav/webdav-backend.ts index 0f42cba..c0dc12b 100644 --- a/app/server/modules/backends/webdav/webdav-backend.ts +++ b/app/server/modules/backends/webdav/webdav-backend.ts @@ -2,7 +2,7 @@ import { execFile as execFileCb } from "node:child_process"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import { promisify } from "node:util"; -import { OPERATION_TIMEOUT } from "../../../core/constants"; +import { OPERATION_TIMEOUT, VOLUME_MOUNT_BASE } from "../../../core/constants"; import { toMessage } from "../../../utils/errors"; import { logger } from "../../../utils/logger"; import { getMountForPath } from "../../../utils/mountinfo"; @@ -13,7 +13,8 @@ import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes"; const execFile = promisify(execFileCb); -const mount = async (config: BackendConfig, path: string) => { +const mount = async (config: BackendConfig, name: string) => { + const path = getVolumePath(name); logger.debug(`Mounting WebDAV volume ${path}...`); if (config.backend !== "webdav") { @@ -104,7 +105,8 @@ const mount = async (config: BackendConfig, path: string) => { } }; -const unmount = async (path: string) => { +const unmount = async (name: string) => { + const path = getVolumePath(name); if (os.platform() !== "linux") { logger.error("WebDAV unmounting is only supported on Linux hosts."); return { status: BACKEND_STATUS.error, error: "WebDAV unmounting is only supported on Linux hosts." }; @@ -134,7 +136,9 @@ const unmount = async (path: string) => { } }; -const checkHealth = async (path: string, readOnly: boolean) => { +const checkHealth = async (name: string, readOnly: boolean) => { + const path = getVolumePath(name); + const run = async () => { logger.debug(`Checking health of WebDAV volume at ${path}...`); await fs.access(path); @@ -161,12 +165,14 @@ const checkHealth = async (path: string, readOnly: boolean) => { } }; -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, +const getVolumePath = (name: string) => { + return `${VOLUME_MOUNT_BASE}/${name}/_data`; +}; + +export const makeWebdavBackend = (config: BackendConfig, name: string): VolumeBackend => ({ + mount: () => mount(config, name), + unmount: () => unmount(name), + checkHealth: () => checkHealth(name, config.readOnly ?? false), + getVolumePath: () => getVolumePath(name), + getBackupPath: async () => getVolumePath(name), }); diff --git a/app/server/modules/backups/backups.service.ts b/app/server/modules/backups/backups.service.ts index 1515d34..f37de13 100644 --- a/app/server/modules/backups/backups.service.ts +++ b/app/server/modules/backups/backups.service.ts @@ -2,7 +2,6 @@ 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"; @@ -11,7 +10,6 @@ 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(); @@ -209,32 +207,7 @@ const executeBackup = async (scheduleId: number, manual = false) => { try { 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 backupPath = await backend.getBackupPath(); const backupOptions: { exclude?: string[]; @@ -270,16 +243,6 @@ 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 0b40b74..441818b 100644 --- a/app/server/modules/driver/driver.controller.ts +++ b/app/server/modules/driver/driver.controller.ts @@ -1,7 +1,6 @@ import { Hono } from "hono"; import { volumeService } from "../volumes/volume.service"; import { createVolumeBackend } from "../backends/backend"; -import { VOLUME_MOUNT_BASE } from "../../core/constants"; export const driverController = new Hono() .post("/VolumeDriver.Capabilities", (c) => { @@ -80,14 +79,16 @@ export const driverController = new Hono() .post("/VolumeDriver.List", async (c) => { const volumes = await volumeService.listVolumes(); - const res = volumes.map((volume) => { + let res = []; + for (const volume of volumes) { const backend = createVolumeBackend(volume); - return { + + res.push({ Name: `im-${volume.name}`, Mountpoint: backend.getVolumePath(), Status: {}, - }; - }); + }); + } return c.json({ Volumes: res, diff --git a/app/server/modules/volumes/volume.service.ts b/app/server/modules/volumes/volume.service.ts index 2d9efb5..808c586 100644 --- a/app/server/modules/volumes/volume.service.ts +++ b/app/server/modules/volumes/volume.service.ts @@ -129,7 +129,8 @@ const getVolume = async (name: string) => { let statfs: Partial = {}; if (volume.status === "mounted") { const backend = createVolumeBackend(volume); - statfs = await withTimeout(getStatFs(backend.getVolumePath()), 1000, "getStatFs").catch((error) => { + const volumePath = backend.getVolumePath(); + statfs = await withTimeout(getStatFs(volumePath), 1000, "getStatFs").catch((error) => { logger.warn(`Failed to get statfs for volume ${name}: ${toMessage(error)}`); return {}; }); @@ -203,7 +204,16 @@ const testConnection = async (backendConfig: BackendConfig) => { }; const backend = createVolumeBackend(mockVolume); - const { error } = await backend.mount(); + let error: string | null = null; + const mount = await backend.mount(); + if (mount.error) { + error = mount.error; + } else { + const health = await backend.checkHealth(); + if (health.error) { + error = health.error; + } + } await backend.unmount(); @@ -295,7 +305,6 @@ const listFiles = async (name: string, subPath?: string) => { throw new InternalServerError("Volume is not mounted"); } - // For directory volumes, use the configured path directly const backend = createVolumeBackend(volume); const volumePath = backend.getVolumePath(); diff --git a/app/server/utils/database-dump.ts b/app/server/utils/database-dump.ts deleted file mode 100644 index 0db91c8..0000000 --- a/app/server/utils/database-dump.ts +++ /dev/null @@ -1,282 +0,0 @@ -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/sanitize.ts b/app/server/utils/sanitize.ts index 698d716..d72e2b7 100644 --- a/app/server/utils/sanitize.ts +++ b/app/server/utils/sanitize.ts @@ -3,6 +3,10 @@ * This removes passwords and credentials from logs and error messages */ export const sanitizeSensitiveData = (text: string): string => { + if (process.env.NODE_ENV === "development") { + return text; + } + let sanitized = text.replace(/\b(pass|password)=([^\s,]+)/gi, "$1=***"); sanitized = sanitized.replace(/\/\/([^:@\s]+):([^@\s]+)@/g, "//$1:***@");