feat: backend status & health check

This commit is contained in:
Nicolas Meienberger
2025-09-03 22:18:59 +02:00
parent 63b983b1b1
commit 7fe75c64e8
12 changed files with 239 additions and 4 deletions

View File

@@ -28,8 +28,11 @@ export type ListVolumesResponses = {
backend: "smb";
};
createdAt: number;
lastError: string;
lastHealthCheck: number;
name: string;
path: string;
status: "error" | "mounted" | "unknown" | "unmounted";
type: "directory" | "nfs" | "smb";
updatedAt: number;
}>;
@@ -167,8 +170,11 @@ export type GetVolumeResponses = {
backend: "smb";
};
createdAt: number;
lastError: string;
lastHealthCheck: number;
name: string;
path: string;
status: "error" | "mounted" | "unknown" | "unmounted";
type: "directory" | "nfs" | "smb";
updatedAt: number;
};

View File

@@ -0,0 +1,19 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_volumes_table` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`path` text NOT NULL,
`type` text NOT NULL,
`status` text DEFAULT 'unmounted' NOT NULL,
`last_error` text,
`last_health_check` integer DEFAULT (unixepoch()) NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
`config` text NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_volumes_table`("id", "name", "path", "type", "status", "last_error", "last_health_check", "created_at", "updated_at", "config") SELECT "id", "name", "path", "type", "status", "last_error", "last_health_check", "created_at", "updated_at", "config" FROM `volumes_table`;--> statement-breakpoint
DROP TABLE `volumes_table`;--> statement-breakpoint
ALTER TABLE `__new_volumes_table` RENAME TO `volumes_table`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE UNIQUE INDEX `volumes_table_name_unique` ON `volumes_table` (`name`);

View File

@@ -0,0 +1,110 @@
{
"version": "6",
"dialect": "sqlite",
"id": "00a82d1d-4745-4487-83e4-42bb7aaa3e95",
"prevId": "004e25a0-ecda-4b1a-aeab-46c8f78d5275",
"tables": {
"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
},
"path": {
"name": "path",
"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
}
},
"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": {}
}
}

View File

@@ -15,6 +15,13 @@
"when": 1755775437391,
"tag": "0001_far_frank_castle",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1756930554198,
"tag": "0002_cheerful_randall",
"breakpoints": true
}
]
}

View File

@@ -1,4 +1,4 @@
import type { BackendType, volumeConfigSchema } from "@ironmount/schemas";
import type { BackendStatus, BackendType, volumeConfigSchema } from "@ironmount/schemas";
import { sql } from "drizzle-orm";
import { int, sqliteTable, text } from "drizzle-orm/sqlite-core";
@@ -7,6 +7,9 @@ export const volumesTable = sqliteTable("volumes_table", {
name: text().notNull().unique(),
path: text().notNull(),
type: text().$type<BackendType>().notNull(),
status: text().$type<BackendStatus>().notNull().default("unmounted"),
lastError: text("last_error"),
lastHealthCheck: int("last_health_check", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
config: text("config", { mode: "json" }).$type<typeof volumeConfigSchema.inferOut>().notNull(),

View File

@@ -1,3 +1,4 @@
import type { BackendStatus } from "@ironmount/schemas";
import type { Volume } from "../../db/schema";
import { makeDirectoryBackend } from "./directory/directory-backend";
import { makeNfsBackend } from "./nfs/nfs-backend";
@@ -5,6 +6,7 @@ import { makeNfsBackend } from "./nfs/nfs-backend";
export type VolumeBackend = {
mount: () => Promise<void>;
unmount: () => Promise<void>;
checkHealth: () => Promise<{ error?: string; status: BackendStatus }>;
};
export const createVolumeBackend = (volume: Volume): VolumeBackend => {

View File

@@ -1,5 +1,6 @@
import * as fs from "node:fs/promises";
import type { BackendConfig } from "@ironmount/schemas";
import * as npath from "node:path";
import { BACKEND_STATUS, type BackendConfig } from "@ironmount/schemas";
import type { VolumeBackend } from "../backend";
const mount = async (_config: BackendConfig, path: string) => {
@@ -11,7 +12,24 @@ const unmount = async () => {
console.log("Cannot unmount directory volume.");
};
const checkHealth = async (path: string) => {
try {
await fs.access(path);
// Try to create a temporary file to ensure write access
const tempFilePath = npath.join(path, `.healthcheck-${Date.now()}`);
await fs.writeFile(tempFilePath, "healthcheck");
await fs.unlink(tempFilePath);
return { status: BACKEND_STATUS.mounted };
} catch (error) {
console.error("Directory health check failed:", error);
return { status: BACKEND_STATUS.error, error: error instanceof Error ? error.message : String(error) };
}
};
export const makeDirectoryBackend = (config: BackendConfig, path: string): VolumeBackend => ({
mount: () => mount(config, path),
unmount,
checkHealth: () => checkHealth(path),
});

View File

@@ -1,7 +1,8 @@
import { exec } from "node:child_process";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import type { BackendConfig } from "@ironmount/schemas";
import * as npath from "node:path";
import { BACKEND_STATUS, type BackendConfig } from "@ironmount/schemas";
import type { VolumeBackend } from "../backend";
const mount = async (config: BackendConfig, path: string) => {
@@ -54,7 +55,24 @@ const unmount = async (path: string) => {
});
};
const checkHealth = async (path: string) => {
try {
await fs.access(path);
// Try to create a temporary file to ensure the mount is writable
const testFilePath = npath.join(path, `.healthcheck-${Date.now()}`);
await fs.writeFile(testFilePath, "healthcheck");
await fs.unlink(testFilePath);
return { status: BACKEND_STATUS.mounted };
} catch (error) {
console.error("NFS volume health check failed:", error);
return { status: BACKEND_STATUS.error, error: error instanceof Error ? error.message : String(error) };
}
};
export const makeNfsBackend = (config: BackendConfig, path: string): VolumeBackend => ({
mount: () => mount(config, path),
unmount: () => unmount(path),
checkHealth: () => checkHealth(path),
});

View File

@@ -24,6 +24,7 @@ export const volumeController = new Hono()
...volume,
updatedAt: volume.updatedAt.getTime(),
createdAt: volume.createdAt.getTime(),
lastHealthCheck: volume.lastHealthCheck.getTime(),
})),
} satisfies ListVolumesResponseDto;

View File

@@ -7,8 +7,11 @@ const volumeSchema = type({
name: "string",
path: "string",
type: type.enumerated("nfs", "smb", "directory"),
status: type.enumerated("mounted", "unmounted", "error", "unknown"),
lastError: "string|null",
createdAt: "number",
updatedAt: "number",
lastHealthCheck: "number",
config: volumeConfigSchema,
});

View File

@@ -109,7 +109,9 @@ const updateVolume = async (name: string, backendConfig: BackendConfig) => {
}
const oldBackend = createVolumeBackend(existing);
await oldBackend.unmount();
await oldBackend.unmount().catch((err) => {
console.warn("Failed to unmount backend:", err);
});
const updated = await db
.update(volumesTable)
@@ -135,6 +137,39 @@ const updateVolume = async (name: string, backendConfig: BackendConfig) => {
}
};
const updateVolumeStatus = async (name: string, status: "mounted" | "unmounted" | "error", error?: string) => {
await db
.update(volumesTable)
.set({
status,
lastHealthCheck: new Date(),
lastError: error ?? null,
updatedAt: new Date(),
})
.where(eq(volumesTable.name, name));
};
const getVolumeStatus = async (name: string) => {
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.name, name),
});
if (!volume) {
return { error: new NotFoundError("Volume not found") };
}
const backend = createVolumeBackend(volume);
const healthResult = await backend.checkHealth();
await updateVolumeStatus(name, healthResult.status, healthResult.error);
return {
name: volume.name,
status: healthResult.status,
lastHealthCheck: new Date(),
error: healthResult.error,
};
};
const testConnection = async (backendConfig: BackendConfig) => {
let tempDir: string | null = null;
@@ -148,7 +183,10 @@ const testConnection = async (backendConfig: BackendConfig) => {
config: backendConfig,
createdAt: new Date(),
updatedAt: new Date(),
lastHealthCheck: new Date(),
type: backendConfig.backend,
status: "unmounted" as const,
lastError: null,
};
const backend = createVolumeBackend(mockVolume);
@@ -186,4 +224,6 @@ export const volumeService = {
getVolume,
updateVolume,
testConnection,
updateVolumeStatus,
getVolumeStatus,
};

View File

@@ -27,3 +27,11 @@ export const directoryConfigSchema = type({
export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(directoryConfigSchema);
export type BackendConfig = typeof volumeConfigSchema.infer;
export const BACKEND_STATUS = {
mounted: "mounted",
unmounted: "unmounted",
error: "error",
} as const;
export type BackendStatus = keyof typeof BACKEND_STATUS;