From 7fe75c64e8bbcc57583da179d2ea40f2dccf7289 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Wed, 3 Sep 2025 22:18:59 +0200 Subject: [PATCH] feat: backend status & health check --- apps/client/app/api-client/types.gen.ts | 6 + apps/server/drizzle/0002_cheerful_randall.sql | 19 +++ apps/server/drizzle/meta/0002_snapshot.json | 110 ++++++++++++++++++ apps/server/drizzle/meta/_journal.json | 7 ++ apps/server/src/db/schema.ts | 5 +- apps/server/src/modules/backends/backend.ts | 2 + .../backends/directory/directory-backend.ts | 20 +++- .../src/modules/backends/nfs/nfs-backend.ts | 20 +++- .../src/modules/volumes/volume.controller.ts | 1 + apps/server/src/modules/volumes/volume.dto.ts | 3 + .../src/modules/volumes/volume.service.ts | 42 ++++++- packages/schemas/src/index.ts | 8 ++ 12 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 apps/server/drizzle/0002_cheerful_randall.sql create mode 100644 apps/server/drizzle/meta/0002_snapshot.json diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index 4d0d9ef..b5fe3eb 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -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; }; diff --git a/apps/server/drizzle/0002_cheerful_randall.sql b/apps/server/drizzle/0002_cheerful_randall.sql new file mode 100644 index 0000000..331b318 --- /dev/null +++ b/apps/server/drizzle/0002_cheerful_randall.sql @@ -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`); \ No newline at end of file diff --git a/apps/server/drizzle/meta/0002_snapshot.json b/apps/server/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..4fc6b70 --- /dev/null +++ b/apps/server/drizzle/meta/0002_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index d7b97c7..4ec75e7 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index 00ad3eb..5b388a7 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -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().notNull(), + status: text().$type().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().notNull(), diff --git a/apps/server/src/modules/backends/backend.ts b/apps/server/src/modules/backends/backend.ts index 0302a8d..9da64be 100644 --- a/apps/server/src/modules/backends/backend.ts +++ b/apps/server/src/modules/backends/backend.ts @@ -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; unmount: () => Promise; + checkHealth: () => Promise<{ error?: string; status: BackendStatus }>; }; export const createVolumeBackend = (volume: Volume): VolumeBackend => { diff --git a/apps/server/src/modules/backends/directory/directory-backend.ts b/apps/server/src/modules/backends/directory/directory-backend.ts index 110d8f4..61cf41c 100644 --- a/apps/server/src/modules/backends/directory/directory-backend.ts +++ b/apps/server/src/modules/backends/directory/directory-backend.ts @@ -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), }); diff --git a/apps/server/src/modules/backends/nfs/nfs-backend.ts b/apps/server/src/modules/backends/nfs/nfs-backend.ts index 5763d49..a08cc0f 100644 --- a/apps/server/src/modules/backends/nfs/nfs-backend.ts +++ b/apps/server/src/modules/backends/nfs/nfs-backend.ts @@ -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), }); diff --git a/apps/server/src/modules/volumes/volume.controller.ts b/apps/server/src/modules/volumes/volume.controller.ts index 1950092..e8eba79 100644 --- a/apps/server/src/modules/volumes/volume.controller.ts +++ b/apps/server/src/modules/volumes/volume.controller.ts @@ -24,6 +24,7 @@ export const volumeController = new Hono() ...volume, updatedAt: volume.updatedAt.getTime(), createdAt: volume.createdAt.getTime(), + lastHealthCheck: volume.lastHealthCheck.getTime(), })), } satisfies ListVolumesResponseDto; diff --git a/apps/server/src/modules/volumes/volume.dto.ts b/apps/server/src/modules/volumes/volume.dto.ts index eef74c5..5f0a65e 100644 --- a/apps/server/src/modules/volumes/volume.dto.ts +++ b/apps/server/src/modules/volumes/volume.dto.ts @@ -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, }); diff --git a/apps/server/src/modules/volumes/volume.service.ts b/apps/server/src/modules/volumes/volume.service.ts index 16690ad..4c6b618 100644 --- a/apps/server/src/modules/volumes/volume.service.ts +++ b/apps/server/src/modules/volumes/volume.service.ts @@ -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, }; diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index 6e28259..39ee174 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -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;