This commit is contained in:
1 parent
e3a713d2df
commit
e842177d9b
163 files changed
+7390
-1240
No files matched your search
@@ -2,19 +2,64 @@ import { TRPCError } from "@trpc/server";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { Container, ContainerState, Docker, Port } from "@homarr/docker";
|
||||
import type { ContainerState, DockerContainerTarget, Port } from "@homarr/docker";
|
||||
import { DockerSingleton } from "@homarr/docker";
|
||||
import {
|
||||
dockerContainersRequestHandler,
|
||||
findDockerContainerAsync,
|
||||
getContainerLogsAsync,
|
||||
getDockerEndpointsAsync,
|
||||
hasDockerEndpointCapability,
|
||||
streamContainerLogsAsync,
|
||||
} from "@homarr/request-handler/docker";
|
||||
|
||||
import { dockerMiddleware } from "../../middlewares/docker";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
||||
import { throwIfDockerOperationsFailed } from "./docker-operation-results";
|
||||
import { getDockerReconciliationAsync, getDockerServiceHealthAsync } from "./docker-reconciliation";
|
||||
|
||||
const dockerContainerTargetSchema = z.object({
|
||||
endpointId: z.string().min(1),
|
||||
id: z.string().min(1),
|
||||
});
|
||||
const dockerContainerTargetsSchema = z.array(dockerContainerTargetSchema).min(1).max(100);
|
||||
|
||||
export const dockerRouter = createTRPCRouter({
|
||||
reconcileServices: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Reconcile Docker container inventory with Homarr integrations and apps, including URL matches and recommended setup or repair actions. Requires admin permission.",
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.query(async ({ ctx }) => await getDockerReconciliationAsync(ctx.db)),
|
||||
getServiceHealth: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get Docker endpoint and discovered-service health projected across Docker, integration, app, and widget setup layers. Requires admin permission.",
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.query(async ({ ctx }) => await getDockerServiceHealthAsync(ctx.db)),
|
||||
refreshInventory: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Invalidate the cached Docker inventory so the next Docker query performs a fresh user-requested discovery. Requires admin permission.",
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.mutation(() => {
|
||||
DockerSingleton.reset();
|
||||
dockerContainersRequestHandler.invalidateCache();
|
||||
}),
|
||||
getContainers: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
@@ -31,67 +76,59 @@ export const dockerRouter = createTRPCRouter({
|
||||
const { data, timestamp } = result;
|
||||
|
||||
return {
|
||||
containers: data satisfies DockerContainer[],
|
||||
containers: data.containers satisfies DockerContainer[],
|
||||
endpoints: data.endpoints,
|
||||
timestamp,
|
||||
};
|
||||
}),
|
||||
getEndpoints: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description: "List configured Docker endpoints with their availability and capabilities",
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.query(async () => getDockerEndpointsAsync()),
|
||||
startAll: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description: "Start Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)",
|
||||
description: "Start Docker containers. REQUIRED: targets (endpointId and id pairs from docker_getContainers)",
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.input(z.object({ ids: z.array(z.string()) }))
|
||||
.input(z.object({ targets: dockerContainerTargetsSchema }))
|
||||
.mutation(async ({ input }) => {
|
||||
const results = await Promise.allSettled(
|
||||
input.ids.map(async (id) => {
|
||||
const container = await getContainerOrThrowAsync(id);
|
||||
await container.start();
|
||||
}),
|
||||
);
|
||||
throwIfDockerOperationsFailed(results);
|
||||
return await performDockerContainerActionsAsync(input.targets, "start");
|
||||
}),
|
||||
stopAll: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description: "Stop Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)",
|
||||
description: "Stop Docker containers. REQUIRED: targets (endpointId and id pairs from docker_getContainers)",
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.input(z.object({ ids: z.array(z.string()) }))
|
||||
.input(z.object({ targets: dockerContainerTargetsSchema }))
|
||||
.mutation(async ({ input }) => {
|
||||
const results = await Promise.allSettled(
|
||||
input.ids.map(async (id) => {
|
||||
const container = await getContainerOrThrowAsync(id);
|
||||
await container.stop();
|
||||
}),
|
||||
);
|
||||
throwIfDockerOperationsFailed(results);
|
||||
return await performDockerContainerActionsAsync(input.targets, "stop");
|
||||
}),
|
||||
restartAll: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Restart Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)",
|
||||
description: "Restart Docker containers. REQUIRED: targets (endpointId and id pairs from docker_getContainers)",
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.input(z.object({ ids: z.array(z.string()) }))
|
||||
.input(z.object({ targets: dockerContainerTargetsSchema }))
|
||||
.mutation(async ({ input }) => {
|
||||
const results = await Promise.allSettled(
|
||||
input.ids.map(async (id) => {
|
||||
const container = await getContainerOrThrowAsync(id);
|
||||
await container.restart();
|
||||
}),
|
||||
);
|
||||
throwIfDockerOperationsFailed(results);
|
||||
return await performDockerContainerActionsAsync(input.targets, "restart");
|
||||
}),
|
||||
removeAll: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
@@ -99,19 +136,13 @@ export const dockerRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Remove/delete Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)",
|
||||
"Remove/delete Docker containers. REQUIRED: targets (endpointId and id pairs from docker_getContainers)",
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.input(z.object({ ids: z.array(z.string()) }))
|
||||
.input(z.object({ targets: dockerContainerTargetsSchema }))
|
||||
.mutation(async ({ input }) => {
|
||||
const results = await Promise.allSettled(
|
||||
input.ids.map(async (id) => {
|
||||
const container = await getContainerOrThrowAsync(id);
|
||||
await container.remove();
|
||||
}),
|
||||
);
|
||||
throwIfDockerOperationsFailed(results);
|
||||
return await performDockerContainerActionsAsync(input.targets, "remove");
|
||||
}),
|
||||
logs: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
@@ -119,18 +150,19 @@ export const dockerRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Fetch logs from a Docker container. REQUIRED: id (container ID from docker_getContainers). OPTIONAL: tail (number 1-1000, default 200)",
|
||||
"Fetch logs from a Docker container. REQUIRED: endpointId and id from docker_getContainers. OPTIONAL: tail (number 1-1000, default 200)",
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
...dockerContainerTargetSchema.shape,
|
||||
tail: z.number().min(1).max(1000).optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const logs = await getContainerLogsAsync(input.id, input.tail ?? 200);
|
||||
assertDockerEndpointCapability(input.endpointId, "logs");
|
||||
const logs = await getContainerLogsAsync(input, input.tail ?? 200);
|
||||
|
||||
if (!logs) {
|
||||
throw new TRPCError({
|
||||
@@ -148,11 +180,12 @@ export const dockerRouter = createTRPCRouter({
|
||||
.concat(dockerMiddleware())
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
...dockerContainerTargetSchema.shape,
|
||||
tail: z.number().min(1).max(1000).optional(),
|
||||
}),
|
||||
)
|
||||
.subscription(({ input }) => {
|
||||
assertDockerEndpointCapability(input.endpointId, "logs");
|
||||
return observable<string>((emit) => {
|
||||
let cleanupFn: (() => void) | undefined;
|
||||
let isSubscribed = true;
|
||||
@@ -160,7 +193,7 @@ export const dockerRouter = createTRPCRouter({
|
||||
const initializeStreamAsync = async () => {
|
||||
try {
|
||||
cleanupFn = await streamContainerLogsAsync(
|
||||
input.id,
|
||||
input,
|
||||
input.tail ?? 200,
|
||||
(data) => {
|
||||
if (isSubscribed) {
|
||||
@@ -200,36 +233,49 @@ export const dockerRouter = createTRPCRouter({
|
||||
}),
|
||||
});
|
||||
|
||||
const getContainerOrDefaultAsync = async (instance: Docker, id: string) => {
|
||||
const container = instance.getContainer(id);
|
||||
type DockerContainerAction = "start" | "stop" | "restart" | "remove";
|
||||
|
||||
return await new Promise<Container | null>((resolve) => {
|
||||
container.inspect((err, data) => {
|
||||
if (err || !data) {
|
||||
resolve(null);
|
||||
} else {
|
||||
resolve(container);
|
||||
export const performDockerContainerActionsAsync = async (
|
||||
targets: DockerContainerTarget[],
|
||||
action: DockerContainerAction,
|
||||
) =>
|
||||
await Promise.all(
|
||||
targets.map(async (target) => {
|
||||
try {
|
||||
const capability = action === "remove" ? "remove" : "lifecycle";
|
||||
if (!hasDockerEndpointCapability(target.endpointId, capability)) {
|
||||
return { target, success: false as const, error: `Endpoint does not permit ${capability} actions` };
|
||||
}
|
||||
const container = await findDockerContainerAsync(target, capability);
|
||||
if (!container) return { target, success: false as const, error: "Container not found" };
|
||||
|
||||
if (action === "start") await container.start();
|
||||
else if (action === "stop") await container.stop();
|
||||
else if (action === "restart") await container.restart();
|
||||
else await container.remove();
|
||||
return { target, success: true as const };
|
||||
} catch (error) {
|
||||
return {
|
||||
target,
|
||||
success: false as const,
|
||||
error: error instanceof Error ? error.message : "Docker action failed",
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const getContainerOrThrowAsync = async (id: string) => {
|
||||
const dockerInstances = DockerSingleton.getInstances();
|
||||
const containers = await Promise.all(dockerInstances.map(({ instance }) => getContainerOrDefaultAsync(instance, id)));
|
||||
const foundContainer = containers.find((container) => container) ?? null;
|
||||
|
||||
if (!foundContainer) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Container not found",
|
||||
});
|
||||
}
|
||||
|
||||
return foundContainer;
|
||||
const assertDockerEndpointCapability = (
|
||||
endpointId: string,
|
||||
capability: "inventory" | "logs" | "lifecycle" | "remove",
|
||||
) => {
|
||||
if (hasDockerEndpointCapability(endpointId, capability)) return;
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: `Docker endpoint does not permit ${capability} access` });
|
||||
};
|
||||
|
||||
interface DockerContainer {
|
||||
endpointId: string;
|
||||
endpointName: string;
|
||||
resourceId: string;
|
||||
name: string;
|
||||
id: string;
|
||||
host: string;
|
||||
|
||||
Reference in new issue
Block a user