Compare commits

..

3 Commits

Author SHA1 Message Date
Nicolas Meienberger
bf33b15b3e fix: cleanup volumes on shutdown 2025-11-10 07:08:51 +01:00
Nicolas Meienberger
2b0fea9645 fix(mounts): use bun shell instead of execFile 2025-11-10 06:52:14 +01:00
Nicolas Meienberger
e9eeda304b chore: update readme with new version 2025-11-09 15:37:16 +01:00
10 changed files with 99 additions and 83 deletions

View File

@@ -36,7 +36,7 @@ In order to run Ironmount, you need to have Docker and Docker Compose installed
```yaml ```yaml
services: services:
ironmount: ironmount:
image: ghcr.io/nicotsx/ironmount:v0.5.0 image: ghcr.io/nicotsx/ironmount:v0.6
container_name: ironmount container_name: ironmount
restart: unless-stopped restart: unless-stopped
privileged: true privileged: true
@@ -67,7 +67,7 @@ If you want to track a local directory on the same server where Ironmount is run
```diff ```diff
services: services:
ironmount: ironmount:
image: ghcr.io/nicotsx/ironmount:v0.5.0 image: ghcr.io/nicotsx/ironmount:v0.6
container_name: ironmount container_name: ironmount
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@@ -124,24 +124,21 @@ Ironmount allows you to easily restore your data from backups. To restore data,
Ironmount is capable of propagating mounted volumes from within the container to the host system. This is particularly useful when you want to access the mounted data directly from the host to use it with other applications or services. Ironmount is capable of propagating mounted volumes from within the container to the host system. This is particularly useful when you want to access the mounted data directly from the host to use it with other applications or services.
In order to enable this feature, you need to run Ironmount with privileged mode and mount /proc from the host. Here is an example of how to set this up in your `docker-compose.yml` file: In order to enable this feature, you need to change your bind mount `/var/lib/ironmount` to use the `:rshared` flag. Here is an example of how to set this up in your `docker-compose.yml` file:
```diff ```diff
services: services:
ironmount: ironmount:
image: ghcr.io/nicotsx/ironmount:v0.5.0 image: ghcr.io/nicotsx/ironmount:v0.6
container_name: ironmount container_name: ironmount
restart: unless-stopped restart: unless-stopped
- cap_add:
- - SYS_ADMIN
+ privileged: true
ports: ports:
- "4096:4096" - "4096:4096"
devices: devices:
- /dev/fuse:/dev/fuse - /dev/fuse:/dev/fuse
volumes: volumes:
- /var/lib/ironmount:/var/lib/ironmount - - /var/lib/ironmount:/var/lib/ironmount
+ - /proc:/host/proc + - /var/lib/ironmount:/var/lib/ironmount:rshared
``` ```
Restart the Ironmount container to apply the changes: Restart the Ironmount container to apply the changes:
@@ -155,24 +152,23 @@ docker compose up -d
Ironmount can also be used as a Docker volume plugin, allowing you to mount your volumes directly into other Docker containers. This enables seamless integration with your containerized applications. Ironmount can also be used as a Docker volume plugin, allowing you to mount your volumes directly into other Docker containers. This enables seamless integration with your containerized applications.
In order to enable this feature, you need to run Ironmount with privileged mode and mount several items from the host. Here is an example of how to set this up in your `docker-compose.yml` file: In order to enable this feature, you need to run Ironmount with several items shared from the host. Here is an example of how to set this up in your `docker-compose.yml` file:
```diff ```diff
services: services:
ironmount: ironmount:
image: ghcr.io/nicotsx/ironmount:v0.5.0 image: ghcr.io/nicotsx/ironmount:v0.6
container_name: ironmount container_name: ironmount
restart: unless-stopped restart: unless-stopped
- cap_add: cap_add:
- - SYS_ADMIN - SYS_ADMIN
+ privileged: true
ports: ports:
- "4096:4096" - "4096:4096"
devices: devices:
- /dev/fuse:/dev/fuse - /dev/fuse:/dev/fuse
volumes: volumes:
- /var/lib/ironmount:/var/lib/ironmount - - /var/lib/ironmount:/var/lib/ironmount
+ - /proc:/host/proc + - /var/lib/ironmount:/var/lib/ironmount:rshared
+ - /run/docker/plugins:/run/docker/plugins + - /run/docker/plugins:/run/docker/plugins
+ - /var/run/docker.sock:/var/run/docker.sock + - /var/run/docker.sock:/var/run/docker.sock
``` ```

View File

@@ -6,9 +6,8 @@ await Bun.build({
sourcemap: true, sourcemap: true,
minify: { minify: {
whitespace: true, whitespace: true,
identifiers: true, identifiers: false,
syntax: true, syntax: true,
keepNames: true,
}, },
external: ["ssh2"], external: ["ssh2"],
}); });

View File

@@ -4,7 +4,6 @@ import { logger } from "../utils/logger";
export type SystemCapabilities = { export type SystemCapabilities = {
docker: boolean; docker: boolean;
hostProc: boolean;
}; };
let capabilitiesPromise: Promise<SystemCapabilities> | null = null; let capabilitiesPromise: Promise<SystemCapabilities> | null = null;
@@ -29,7 +28,6 @@ export async function getCapabilities(): Promise<SystemCapabilities> {
async function detectCapabilities(): Promise<SystemCapabilities> { async function detectCapabilities(): Promise<SystemCapabilities> {
return { return {
docker: await detectDocker(), docker: await detectDocker(),
hostProc: await detectHostProc(),
}; };
} }
@@ -55,23 +53,3 @@ async function detectDocker(): Promise<boolean> {
return false; return false;
} }
} }
/**
* Checks if host proc is available by attempting to access /host/proc/1/ns/mnt
* This allows using nsenter to execute mount commands in the host namespace
*/
async function detectHostProc(): Promise<boolean> {
try {
await fs.access("/host/proc/1/ns/mnt");
logger.info("Host proc capability: enabled");
return true;
} catch (_) {
logger.warn(
"Host proc capability: disabled. " +
"To enable: mount /proc:/host/proc:ro in docker-compose.yml. " +
"Mounts will be executed in container namespace instead of host namespace.",
);
return false;
}
}

View File

@@ -3,3 +3,4 @@ export const VOLUME_MOUNT_BASE = "/var/lib/ironmount/volumes";
export const REPOSITORY_BASE = "/var/lib/ironmount/repositories"; export const REPOSITORY_BASE = "/var/lib/ironmount/repositories";
export const DATABASE_URL = "/var/lib/ironmount/data/ironmount.db"; export const DATABASE_URL = "/var/lib/ironmount/data/ironmount.db";
export const RESTIC_PASS_FILE = "/var/lib/ironmount/data/restic.pass"; export const RESTIC_PASS_FILE = "/var/lib/ironmount/data/restic.pass";
export const SOCKET_PATH = "/run/docker/plugins/ironmount.sock";

View File

@@ -17,6 +17,8 @@ import { backupScheduleController } from "./modules/backups/backups.controller";
import { eventsController } from "./modules/events/events.controller"; import { eventsController } from "./modules/events/events.controller";
import { handleServiceError } from "./utils/errors"; import { handleServiceError } from "./utils/errors";
import { logger } from "./utils/logger"; import { logger } from "./utils/logger";
import { shutdown } from "./modules/lifecycle/shutdown";
import { SOCKET_PATH } from "./core/constants";
export const generalDescriptor = (app: Hono) => export const generalDescriptor = (app: Hono) =>
openAPIRouteHandler(app, { openAPIRouteHandler(app, {
@@ -70,17 +72,15 @@ runDbMigrations();
const { docker } = await getCapabilities(); const { docker } = await getCapabilities();
if (docker) { if (docker) {
const socketPath = "/run/docker/plugins/ironmount.sock";
try { try {
await fs.mkdir("/run/docker/plugins", { recursive: true }); await fs.mkdir("/run/docker/plugins", { recursive: true });
Bun.serve({ Bun.serve({
unix: socketPath, unix: SOCKET_PATH,
fetch: driver.fetch, fetch: driver.fetch,
}); });
logger.info(`Docker volume plugin server running at ${socketPath}`); logger.info(`Docker volume plugin server running at ${SOCKET_PATH}`);
} catch (error) { } catch (error) {
logger.error(`Failed to start Docker volume plugin server: ${error}`); logger.error(`Failed to start Docker volume plugin server: ${error}`);
} }
@@ -96,3 +96,16 @@ startup();
logger.info(`Server is running at http://localhost:4096`); logger.info(`Server is running at http://localhost:4096`);
export type AppType = typeof app; export type AppType = typeof app;
process.on("SIGTERM", async () => {
logger.info("SIGTERM received, starting graceful shutdown...");
await shutdown();
process.exit(0);
});
process.on("SIGINT", async () => {
logger.info("SIGINT received, starting graceful shutdown...");
await shutdown();
process.exit(0);
});

View File

@@ -1,31 +1,14 @@
import { execFile as execFileCb } from "node:child_process";
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as npath from "node:path"; import * as npath from "node:path";
import { promisify } from "node:util";
import { getCapabilities } from "../../../core/capabilities";
import { OPERATION_TIMEOUT } from "../../../core/constants";
import { toMessage } from "../../../utils/errors"; import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger"; import { logger } from "../../../utils/logger";
import { $ } from "bun";
const execFile = promisify(execFileCb);
export const executeMount = async (args: string[]): Promise<void> => { export const executeMount = async (args: string[]): Promise<void> => {
const capabilities = await getCapabilities();
let stderr: string | undefined; let stderr: string | undefined;
if (capabilities.hostProc) { const result = await $`mount ${args}`.nothrow();
const result = await execFile("nsenter", ["--mount=/host/proc/1/ns/mnt", "mount", ...args], { stderr = result.stderr.toString();
timeout: OPERATION_TIMEOUT,
maxBuffer: 1024 * 1024,
});
stderr = result.stderr;
} else {
const result = await execFile("mount", args, {
timeout: OPERATION_TIMEOUT,
maxBuffer: 1024 * 1024,
});
stderr = result.stderr;
}
if (stderr?.trim()) { if (stderr?.trim()) {
logger.warn(stderr.trim()); logger.warn(stderr.trim());
@@ -33,22 +16,10 @@ export const executeMount = async (args: string[]): Promise<void> => {
}; };
export const executeUnmount = async (path: string): Promise<void> => { export const executeUnmount = async (path: string): Promise<void> => {
const capabilities = await getCapabilities();
let stderr: string | undefined; let stderr: string | undefined;
if (capabilities.hostProc) { const result = await $`umount -l -f ${path}`.nothrow();
const result = await execFile("nsenter", ["--mount=/host/proc/1/ns/mnt", "umount", "-l", "-f", path], { stderr = result.stderr.toString();
timeout: OPERATION_TIMEOUT,
maxBuffer: 1024 * 1024,
});
stderr = result.stderr;
} else {
const result = await execFile("umount", ["-l", "-f", path], {
timeout: OPERATION_TIMEOUT,
maxBuffer: 1024 * 1024,
});
stderr = result.stderr;
}
if (stderr?.trim()) { if (stderr?.trim()) {
logger.warn(stderr.trim()); logger.warn(stderr.trim());

View File

@@ -0,0 +1,28 @@
import { Scheduler } from "../../core/scheduler";
import { eq, or } from "drizzle-orm";
import { db } from "../../db/db";
import { volumesTable } from "../../db/schema";
import { logger } from "../../utils/logger";
import { SOCKET_PATH } from "../../core/constants";
import { createVolumeBackend } from "../backends/backend";
export const shutdown = async () => {
await Scheduler.stop();
await Bun.file(SOCKET_PATH)
.delete()
.catch(() => {
// Ignore errors if the socket file does not exist
});
const volumes = await db.query.volumesTable.findMany({
where: or(eq(volumesTable.status, "mounted")),
});
for (const volume of volumes) {
const backend = createVolumeBackend(volume);
const { status, error } = await backend.unmount();
logger.info(`Volume ${volume.name} unmount status: ${status}${error ? `, error: ${error}` : ""}`);
}
};

View File

@@ -189,7 +189,7 @@ const backup = async (
let stdout = ""; let stdout = "";
await safeSpawn({ const res = await safeSpawn({
command: "restic", command: "restic",
args, args,
env, env,
@@ -210,6 +210,11 @@ const backup = async (
}, },
}); });
if (res.exitCode !== 0) {
logger.error(`Restic backup failed: ${res.stderr}`);
throw new Error(`Restic backup failed: ${res.stderr}`);
}
const lastLine = stdout.trim(); const lastLine = stdout.trim();
const resSummary = JSON.parse(lastLine ?? "{}"); const resSummary = JSON.parse(lastLine ?? "{}");

View File

@@ -12,10 +12,19 @@ interface Params {
finally?: () => Promise<void> | void; finally?: () => Promise<void> | void;
} }
type SpawnResult = {
exitCode: number;
stdout: string;
stderr: string;
};
export const safeSpawn = (params: Params) => { export const safeSpawn = (params: Params) => {
const { command, args, env = {}, signal, ...callbacks } = params; const { command, args, env = {}, signal, ...callbacks } = params;
return new Promise((resolve, reject) => { return new Promise<SpawnResult>((resolve) => {
let stdoutData = "";
let stderrData = "";
const child = spawn(command, args, { const child = spawn(command, args, {
env: { ...process.env, ...env }, env: { ...process.env, ...env },
signal: signal, signal: signal,
@@ -24,12 +33,16 @@ export const safeSpawn = (params: Params) => {
child.stdout.on("data", (data) => { child.stdout.on("data", (data) => {
if (callbacks.onStdout) { if (callbacks.onStdout) {
callbacks.onStdout(data.toString()); callbacks.onStdout(data.toString());
} else {
stdoutData += data.toString();
} }
}); });
child.stderr.on("data", (data) => { child.stderr.on("data", (data) => {
if (callbacks.onStderr) { if (callbacks.onStderr) {
callbacks.onStderr(data.toString()); callbacks.onStderr(data.toString());
} else {
stderrData += data.toString();
} }
}); });
@@ -40,7 +53,12 @@ export const safeSpawn = (params: Params) => {
if (callbacks.finally) { if (callbacks.finally) {
await callbacks.finally(); await callbacks.finally();
} }
reject(error);
resolve({
exitCode: -1,
stdout: stdoutData,
stderr: stderrData,
});
}); });
child.on("close", async (code) => { child.on("close", async (code) => {
@@ -50,7 +68,12 @@ export const safeSpawn = (params: Params) => {
if (callbacks.finally) { if (callbacks.finally) {
await callbacks.finally(); await callbacks.finally();
} }
resolve(code);
resolve({
exitCode: code === null ? -1 : code,
stdout: stdoutData,
stderr: stderrData,
});
}); });
}); });
}; };

View File

@@ -34,4 +34,6 @@ services:
ports: ports:
- "4096:4096" - "4096:4096"
volumes: volumes:
- /var/lib/ironmount/:/var/lib/ironmount/ - /var/lib/ironmount:/var/lib/ironmount:rshared
- /run/docker/plugins:/run/docker/plugins
- /var/run/docker.sock:/var/run/docker.sock