mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat: cleanup dangling volumes and folders on startup and on schedule
This commit is contained in:
@@ -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:
|
||||
|
||||
15
apps/server/drizzle/0006_secret_micromacro.sql
Normal file
15
apps/server/drizzle/0006_secret_micromacro.sql
Normal file
@@ -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`;
|
||||
311
apps/server/drizzle/meta/0006_snapshot.json
Normal file
311
apps/server/drizzle/meta/0006_snapshot.json
Normal file
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,13 @@
|
||||
"when": 1759416698274,
|
||||
"tag": "0005_simple_alice",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1760734377440,
|
||||
"tag": "0006_secret_micromacro",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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<BackendType>().notNull(),
|
||||
status: text().$type<BackendStatus>().notNull().default("unmounted"),
|
||||
lastError: text("last_error"),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
42
apps/server/src/modules/lifecycle/cleanup.ts
Normal file
42
apps/server/src/modules/lifecycle/cleanup.ts
Normal file
@@ -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)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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...");
|
||||
|
||||
|
||||
5
apps/server/src/modules/volumes/helpers.ts
Normal file
5
apps/server/src/modules/volumes/helpers.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { VOLUME_MOUNT_BASE } from "../../core/constants";
|
||||
|
||||
export const getVolumePath = (name: string) => {
|
||||
return `${VOLUME_MOUNT_BASE}/${name}/_data`;
|
||||
};
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<MountInfo[]> {
|
||||
export async function readMountInfo(): Promise<MountInfo[]> {
|
||||
const text = await fs.readFile("/proc/self/mountinfo", "utf-8");
|
||||
const result: MountInfo[] = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user