feat: smb support

This commit is contained in:
Nicolas Meienberger
2025-09-25 21:35:08 +02:00
parent 9b57dd8a1c
commit 323312ec7b
8 changed files with 339 additions and 39 deletions

View File

@@ -71,7 +71,7 @@ const socketPath = "/run/docker/plugins/ironmount.sock";
fetch: app.fetch,
});
await startup();
startup();
logger.info(`Server is running at http://localhost:8080 and unix socket at ${socketPath}`);
})();

View File

@@ -1,8 +1,9 @@
import type { BackendStatus } from "@ironmount/schemas";
import { VOLUME_MOUNT_BASE } from "../../core/constants";
import type { Volume } from "../../db/schema";
import { makeDirectoryBackend } from "./directory/directory-backend";
import { makeNfsBackend } from "./nfs/nfs-backend";
import { VOLUME_MOUNT_BASE } from "../../core/constants";
import { makeSmbBackend } from "./smb/smb-backend";
type OperationResult = {
error?: string;
@@ -22,11 +23,11 @@ export const createVolumeBackend = (volume: Volume): VolumeBackend => {
case "nfs": {
return makeNfsBackend(volume.config, path);
}
case "smb": {
return makeSmbBackend(volume.config, path);
}
case "directory": {
return makeDirectoryBackend(volume.config, path);
}
default: {
throw new Error(`Backend ${volume.config.backend} not implemented`);
}
}
};

View File

@@ -1,17 +1,13 @@
import { execFile as execFileCb } from "node:child_process";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as npath from "node:path";
import { BACKEND_STATUS, type BackendConfig } from "@ironmount/schemas";
import type { VolumeBackend } from "../backend";
import { logger } from "../../../utils/logger";
import { promisify } from "node:util";
import { withTimeout } from "../../../utils/timeout";
import { OPERATION_TIMEOUT } from "../../../core/constants";
import { toMessage } from "../../../utils/errors";
import { getMountForPath } from "../../../utils/mountinfo";
const execFile = promisify(execFileCb);
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
const mount = async (config: BackendConfig, path: string) => {
logger.debug(`Mounting volume ${path}...`);
@@ -44,14 +40,7 @@ const mount = async (config: BackendConfig, path: string) => {
logger.debug(`Mounting volume ${path}...`);
logger.info(`Executing mount: mount ${args.join(" ")}`);
const { stderr } = await execFile("mount", args, {
timeout: OPERATION_TIMEOUT,
maxBuffer: 1024 * 1024,
});
if (stderr?.trim()) {
logger.warn(stderr.trim());
}
await executeMount(args);
logger.info(`NFS volume at ${path} mounted successfully.`);
return { status: BACKEND_STATUS.mounted };
@@ -59,11 +48,9 @@ const mount = async (config: BackendConfig, path: string) => {
try {
return await withTimeout(run(), OPERATION_TIMEOUT, "NFS mount");
} catch (err: any) {
const msg = err.stderr?.toString().trim() || err.message;
logger.error("Error mounting NFS volume", { error: msg });
return { status: BACKEND_STATUS.error, error: msg };
} catch (err) {
logger.error("Error mounting NFS volume", { error: toMessage(err) });
return { status: BACKEND_STATUS.error, error: toMessage(err) };
}
};
@@ -81,14 +68,7 @@ const unmount = async (path: string) => {
return { status: BACKEND_STATUS.unmounted };
}
const { stderr } = await execFile("umount", ["-l", "-f", path], {
timeout: OPERATION_TIMEOUT,
maxBuffer: 1024 * 1024,
});
if (stderr?.trim()) {
logger.warn(stderr.trim());
}
await executeUnmount(path);
await fs.rmdir(path);
@@ -101,7 +81,6 @@ const unmount = async (path: string) => {
} catch (err: any) {
const msg = err.stderr?.toString().trim() || err.message;
logger.error("Error unmounting NFS volume", { path, error: msg });
return { status: BACKEND_STATUS.error, error: msg };
}
};
@@ -117,10 +96,7 @@ const checkHealth = async (path: string) => {
throw new Error(`Path ${path} is not mounted as NFS.`);
}
const testFilePath = npath.join(path, `.healthcheck-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
await fs.writeFile(testFilePath, "healthcheck");
await fs.unlink(testFilePath);
await createTestFile(path);
logger.debug(`NFS volume at ${path} is healthy and mounted.`);
return { status: BACKEND_STATUS.mounted };

View File

@@ -0,0 +1,128 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import { BACKEND_STATUS, type BackendConfig } from "@ironmount/schemas";
import type { VolumeBackend } from "../backend";
import { logger } from "../../../utils/logger";
import { withTimeout } from "../../../utils/timeout";
import { OPERATION_TIMEOUT } from "../../../core/constants";
import { toMessage } from "../../../utils/errors";
import { getMountForPath } from "../../../utils/mountinfo";
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
const mount = async (config: BackendConfig, path: string) => {
logger.debug(`Mounting SMB volume ${path}...`);
if (config.backend !== "smb") {
logger.error("Provided config is not for SMB backend");
return { status: BACKEND_STATUS.error, error: "Provided config is not for SMB backend" };
}
if (os.platform() !== "linux") {
logger.error("SMB mounting is only supported on Linux hosts.");
return { status: BACKEND_STATUS.error, error: "SMB mounting is only supported on Linux hosts." };
}
const { status } = await checkHealth(path);
if (status === "mounted") {
return { status: BACKEND_STATUS.mounted };
}
logger.debug(`Trying to unmount any existing mounts at ${path} before mounting...`);
await unmount(path);
const run = async () => {
await fs.mkdir(path, { recursive: true });
const source = `//${config.server}/${config.share}`;
const options = [
`user=${config.username}`,
`pass=${config.password}`,
`vers=${config.vers}`,
`port=${config.port}`,
"uid=1000",
"gid=1000",
];
if (config.domain) {
options.push(`domain=${config.domain}`);
}
const args = ["-t", "cifs", "-o", options.join(","), source, path];
logger.debug(`Mounting SMB volume ${path}...`);
logger.info(`Executing mount: mount ${args.join(" ")}`);
await executeMount(args);
logger.info(`SMB volume at ${path} mounted successfully.`);
return { status: BACKEND_STATUS.mounted };
};
try {
return await withTimeout(run(), OPERATION_TIMEOUT, "SMB mount");
} catch (error) {
logger.error("Error mounting SMB volume", { error: toMessage(error) });
return { status: BACKEND_STATUS.error, error: toMessage(error) };
}
};
const unmount = async (path: string) => {
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." };
}
const run = async () => {
try {
await fs.access(path);
} catch {
logger.warn(`Path ${path} does not exist. Skipping unmount.`);
return { status: BACKEND_STATUS.unmounted };
}
await executeUnmount(path);
await fs.rmdir(path);
logger.info(`SMB volume at ${path} unmounted successfully.`);
return { status: BACKEND_STATUS.unmounted };
};
try {
return await withTimeout(run(), OPERATION_TIMEOUT, "SMB unmount");
} catch (error) {
logger.error("Error unmounting SMB volume", { path, error: toMessage(error) });
return { status: BACKEND_STATUS.error, error: toMessage(error) };
}
};
const checkHealth = async (path: string) => {
const run = async () => {
logger.debug(`Checking health of SMB volume at ${path}...`);
await fs.access(path);
const mount = await getMountForPath(path);
if (!mount || mount.fstype !== "cifs") {
throw new Error(`Path ${path} is not mounted as CIFS/SMB.`);
}
await createTestFile(path);
logger.debug(`SMB volume at ${path} is healthy and mounted.`);
return { status: BACKEND_STATUS.mounted };
};
try {
return await withTimeout(run(), OPERATION_TIMEOUT, "SMB health check");
} catch (error) {
logger.error("SMB volume health check failed:", toMessage(error));
return { status: BACKEND_STATUS.error, error: toMessage(error) };
}
};
export const makeSmbBackend = (config: BackendConfig, path: string): VolumeBackend => ({
mount: () => mount(config, path),
unmount: () => unmount(path),
checkHealth: () => checkHealth(path),
});

View File

@@ -0,0 +1,37 @@
import * as npath from "node:path";
import * as fs from "node:fs/promises";
import { execFile as execFileCb } from "node:child_process";
import { promisify } from "node:util";
import { OPERATION_TIMEOUT } from "../../../core/constants";
import { logger } from "../../../utils/logger";
const execFile = promisify(execFileCb);
export const executeMount = async (args: string[]): Promise<void> => {
const { stderr } = await execFile("mount", args, {
timeout: OPERATION_TIMEOUT,
maxBuffer: 1024 * 1024,
});
if (stderr?.trim()) {
logger.warn(stderr.trim());
}
};
export const executeUnmount = async (path: string): Promise<void> => {
const { stderr } = await execFile("umount", ["-l", "-f", path], {
timeout: OPERATION_TIMEOUT,
maxBuffer: 1024 * 1024,
});
if (stderr?.trim()) {
logger.warn(stderr.trim());
}
};
export const createTestFile = async (path: string): Promise<void> => {
const testFilePath = npath.join(path, `.healthcheck-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
await fs.writeFile(testFilePath, "healthcheck");
await fs.unlink(testFilePath);
};