+12








fcd7c979df
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com> Co-authored-by: arturict <arturict@users.noreply.github.com> Co-authored-by: Thomas <thomas@ajnart.fr> Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com> Co-authored-by: Danura30082 <danura30082+github@gmail.com> Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com> Co-authored-by: ajnart <thomas@ajnart.fr> Co-authored-by: ajnart <thomas@ajnart.dev> Co-authored-by: chenjun <chenjun@foxmail.com> Co-authored-by: Wikibear <wikibear@users.noreply.github.com> Co-authored-by: Bergasha <bergasha@gmail.com> Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com> Co-authored-by: Lucas Araujo <lucas.lra@gmail.com> Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com> Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social> Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com> Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com> Co-authored-by: thrawnx <tight.bulb9384@fastmail.com> Co-authored-by: Chris <58918656+nordada@users.noreply.github.com> Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com> Co-authored-by: Yanis <naamaneyanis@gmail.com> Co-authored-by: Wikibear <me@sej.de> Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
303 lines
9.7 KiB
TypeScript
303 lines
9.7 KiB
TypeScript
import { TRPCError } from "@trpc/server";
|
|
import { observable } from "@trpc/server/observable";
|
|
import { z } from "zod/v4";
|
|
|
|
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 { 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);
|
|
|
|
const normalizeGetContainersInput = (input: { endpointIds?: string[] } | undefined) => {
|
|
if (!input?.endpointIds || input.endpointIds.length === 0) return {};
|
|
return input;
|
|
};
|
|
|
|
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({
|
|
mcp: {
|
|
enabled: true,
|
|
description:
|
|
"List Docker containers with their state, image, CPU/memory usage, and ports. Optionally filter by endpoint IDs; omitted or empty means all endpoints.",
|
|
},
|
|
})
|
|
.concat(dockerMiddleware())
|
|
.input(z.object({ endpointIds: z.array(z.string().min(1)).max(100).optional() }).optional())
|
|
.query(async ({ input }) => {
|
|
const innerHandler = dockerContainersRequestHandler.handler(normalizeGetContainersInput(input));
|
|
const result = await innerHandler.getDataAsync();
|
|
|
|
const { data, timestamp } = result;
|
|
|
|
return {
|
|
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: targets (endpointId and id pairs from docker_getContainers)",
|
|
},
|
|
})
|
|
.concat(dockerMiddleware())
|
|
.input(z.object({ targets: dockerContainerTargetsSchema }))
|
|
.mutation(async ({ input }) => {
|
|
return await performDockerContainerActionsAsync(input.targets, "start");
|
|
}),
|
|
stopAll: permissionRequiredProcedure
|
|
.requiresPermission("admin")
|
|
.meta({
|
|
mcp: {
|
|
enabled: true,
|
|
description: "Stop Docker containers. REQUIRED: targets (endpointId and id pairs from docker_getContainers)",
|
|
},
|
|
})
|
|
.concat(dockerMiddleware())
|
|
.input(z.object({ targets: dockerContainerTargetsSchema }))
|
|
.mutation(async ({ input }) => {
|
|
return await performDockerContainerActionsAsync(input.targets, "stop");
|
|
}),
|
|
restartAll: permissionRequiredProcedure
|
|
.requiresPermission("admin")
|
|
.meta({
|
|
mcp: {
|
|
enabled: true,
|
|
description: "Restart Docker containers. REQUIRED: targets (endpointId and id pairs from docker_getContainers)",
|
|
},
|
|
})
|
|
.concat(dockerMiddleware())
|
|
.input(z.object({ targets: dockerContainerTargetsSchema }))
|
|
.mutation(async ({ input }) => {
|
|
return await performDockerContainerActionsAsync(input.targets, "restart");
|
|
}),
|
|
removeAll: permissionRequiredProcedure
|
|
.requiresPermission("admin")
|
|
.meta({
|
|
mcp: {
|
|
enabled: true,
|
|
description:
|
|
"Remove/delete Docker containers. REQUIRED: targets (endpointId and id pairs from docker_getContainers)",
|
|
},
|
|
})
|
|
.concat(dockerMiddleware())
|
|
.input(z.object({ targets: dockerContainerTargetsSchema }))
|
|
.mutation(async ({ input }) => {
|
|
return await performDockerContainerActionsAsync(input.targets, "remove");
|
|
}),
|
|
logs: permissionRequiredProcedure
|
|
.requiresPermission("admin")
|
|
.meta({
|
|
mcp: {
|
|
enabled: true,
|
|
description:
|
|
"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({
|
|
...dockerContainerTargetSchema.shape,
|
|
tail: z.number().min(1).max(1000).optional(),
|
|
}),
|
|
)
|
|
.query(async ({ input }) => {
|
|
assertDockerEndpointCapability(input.endpointId, "logs");
|
|
const logs = await getContainerLogsAsync(input, input.tail ?? 200);
|
|
|
|
if (!logs) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "Container not found",
|
|
});
|
|
}
|
|
|
|
return {
|
|
logs,
|
|
};
|
|
}),
|
|
subscribeLogs: permissionRequiredProcedure
|
|
.requiresPermission("admin")
|
|
.concat(dockerMiddleware())
|
|
.input(
|
|
z.object({
|
|
...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;
|
|
|
|
const initializeStreamAsync = async () => {
|
|
try {
|
|
cleanupFn = await streamContainerLogsAsync(
|
|
input,
|
|
input.tail ?? 200,
|
|
(data) => {
|
|
if (isSubscribed) {
|
|
emit.next(data);
|
|
}
|
|
},
|
|
(err) => {
|
|
if (isSubscribed) {
|
|
emit.error(
|
|
new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: err.message,
|
|
}),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
} catch (err) {
|
|
if (isSubscribed) {
|
|
emit.error(
|
|
new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: err instanceof Error ? err.message : "Container not found",
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
void initializeStreamAsync();
|
|
|
|
return () => {
|
|
isSubscribed = false;
|
|
cleanupFn?.();
|
|
};
|
|
});
|
|
}),
|
|
});
|
|
|
|
type DockerContainerAction = "start" | "stop" | "restart" | "remove";
|
|
|
|
export const performDockerContainerActionsAsync = async (
|
|
targets: DockerContainerTarget[],
|
|
action: DockerContainerAction,
|
|
) => {
|
|
const results = 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",
|
|
};
|
|
}
|
|
}),
|
|
);
|
|
|
|
if (results.some(({ success }) => success)) {
|
|
dockerContainersRequestHandler.invalidateCache();
|
|
}
|
|
|
|
return results;
|
|
};
|
|
|
|
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;
|
|
state: ContainerState;
|
|
image: string;
|
|
ports: Port[] | undefined;
|
|
iconUrl: string | null;
|
|
cpuUsage: number;
|
|
memoryUsage: number;
|
|
}
|