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";
|
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;
|
||||||
};
|
};
|
||||||
|
|||||||
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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user