diff --git a/README.md b/README.md index 898ac72..ef4df5e 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ In order to run Ironmount, you need to have Docker and Docker Compose installed ```yaml services: ironmount: - image: ghcr.io/nicotsx/ironmount:v0.2.0 + image: ghcr.io/nicotsx/ironmount:v0.3.0 container_name: ironmount restart: unless-stopped cap_add: @@ -54,7 +54,7 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock - /run/docker/plugins:/run/docker/plugins - - /var/lib/docker/volumes/:/var/lib/docker/volumes:rshared + - /var/lib/ironmount/volumes/:/var/lib/ironmount/volumes:rshared - ironmount_data:/data volumes: diff --git a/apps/server/drizzle/0006_secret_micromacro.sql b/apps/server/drizzle/0006_secret_micromacro.sql new file mode 100644 index 0000000..13ad372 --- /dev/null +++ b/apps/server/drizzle/0006_secret_micromacro.sql @@ -0,0 +1,15 @@ +CREATE TABLE `repositories_table` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `backend` text NOT NULL, + `config` text NOT NULL, + `compression_mode` text DEFAULT 'auto', + `status` text DEFAULT 'unknown', + `last_checked` integer, + `last_error` text, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `repositories_table_name_unique` ON `repositories_table` (`name`);--> statement-breakpoint +ALTER TABLE `volumes_table` DROP COLUMN `path`; \ No newline at end of file diff --git a/apps/server/drizzle/meta/0006_snapshot.json b/apps/server/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..a9693d6 --- /dev/null +++ b/apps/server/drizzle/meta/0006_snapshot.json @@ -0,0 +1,311 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "16f360b6-fb61-44f3-a7f7-2bae78ebf7ca", + "prevId": "75f0aac0-aa63-4577-bfb6-4638a008935f", + "tables": { + "repositories_table": { + "name": "repositories_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "backend": { + "name": "backend", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "compression_mode": { + "name": "compression_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'auto'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'unknown'" + }, + "last_checked": { + "name": "last_checked", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "repositories_table_name_unique": { + "name": "repositories_table_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions_table": { + "name": "sessions_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_table_user_id_users_table_id_fk": { + "name": "sessions_table_user_id_users_table_id_fk", + "tableFrom": "sessions_table", + "tableTo": "users_table", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users_table": { + "name": "users_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "users_table_username_unique": { + "name": "users_table_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "volumes_table": { + "name": "volumes_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unmounted'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_health_check": { + "name": "last_health_check", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auto_remount": { + "name": "auto_remount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "volumes_table_name_unique": { + "name": "volumes_table_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 89f6717..8838894 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1759416698274, "tag": "0005_simple_alice", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1760734377440, + "tag": "0006_secret_micromacro", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index fe79129..e3b6e9a 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -13,7 +13,6 @@ import { int, sqliteTable, text } from "drizzle-orm/sqlite-core"; export const volumesTable = sqliteTable("volumes_table", { id: int().primaryKey({ autoIncrement: true }), name: text().notNull().unique(), - path: text().notNull(), type: text().$type().notNull(), status: text().$type().notNull().default("unmounted"), lastError: text("last_error"), diff --git a/apps/server/src/modules/backends/utils/backend-utils.ts b/apps/server/src/modules/backends/utils/backend-utils.ts index 4b89af3..fca9cf4 100644 --- a/apps/server/src/modules/backends/utils/backend-utils.ts +++ b/apps/server/src/modules/backends/utils/backend-utils.ts @@ -1,10 +1,10 @@ -import * as npath from "node:path"; -import * as fs from "node:fs/promises"; import { execFile as execFileCb } from "node:child_process"; +import * as fs from "node:fs/promises"; +import * as npath from "node:path"; import { promisify } from "node:util"; import { OPERATION_TIMEOUT } from "../../../core/constants"; -import { logger } from "../../../utils/logger"; import { toMessage } from "../../../utils/errors"; +import { logger } from "../../../utils/logger"; const execFile = promisify(execFileCb); diff --git a/apps/server/src/modules/driver/driver.controller.ts b/apps/server/src/modules/driver/driver.controller.ts index 5a52895..b00955c 100644 --- a/apps/server/src/modules/driver/driver.controller.ts +++ b/apps/server/src/modules/driver/driver.controller.ts @@ -43,9 +43,17 @@ export const driverController = new Hono() Err: "", }); }) - .post("/VolumeDriver.Path", (c) => { + .post("/VolumeDriver.Path", async (c) => { + const body = await c.req.json(); + + if (!body.Name) { + return c.json({ Err: "Volume name is required" }, 400); + } + + const { volume } = await volumeService.getVolume(body.Name.replace(/^im-/, "")); + return c.json({ - Mountpoint: `/mnt/something`, + Mountpoint: `${VOLUME_MOUNT_BASE}/${volume.name}/_data`, }); }) .post("/VolumeDriver.Get", async (c) => { diff --git a/apps/server/src/modules/lifecycle/cleanup.ts b/apps/server/src/modules/lifecycle/cleanup.ts new file mode 100644 index 0000000..520b2e4 --- /dev/null +++ b/apps/server/src/modules/lifecycle/cleanup.ts @@ -0,0 +1,42 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { VOLUME_MOUNT_BASE } from "../../core/constants"; +import { toMessage } from "../../utils/errors"; +import { logger } from "../../utils/logger"; +import { readMountInfo } from "../../utils/mountinfo"; +import { executeUnmount } from "../backends/utils/backend-utils"; +import { getVolumePath } from "../volumes/helpers"; +import { volumeService } from "../volumes/volume.service"; + +export const cleanupDanglingMounts = async () => { + const allVolumes = await volumeService.listVolumes(); + const allSystemMounts = await readMountInfo(); + + for (const mount of allSystemMounts) { + if (mount.mountPoint.includes("ironmount") && mount.mountPoint.endsWith("_data")) { + const matchingVolume = allVolumes.find((v) => getVolumePath(v.name) === mount.mountPoint); + if (!matchingVolume) { + logger.info(`Found dangling mount at ${mount.mountPoint}, attempting to unmount...`); + await executeUnmount(mount.mountPoint); + + await fs.rmdir(path.dirname(mount.mountPoint)).catch((err) => { + logger.warn(`Failed to remove dangling mount directory ${path.dirname(mount.mountPoint)}: ${toMessage(err)}`); + }); + } + } + } + + const allIronmountDirs = await fs.readdir(VOLUME_MOUNT_BASE).catch(() => []); + + for (const dir of allIronmountDirs) { + const volumePath = getVolumePath(dir); + const matchingVolume = allVolumes.find((v) => getVolumePath(v.name) === volumePath); + if (!matchingVolume) { + const fullPath = path.join(VOLUME_MOUNT_BASE, dir); + logger.info(`Found dangling mount directory at ${fullPath}, attempting to remove...`); + await fs.rmdir(fullPath, { recursive: true }).catch((err) => { + logger.warn(`Failed to remove dangling mount directory ${fullPath}: ${toMessage(err)}`); + }); + } + } +}; diff --git a/apps/server/src/modules/lifecycle/startup.ts b/apps/server/src/modules/lifecycle/startup.ts index d437c42..0e9cbd4 100644 --- a/apps/server/src/modules/lifecycle/startup.ts +++ b/apps/server/src/modules/lifecycle/startup.ts @@ -3,11 +3,13 @@ import { getTasks, schedule } from "node-cron"; import { db } from "../../db/db"; import { volumesTable } from "../../db/schema"; import { logger } from "../../utils/logger"; -import { volumeService } from "../volumes/volume.service"; import { restic } from "../../utils/restic"; +import { volumeService } from "../volumes/volume.service"; +import { cleanupDanglingMounts } from "./cleanup"; export const startup = async () => { await restic.ensurePassfile(); + cleanupDanglingMounts(); const volumes = await db.query.volumesTable.findMany({ where: or( @@ -23,6 +25,11 @@ export const startup = async () => { const existingTasks = getTasks(); existingTasks.forEach(async (task) => await task.destroy()); + schedule("0 * * * *", async () => { + logger.debug("Running hourly cleanup of dangling mounts..."); + await cleanupDanglingMounts(); + }); + schedule("* * * * *", async () => { logger.debug("Running health check for all volumes..."); diff --git a/apps/server/src/modules/volumes/helpers.ts b/apps/server/src/modules/volumes/helpers.ts new file mode 100644 index 0000000..ee24aee --- /dev/null +++ b/apps/server/src/modules/volumes/helpers.ts @@ -0,0 +1,5 @@ +import { VOLUME_MOUNT_BASE } from "../../core/constants"; + +export const getVolumePath = (name: string) => { + return `${VOLUME_MOUNT_BASE}/${name}/_data`; +}; diff --git a/apps/server/src/modules/volumes/volume.controller.ts b/apps/server/src/modules/volumes/volume.controller.ts index d84e579..dacc391 100644 --- a/apps/server/src/modules/volumes/volume.controller.ts +++ b/apps/server/src/modules/volumes/volume.controller.ts @@ -1,5 +1,6 @@ import { Hono } from "hono"; import { validator } from "hono-openapi"; +import { getVolumePath } from "./helpers"; import { createVolumeBody, createVolumeDto, @@ -29,6 +30,7 @@ export const volumeController = new Hono() const response = { volumes: volumes.map((volume) => ({ + path: getVolumePath(volume.name), ...volume, updatedAt: volume.updatedAt.getTime(), createdAt: volume.createdAt.getTime(), @@ -63,6 +65,7 @@ export const volumeController = new Hono() const response = { volume: { ...res.volume, + path: getVolumePath(res.volume.name), createdAt: res.volume.createdAt.getTime(), updatedAt: res.volume.updatedAt.getTime(), lastHealthCheck: res.volume.lastHealthCheck.getTime(), @@ -95,6 +98,7 @@ export const volumeController = new Hono() message: "Volume updated", volume: { ...res.volume, + path: getVolumePath(res.volume.name), createdAt: res.volume.createdAt.getTime(), updatedAt: res.volume.updatedAt.getTime(), lastHealthCheck: res.volume.lastHealthCheck.getTime(), diff --git a/apps/server/src/modules/volumes/volume.service.ts b/apps/server/src/modules/volumes/volume.service.ts index 6997a87..a3fbc83 100644 --- a/apps/server/src/modules/volumes/volume.service.ts +++ b/apps/server/src/modules/volumes/volume.service.ts @@ -13,6 +13,7 @@ import { toMessage } from "../../utils/errors"; import { getStatFs, type StatFs } from "../../utils/mountinfo"; import { createVolumeBackend } from "../backends/backend"; import type { UpdateVolumeBody } from "./volume.dto"; +import { getVolumePath } from "./helpers"; const listVolumes = async () => { const volumes = await db.query.volumesTable.findMany({}); @@ -31,14 +32,11 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => { throw new ConflictError("Volume already exists"); } - const volumePathHost = path.join(VOLUME_MOUNT_BASE); - const [created] = await db .insert(volumesTable) .values({ name: slug, config: backendConfig, - path: path.join(volumePathHost, slug, "_data"), type: backendConfig.backend, }) .returning(); @@ -266,10 +264,12 @@ const listFiles = async (name: string, subPath?: string) => { throw new InternalServerError("Volume is not mounted"); } - const requestedPath = subPath ? path.join(volume.path, subPath) : volume.path; + const volumePath = getVolumePath(volume.name); + + const requestedPath = subPath ? path.join(volumePath, subPath) : volumePath; const normalizedPath = path.normalize(requestedPath); - if (!normalizedPath.startsWith(volume.path)) { + if (!normalizedPath.startsWith(volumePath)) { throw new InternalServerError("Invalid path"); } @@ -279,7 +279,7 @@ const listFiles = async (name: string, subPath?: string) => { const files = await Promise.all( entries.map(async (entry) => { const fullPath = path.join(normalizedPath, entry.name); - const relativePath = path.relative(volume.path, fullPath); + const relativePath = path.relative(volumePath, fullPath); try { const stats = await fs.stat(fullPath); diff --git a/apps/server/src/utils/mountinfo.ts b/apps/server/src/utils/mountinfo.ts index 1719959..38ea26d 100644 --- a/apps/server/src/utils/mountinfo.ts +++ b/apps/server/src/utils/mountinfo.ts @@ -1,6 +1,5 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { $ } from "bun"; type MountInfo = { mountPoint: string; @@ -22,7 +21,7 @@ function unescapeMount(s: string): string { return s.replace(/\\([0-7]{3})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8))); } -async function readMountInfo(): Promise { +export async function readMountInfo(): Promise { const text = await fs.readFile("/proc/self/mountinfo", "utf-8"); const result: MountInfo[] = [];