refactor: unify backend and frontend servers (#3)

* refactor: unify backend and frontend servers

* refactor: correct paths for openapi & drizzle

* refactor: move api-client to client

* fix: drizzle paths

* chore: fix linting issues

* fix: form reset issue
This commit is contained in:
Nico
2025-11-13 20:11:46 +01:00
committed by GitHub
parent 8d7e50508d
commit 95a0d44b45
240 changed files with 5171 additions and 5875 deletions

View File

@@ -0,0 +1,10 @@
import { VOLUME_MOUNT_BASE } from "../../core/constants";
import type { Volume } from "../../db/schema";
export const getVolumePath = (volume: Volume) => {
if (volume.config.backend === "directory") {
return volume.config.path;
}
return `${VOLUME_MOUNT_BASE}/${volume.name}/_data`;
};

View File

@@ -0,0 +1,137 @@
import { Hono } from "hono";
import { validator } from "hono-openapi";
import {
createVolumeBody,
createVolumeDto,
deleteVolumeDto,
getContainersDto,
getVolumeDto,
healthCheckDto,
type ListVolumesDto,
listFilesDto,
listVolumesDto,
mountVolumeDto,
testConnectionBody,
testConnectionDto,
unmountVolumeDto,
updateVolumeBody,
updateVolumeDto,
type CreateVolumeDto,
type GetVolumeDto,
type ListContainersDto,
type UpdateVolumeDto,
type ListFilesDto,
browseFilesystemDto,
type BrowseFilesystemDto,
} from "./volume.dto";
import { volumeService } from "./volume.service";
import { getVolumePath } from "./helpers";
export const volumeController = new Hono()
.get("/", listVolumesDto, async (c) => {
const volumes = await volumeService.listVolumes();
return c.json<ListVolumesDto>(volumes, 200);
})
.post("/", createVolumeDto, validator("json", createVolumeBody), async (c) => {
const body = c.req.valid("json");
const res = await volumeService.createVolume(body.name, body.config);
const response = {
...res.volume,
path: getVolumePath(res.volume),
};
return c.json<CreateVolumeDto>(response, 201);
})
.post("/test-connection", testConnectionDto, validator("json", testConnectionBody), async (c) => {
const body = c.req.valid("json");
const result = await volumeService.testConnection(body.config);
return c.json(result, 200);
})
.delete("/:name", deleteVolumeDto, async (c) => {
const { name } = c.req.param();
await volumeService.deleteVolume(name);
return c.json({ message: "Volume deleted" }, 200);
})
.get("/:name", getVolumeDto, async (c) => {
const { name } = c.req.param();
const res = await volumeService.getVolume(name);
const response = {
volume: {
...res.volume,
path: getVolumePath(res.volume),
},
statfs: {
total: res.statfs.total ?? 0,
used: res.statfs.used ?? 0,
free: res.statfs.free ?? 0,
},
};
return c.json<GetVolumeDto>(response, 200);
})
.get("/:name/containers", getContainersDto, async (c) => {
const { name } = c.req.param();
const { containers } = await volumeService.getContainersUsingVolume(name);
return c.json<ListContainersDto>(containers, 200);
})
.put("/:name", updateVolumeDto, validator("json", updateVolumeBody), async (c) => {
const { name } = c.req.param();
const body = c.req.valid("json");
const res = await volumeService.updateVolume(name, body);
const response = {
...res.volume,
path: getVolumePath(res.volume),
};
return c.json<UpdateVolumeDto>(response, 200);
})
.post("/:name/mount", mountVolumeDto, async (c) => {
const { name } = c.req.param();
const { error, status } = await volumeService.mountVolume(name);
return c.json({ error, status }, error ? 500 : 200);
})
.post("/:name/unmount", unmountVolumeDto, async (c) => {
const { name } = c.req.param();
const { error, status } = await volumeService.unmountVolume(name);
return c.json({ error, status }, error ? 500 : 200);
})
.post("/:name/health-check", healthCheckDto, async (c) => {
const { name } = c.req.param();
const { error, status } = await volumeService.checkHealth(name);
return c.json({ error, status }, 200);
})
.get("/:name/files", listFilesDto, async (c) => {
const { name } = c.req.param();
const subPath = c.req.query("path");
const result = await volumeService.listFiles(name, subPath);
const response = {
files: result.files,
path: result.path,
};
c.header("Cache-Control", "public, max-age=10, stale-while-revalidate=60");
return c.json<ListFilesDto>(response, 200);
})
.get("/filesystem/browse", browseFilesystemDto, async (c) => {
const path = c.req.query("path") || "/";
const result = await volumeService.browseFilesystem(path);
const response = {
directories: result.directories,
path: result.path,
};
return c.json<BrowseFilesystemDto>(response, 200);
});

View File

@@ -0,0 +1,373 @@
import { type } from "arktype";
import { describeRoute, resolver } from "hono-openapi";
import { BACKEND_STATUS, BACKEND_TYPES, volumeConfigSchema } from "~/schemas/volumes";
export const volumeSchema = type({
id: "number",
name: "string",
type: type.valueOf(BACKEND_TYPES),
status: type.valueOf(BACKEND_STATUS),
lastError: "string | null",
createdAt: "number",
updatedAt: "number",
lastHealthCheck: "number",
config: volumeConfigSchema,
autoRemount: "boolean",
});
export type VolumeDto = typeof volumeSchema.infer;
/**
* List all volumes
*/
export const listVolumesResponse = volumeSchema.array();
export type ListVolumesDto = typeof listVolumesResponse.infer;
export const listVolumesDto = describeRoute({
description: "List all volumes",
tags: ["Volumes"],
operationId: "listVolumes",
responses: {
200: {
description: "A list of volumes",
content: {
"application/json": {
schema: resolver(listVolumesResponse),
},
},
},
},
});
/**
* Create a new volume
*/
export const createVolumeBody = type({
name: "string",
config: volumeConfigSchema,
});
export const createVolumeResponse = volumeSchema;
export type CreateVolumeDto = typeof createVolumeResponse.infer;
export const createVolumeDto = describeRoute({
description: "Create a new volume",
operationId: "createVolume",
tags: ["Volumes"],
responses: {
201: {
description: "Volume created successfully",
content: {
"application/json": {
schema: resolver(createVolumeResponse),
},
},
},
},
});
/**
* Delete a volume
*/
export const deleteVolumeResponse = type({
message: "string",
});
export type DeleteVolumeDto = typeof deleteVolumeResponse.infer;
export const deleteVolumeDto = describeRoute({
description: "Delete a volume",
operationId: "deleteVolume",
tags: ["Volumes"],
responses: {
200: {
description: "Volume deleted successfully",
content: {
"application/json": {
schema: resolver(deleteVolumeResponse),
},
},
},
},
});
const statfsSchema = type({
total: "number",
used: "number",
free: "number",
});
const getVolumeResponse = type({
volume: volumeSchema,
statfs: statfsSchema,
});
export type GetVolumeDto = typeof getVolumeResponse.infer;
/**
* Get a volume
*/
export const getVolumeDto = describeRoute({
description: "Get a volume by name",
operationId: "getVolume",
tags: ["Volumes"],
responses: {
200: {
description: "Volume details",
content: {
"application/json": {
schema: resolver(getVolumeResponse),
},
},
},
404: {
description: "Volume not found",
},
},
});
/**
* Update a volume
*/
export const updateVolumeBody = type({
autoRemount: "boolean?",
config: volumeConfigSchema.optional(),
});
export type UpdateVolumeBody = typeof updateVolumeBody.infer;
export const updateVolumeResponse = volumeSchema;
export type UpdateVolumeDto = typeof updateVolumeResponse.infer;
export const updateVolumeDto = describeRoute({
description: "Update a volume's configuration",
operationId: "updateVolume",
tags: ["Volumes"],
responses: {
200: {
description: "Volume updated successfully",
content: {
"application/json": {
schema: resolver(updateVolumeResponse),
},
},
},
404: {
description: "Volume not found",
},
},
});
/**
* Test connection
*/
export const testConnectionBody = type({
config: volumeConfigSchema,
});
export const testConnectionResponse = type({
success: "boolean",
message: "string",
});
export type TestConnectionDto = typeof testConnectionResponse.infer;
export const testConnectionDto = describeRoute({
description: "Test connection to backend",
operationId: "testConnection",
tags: ["Volumes"],
responses: {
200: {
description: "Connection test result",
content: {
"application/json": {
schema: resolver(testConnectionResponse),
},
},
},
},
});
/**
* Mount volume
*/
export const mountVolumeResponse = type({
error: "string?",
status: type.valueOf(BACKEND_STATUS),
});
export type MountVolumeDto = typeof mountVolumeResponse.infer;
export const mountVolumeDto = describeRoute({
description: "Mount a volume",
operationId: "mountVolume",
tags: ["Volumes"],
responses: {
200: {
description: "Volume mounted successfully",
content: {
"application/json": {
schema: resolver(mountVolumeResponse),
},
},
},
},
});
/**
* Unmount volume
*/
export const unmountVolumeResponse = type({
error: "string?",
status: type.valueOf(BACKEND_STATUS),
});
export type UnmountVolumeDto = typeof unmountVolumeResponse.infer;
export const unmountVolumeDto = describeRoute({
description: "Unmount a volume",
operationId: "unmountVolume",
tags: ["Volumes"],
responses: {
200: {
description: "Volume unmounted successfully",
content: {
"application/json": {
schema: resolver(unmountVolumeResponse),
},
},
},
},
});
export const healthCheckResponse = type({
error: "string?",
status: type.valueOf(BACKEND_STATUS),
});
export type HealthCheckDto = typeof healthCheckResponse.infer;
export const healthCheckDto = describeRoute({
description: "Perform a health check on a volume",
operationId: "healthCheckVolume",
tags: ["Volumes"],
responses: {
200: {
description: "Volume health check result",
content: {
"application/json": {
schema: resolver(healthCheckResponse),
},
},
},
404: {
description: "Volume not found",
},
},
});
/**
* Get containers using a volume
*/
const containerSchema = type({
id: "string",
name: "string",
state: "string",
image: "string",
});
export const listContainersResponse = containerSchema.array();
export type ListContainersDto = typeof listContainersResponse.infer;
export const getContainersDto = describeRoute({
description: "Get containers using a volume by name",
operationId: "getContainersUsingVolume",
tags: ["Volumes"],
responses: {
200: {
description: "List of containers using the volume",
content: {
"application/json": {
schema: resolver(listContainersResponse),
},
},
},
404: {
description: "Volume not found",
},
},
});
/**
* List files in a volume
*/
const fileEntrySchema = type({
name: "string",
path: "string",
type: type.enumerated("file", "directory"),
size: "number?",
modifiedAt: "number?",
});
export const listFilesResponse = type({
files: fileEntrySchema.array(),
path: "string",
});
export type ListFilesDto = typeof listFilesResponse.infer;
export const listFilesDto = describeRoute({
description: "List files in a volume directory",
operationId: "listFiles",
tags: ["Volumes"],
parameters: [
{
in: "query",
name: "path",
required: false,
schema: {
type: "string",
},
description: "Subdirectory path to list (relative to volume root)",
},
],
responses: {
200: {
description: "List of files in the volume",
content: {
"application/json": {
schema: resolver(listFilesResponse),
},
},
},
},
});
/**
* Browse filesystem directories
*/
export const browseFilesystemResponse = type({
directories: fileEntrySchema.array(),
path: "string",
});
export type BrowseFilesystemDto = typeof browseFilesystemResponse.infer;
export const browseFilesystemDto = describeRoute({
description: "Browse directories on the host filesystem",
operationId: "browseFilesystem",
tags: ["Volumes"],
parameters: [
{
in: "query",
name: "path",
required: false,
schema: {
type: "string",
},
description: "Directory path to browse (absolute path, defaults to /)",
},
],
responses: {
200: {
description: "List of directories in the specified path",
content: {
"application/json": {
schema: resolver(browseFilesystemResponse),
},
},
},
},
});

View File

@@ -0,0 +1,406 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import Docker from "dockerode";
import { eq } from "drizzle-orm";
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
import slugify from "slugify";
import { getCapabilities } from "../../core/capabilities";
import { db } from "../../db/db";
import { volumesTable } from "../../db/schema";
import { toMessage } from "../../utils/errors";
import { getStatFs, type StatFs } from "../../utils/mountinfo";
import { withTimeout } from "../../utils/timeout";
import { createVolumeBackend } from "../backends/backend";
import type { UpdateVolumeBody } from "./volume.dto";
import { getVolumePath } from "./helpers";
import { logger } from "../../utils/logger";
import { serverEvents } from "../../core/events";
import type { BackendConfig } from "~/schemas/volumes";
const listVolumes = async () => {
const volumes = await db.query.volumesTable.findMany({});
return volumes;
};
const createVolume = async (name: string, backendConfig: BackendConfig) => {
const slug = slugify(name, { lower: true, strict: true });
const existing = await db.query.volumesTable.findFirst({
where: eq(volumesTable.name, slug),
});
if (existing) {
throw new ConflictError("Volume already exists");
}
const [created] = await db
.insert(volumesTable)
.values({
name: slug,
config: backendConfig,
type: backendConfig.backend,
})
.returning();
if (!created) {
throw new InternalServerError("Failed to create volume");
}
const backend = createVolumeBackend(created);
const { error, status } = await backend.mount();
await db
.update(volumesTable)
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
.where(eq(volumesTable.name, slug));
return { volume: created, status: 201 };
};
const deleteVolume = async (name: string) => {
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.name, name),
});
if (!volume) {
throw new NotFoundError("Volume not found");
}
const backend = createVolumeBackend(volume);
await backend.unmount();
await db.delete(volumesTable).where(eq(volumesTable.name, name));
};
const mountVolume = async (name: string) => {
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.name, name),
});
if (!volume) {
throw new NotFoundError("Volume not found");
}
const backend = createVolumeBackend(volume);
const { error, status } = await backend.mount();
await db
.update(volumesTable)
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
.where(eq(volumesTable.name, name));
if (status === "mounted") {
serverEvents.emit("volume:mounted", { volumeName: name });
}
return { error, status };
};
const unmountVolume = async (name: string) => {
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.name, name),
});
if (!volume) {
throw new NotFoundError("Volume not found");
}
const backend = createVolumeBackend(volume);
const { status, error } = await backend.unmount();
await db.update(volumesTable).set({ status }).where(eq(volumesTable.name, name));
if (status === "unmounted") {
serverEvents.emit("volume:unmounted", { volumeName: name });
}
return { error, status };
};
const getVolume = async (name: string) => {
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.name, name),
});
if (!volume) {
throw new NotFoundError("Volume not found");
}
let statfs: Partial<StatFs> = {};
if (volume.status === "mounted") {
statfs = await withTimeout(getStatFs(getVolumePath(volume)), 1000, "getStatFs").catch((error) => {
logger.warn(`Failed to get statfs for volume ${name}: ${toMessage(error)}`);
return {};
});
}
return { volume, statfs };
};
const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
const existing = await db.query.volumesTable.findFirst({
where: eq(volumesTable.name, name),
});
if (!existing) {
throw new NotFoundError("Volume not found");
}
const configChanged =
JSON.stringify(existing.config) !== JSON.stringify(volumeData.config) && volumeData.config !== undefined;
if (configChanged) {
logger.debug("Unmounting existing volume before applying new config");
const backend = createVolumeBackend(existing);
await backend.unmount();
}
const [updated] = await db
.update(volumesTable)
.set({
config: volumeData.config,
type: volumeData.config?.backend,
autoRemount: volumeData.autoRemount,
updatedAt: Date.now(),
})
.where(eq(volumesTable.name, name))
.returning();
if (!updated) {
throw new InternalServerError("Failed to update volume");
}
if (configChanged) {
const backend = createVolumeBackend(updated);
const { error, status } = await backend.mount();
await db
.update(volumesTable)
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
.where(eq(volumesTable.name, name));
serverEvents.emit("volume:updated", { volumeName: name });
}
return { volume: updated };
};
const testConnection = async (backendConfig: BackendConfig) => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ironmount-test-"));
const mockVolume = {
id: 0,
name: "test-connection",
path: tempDir,
config: backendConfig,
createdAt: Date.now(),
updatedAt: Date.now(),
lastHealthCheck: Date.now(),
type: backendConfig.backend,
status: "unmounted" as const,
lastError: null,
autoRemount: true,
};
const backend = createVolumeBackend(mockVolume);
const { error } = await backend.mount();
await backend.unmount();
await fs.access(tempDir);
await fs.rm(tempDir, { recursive: true, force: true });
return {
success: !error,
message: error ? toMessage(error) : "Connection successful",
};
};
const checkHealth = async (name: string) => {
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.name, name),
});
if (!volume) {
throw new NotFoundError("Volume not found");
}
const backend = createVolumeBackend(volume);
const { error, status } = await backend.checkHealth();
if (status !== volume.status) {
serverEvents.emit("volume:status_changed", { volumeName: name, status });
}
await db
.update(volumesTable)
.set({ lastHealthCheck: Date.now(), status, lastError: error ?? null })
.where(eq(volumesTable.name, volume.name));
return { status, error };
};
const getContainersUsingVolume = async (name: string) => {
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.name, name),
});
if (!volume) {
throw new NotFoundError("Volume not found");
}
const { docker } = await getCapabilities();
if (!docker) {
logger.debug("Docker capability not available, returning empty containers list");
return { containers: [] };
}
try {
const docker = new Docker();
const containers = await docker.listContainers({ all: true });
const usingContainers = [];
for (const info of containers) {
const container = docker.getContainer(info.Id);
const inspect = await container.inspect();
const mounts = inspect.Mounts || [];
const usesVolume = mounts.some((mount) => mount.Type === "volume" && mount.Name === `im-${volume.name}`);
if (usesVolume) {
usingContainers.push({
id: inspect.Id,
name: inspect.Name,
state: inspect.State.Status,
image: inspect.Config.Image,
});
}
}
return { containers: usingContainers };
} catch (error) {
logger.error(`Failed to get containers using volume: ${toMessage(error)}`);
return { containers: [] };
}
};
const listFiles = async (name: string, subPath?: string) => {
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.name, name),
});
if (!volume) {
throw new NotFoundError("Volume not found");
}
if (volume.status !== "mounted") {
throw new InternalServerError("Volume is not mounted");
}
// For directory volumes, use the configured path directly
const volumePath = getVolumePath(volume);
const requestedPath = subPath ? path.join(volumePath, subPath) : volumePath;
const normalizedPath = path.normalize(requestedPath);
if (!normalizedPath.startsWith(volumePath)) {
throw new InternalServerError("Invalid path");
}
try {
const entries = await fs.readdir(normalizedPath, { withFileTypes: true });
const files = await Promise.all(
entries.map(async (entry) => {
const fullPath = path.join(normalizedPath, entry.name);
const relativePath = path.relative(volumePath, fullPath);
try {
const stats = await fs.stat(fullPath);
return {
name: entry.name,
path: `/${relativePath}`,
type: entry.isDirectory() ? ("directory" as const) : ("file" as const),
size: entry.isFile() ? stats.size : undefined,
modifiedAt: stats.mtimeMs,
};
} catch {
return {
name: entry.name,
path: `/${relativePath}`,
type: entry.isDirectory() ? ("directory" as const) : ("file" as const),
size: undefined,
modifiedAt: undefined,
};
}
}),
);
return {
files: files.sort((a, b) => {
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1;
}
return a.name.localeCompare(b.name);
}),
path: subPath || "/",
};
} catch (error) {
throw new InternalServerError(`Failed to list files: ${toMessage(error)}`);
}
};
const browseFilesystem = async (browsePath: string) => {
const normalizedPath = path.normalize(browsePath);
try {
const entries = await fs.readdir(normalizedPath, { withFileTypes: true });
const directories = await Promise.all(
entries
.filter((entry) => entry.isDirectory())
.map(async (entry) => {
const fullPath = path.join(normalizedPath, entry.name);
try {
const stats = await fs.stat(fullPath);
return {
name: entry.name,
path: fullPath,
type: "directory" as const,
size: undefined,
modifiedAt: stats.mtimeMs,
};
} catch {
return {
name: entry.name,
path: fullPath,
type: "directory" as const,
size: undefined,
modifiedAt: undefined,
};
}
}),
);
return {
directories: directories.sort((a, b) => a.name.localeCompare(b.name)),
path: normalizedPath,
};
} catch (error) {
throw new InternalServerError(`Failed to browse filesystem: ${toMessage(error)}`);
}
};
export const volumeService = {
listVolumes,
createVolume,
mountVolume,
deleteVolume,
getVolume,
updateVolume,
testConnection,
unmountVolume,
checkHealth,
getContainersUsingVolume,
listFiles,
browseFilesystem,
};