mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat: backend status & health check
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
19
apps/server/drizzle/0002_cheerful_randall.sql
Normal file
19
apps/server/drizzle/0002_cheerful_randall.sql
Normal 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`);
|
||||
110
apps/server/drizzle/meta/0002_snapshot.json
Normal file
110
apps/server/drizzle/meta/0002_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ export const volumeController = new Hono()
|
||||
...volume,
|
||||
updatedAt: volume.updatedAt.getTime(),
|
||||
createdAt: volume.createdAt.getTime(),
|
||||
lastHealthCheck: volume.lastHealthCheck.getTime(),
|
||||
})),
|
||||
} satisfies ListVolumesResponseDto;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user