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"; backend: "smb";
}; };
createdAt: number; createdAt: number;
lastError: string;
lastHealthCheck: number;
name: string; name: string;
path: string; path: string;
status: "error" | "mounted" | "unknown" | "unmounted";
type: "directory" | "nfs" | "smb"; type: "directory" | "nfs" | "smb";
updatedAt: number; updatedAt: number;
}>; }>;
@@ -167,8 +170,11 @@ export type GetVolumeResponses = {
backend: "smb"; backend: "smb";
}; };
createdAt: number; createdAt: number;
lastError: string;
lastHealthCheck: number;
name: string; name: string;
path: string; path: string;
status: "error" | "mounted" | "unknown" | "unmounted";
type: "directory" | "nfs" | "smb"; type: "directory" | "nfs" | "smb";
updatedAt: number; 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, "when": 1755775437391,
"tag": "0001_far_frank_castle", "tag": "0001_far_frank_castle",
"breakpoints": true "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 { sql } from "drizzle-orm";
import { int, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { int, sqliteTable, text } from "drizzle-orm/sqlite-core";
@@ -7,6 +7,9 @@ export const volumesTable = sqliteTable("volumes_table", {
name: text().notNull().unique(), name: text().notNull().unique(),
path: text().notNull(), path: text().notNull(),
type: text().$type<BackendType>().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())`), createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_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(), 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 type { Volume } from "../../db/schema";
import { makeDirectoryBackend } from "./directory/directory-backend"; import { makeDirectoryBackend } from "./directory/directory-backend";
import { makeNfsBackend } from "./nfs/nfs-backend"; import { makeNfsBackend } from "./nfs/nfs-backend";
@@ -5,6 +6,7 @@ import { makeNfsBackend } from "./nfs/nfs-backend";
export type VolumeBackend = { export type VolumeBackend = {
mount: () => Promise<void>; mount: () => Promise<void>;
unmount: () => Promise<void>; unmount: () => Promise<void>;
checkHealth: () => Promise<{ error?: string; status: BackendStatus }>;
}; };
export const createVolumeBackend = (volume: Volume): VolumeBackend => { export const createVolumeBackend = (volume: Volume): VolumeBackend => {

View File

@@ -1,5 +1,6 @@
import * as fs from "node:fs/promises"; 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"; import type { VolumeBackend } from "../backend";
const mount = async (_config: BackendConfig, path: string) => { const mount = async (_config: BackendConfig, path: string) => {
@@ -11,7 +12,24 @@ const unmount = async () => {
console.log("Cannot unmount directory volume."); 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 => ({ export const makeDirectoryBackend = (config: BackendConfig, path: string): VolumeBackend => ({
mount: () => mount(config, path), mount: () => mount(config, path),
unmount, unmount,
checkHealth: () => checkHealth(path),
}); });

View File

@@ -1,7 +1,8 @@
import { exec } from "node:child_process"; import { exec } from "node:child_process";
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as os from "node:os"; 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"; import type { VolumeBackend } from "../backend";
const mount = async (config: BackendConfig, path: string) => { 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 => ({ export const makeNfsBackend = (config: BackendConfig, path: string): VolumeBackend => ({
mount: () => mount(config, path), mount: () => mount(config, path),
unmount: () => unmount(path), unmount: () => unmount(path),
checkHealth: () => checkHealth(path),
}); });

View File

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

View File

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

View File

@@ -109,7 +109,9 @@ const updateVolume = async (name: string, backendConfig: BackendConfig) => {
} }
const oldBackend = createVolumeBackend(existing); const oldBackend = createVolumeBackend(existing);
await oldBackend.unmount(); await oldBackend.unmount().catch((err) => {
console.warn("Failed to unmount backend:", err);
});
const updated = await db const updated = await db
.update(volumesTable) .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) => { const testConnection = async (backendConfig: BackendConfig) => {
let tempDir: string | null = null; let tempDir: string | null = null;
@@ -148,7 +183,10 @@ const testConnection = async (backendConfig: BackendConfig) => {
config: backendConfig, config: backendConfig,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
lastHealthCheck: new Date(),
type: backendConfig.backend, type: backendConfig.backend,
status: "unmounted" as const,
lastError: null,
}; };
const backend = createVolumeBackend(mockVolume); const backend = createVolumeBackend(mockVolume);
@@ -186,4 +224,6 @@ export const volumeService = {
getVolume, getVolume,
updateVolume, updateVolume,
testConnection, testConnection,
updateVolumeStatus,
getVolumeStatus,
}; };

View File

@@ -27,3 +27,11 @@ export const directoryConfigSchema = type({
export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(directoryConfigSchema); export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(directoryConfigSchema);
export type BackendConfig = typeof volumeConfigSchema.infer; 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;