feat: cleanup dangling volumes and folders on startup and on schedule

This commit is contained in:
Nicolas Meienberger
2025-10-17 23:01:47 +02:00
parent 8a9d5fc3c8
commit 8fcc9ada74
13 changed files with 414 additions and 17 deletions

View File

@@ -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"),

View File

@@ -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);

View File

@@ -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) => {

View 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)}`);
});
}
}
};

View File

@@ -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...");

View File

@@ -0,0 +1,5 @@
import { VOLUME_MOUNT_BASE } from "../../core/constants";
export const getVolumePath = (name: string) => {
return `${VOLUME_MOUNT_BASE}/${name}/_data`;
};

View File

@@ -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(),

View File

@@ -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);

View File

@@ -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[] = [];