feat!: release v2 (#6545)
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>
This commit is contained in:
3286 files changed
+284386
-139176
No files matched your search
@@ -0,0 +1,10 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
export const throwIfDockerOperationsFailed = (results: readonly PromiseSettledResult<unknown>[]) => {
|
||||
if (results.some((result) => result.status === "rejected")) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "One or more Docker container operations failed",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,290 @@
|
||||
import type { Database } from "@homarr/db";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import {
|
||||
buildDockerServiceUrlCandidates,
|
||||
getIntegrationDefaultPort,
|
||||
matchDockerService,
|
||||
normalizeDockerServiceUrl,
|
||||
} from "@homarr/definitions";
|
||||
import { dockerContainersRequestHandler } from "@homarr/request-handler/docker";
|
||||
|
||||
export type DockerReconciliationState = "newRecognized" | "newApp" | "represented" | "linked" | "moved";
|
||||
export type DockerReconciliationNextAction =
|
||||
| "setupIntegration"
|
||||
| "createApp"
|
||||
| "viewRepresentation"
|
||||
| "reviewIntegration";
|
||||
|
||||
export const getDockerReconciliationAsync = async (db: Database) => {
|
||||
const [{ data, timestamp }, integrations, apps] = await Promise.all([
|
||||
dockerContainersRequestHandler.handler({}).getDataAsync(),
|
||||
db.query.integrations.findMany({
|
||||
with: {
|
||||
items: { columns: { itemId: true } },
|
||||
secrets: { columns: { kind: true } },
|
||||
},
|
||||
}),
|
||||
db.query.apps.findMany(),
|
||||
]);
|
||||
const normalizedIntegrations = integrations.map((integration) => ({
|
||||
integration,
|
||||
normalizedUrl: normalizeDockerServiceUrl(integration.url),
|
||||
}));
|
||||
const normalizedApps = apps.map((app) => ({ app, normalizedUrl: normalizeDockerServiceUrl(app.href) }));
|
||||
const containerNameCounts = new Map<string, number>();
|
||||
for (const container of data.containers) {
|
||||
const name = normalizeName(container.name);
|
||||
containerNameCounts.set(name, (containerNameCounts.get(name) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const candidates = data.containers.map((container) => {
|
||||
const match = matchDockerService(container);
|
||||
const urlCandidates = buildDockerServiceUrlCandidates({
|
||||
containerName: container.name,
|
||||
endpointHost: container.host,
|
||||
ports: container.ports,
|
||||
preferredPort: match ? getIntegrationDefaultPort(match.kind) : undefined,
|
||||
});
|
||||
const candidateUrls = new Set(
|
||||
urlCandidates.map(({ url }) => normalizeDockerServiceUrl(url)).filter((url) => url !== null),
|
||||
);
|
||||
const uniqueName = containerNameCounts.get(normalizeName(container.name)) === 1;
|
||||
const sameKindIntegrations = match
|
||||
? normalizedIntegrations.filter(({ integration }) => integration.kind === match.kind)
|
||||
: [];
|
||||
const exactIntegrationMatches = sameKindIntegrations
|
||||
.filter(({ normalizedUrl }) => normalizedUrl !== null && candidateUrls.has(normalizedUrl))
|
||||
.map(({ integration }) => integration);
|
||||
const nameIntegrationMatches = uniqueName
|
||||
? sameKindIntegrations
|
||||
.filter(({ integration }) => normalizeName(integration.name) === normalizeName(container.name))
|
||||
.map(({ integration }) => integration)
|
||||
: [];
|
||||
const exactIntegration = exactIntegrationMatches.length === 1 ? exactIntegrationMatches[0] : undefined;
|
||||
const nameIntegration =
|
||||
exactIntegrationMatches.length === 0 && nameIntegrationMatches.length === 1
|
||||
? nameIntegrationMatches[0]
|
||||
: undefined;
|
||||
const integration = exactIntegration ?? nameIntegration;
|
||||
const exactAppMatches = normalizedApps
|
||||
.filter(({ normalizedUrl }) => normalizedUrl !== null && candidateUrls.has(normalizedUrl))
|
||||
.map(({ app }) => app);
|
||||
const nameAppMatches = uniqueName
|
||||
? apps.filter(({ name }) => normalizeName(name) === normalizeName(container.name))
|
||||
: [];
|
||||
const exactApp = exactAppMatches.length === 1 ? exactAppMatches[0] : undefined;
|
||||
const nameApp = exactAppMatches.length === 0 && nameAppMatches.length === 1 ? nameAppMatches[0] : undefined;
|
||||
const linkedApp = integration?.appId ? apps.find(({ id }) => id === integration.appId) : undefined;
|
||||
const app = linkedApp ?? exactApp ?? nameApp;
|
||||
const exactUrl = Boolean(exactIntegration || exactApp);
|
||||
const linked = Boolean(integration?.appId && linkedApp);
|
||||
const integrationMoved = Boolean(integration && !exactIntegration);
|
||||
const integrationAmbiguous =
|
||||
exactIntegrationMatches.length > 1 || (exactIntegrationMatches.length === 0 && nameIntegrationMatches.length > 1);
|
||||
const appAmbiguous =
|
||||
!linkedApp && (exactAppMatches.length > 1 || (exactAppMatches.length === 0 && nameAppMatches.length > 1));
|
||||
const ambiguous = integrationAmbiguous || appAmbiguous;
|
||||
const state = getReconciliationState({
|
||||
integrationMoved,
|
||||
linked,
|
||||
represented: Boolean(integration || app),
|
||||
recognized: Boolean(match),
|
||||
});
|
||||
const nextAction = getCandidateNextAction(state, integrationAmbiguous, appAmbiguous);
|
||||
|
||||
return {
|
||||
candidateKey: container.resourceId,
|
||||
endpointId: container.endpointId,
|
||||
endpointName: container.endpointName,
|
||||
nativeId: container.id,
|
||||
container,
|
||||
match,
|
||||
urlCandidates,
|
||||
state,
|
||||
nextAction,
|
||||
representation: {
|
||||
integration: integration
|
||||
? {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
appId: integration.appId,
|
||||
itemCount: integration.items.length,
|
||||
configuredSecretKinds: integration.secrets.map(({ kind }) => kind),
|
||||
}
|
||||
: null,
|
||||
app: app ? { id: app.id, name: app.name, href: app.href } : null,
|
||||
signals: {
|
||||
exactUrl,
|
||||
exactIntegrationUrl: Boolean(exactIntegration),
|
||||
exactAppUrl: Boolean(exactApp),
|
||||
nameMatch: Boolean(nameIntegration || nameApp),
|
||||
linked,
|
||||
ambiguous,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { candidates, endpoints: data.endpoints, timestamp };
|
||||
};
|
||||
|
||||
export const getDockerServiceHealthAsync = async (db: Database) => {
|
||||
const reconciliation = await getDockerReconciliationAsync(db);
|
||||
return {
|
||||
timestamp: reconciliation.timestamp,
|
||||
endpoints: reconciliation.endpoints.map((endpoint) => ({
|
||||
key: `endpoint:${endpoint.id}`,
|
||||
identity: { endpointId: endpoint.id, name: endpoint.name },
|
||||
layer: "docker" as const,
|
||||
status: endpoint.status,
|
||||
nextAction: endpoint.status === "unavailable" ? ("checkEndpoint" as const) : ("none" as const),
|
||||
})),
|
||||
services: reconciliation.candidates.map((candidate) => {
|
||||
const integration = candidate.representation.integration;
|
||||
const app = candidate.representation.app;
|
||||
const itemCount = integration?.itemCount;
|
||||
|
||||
return {
|
||||
key: candidate.candidateKey,
|
||||
identity: {
|
||||
endpointId: candidate.endpointId,
|
||||
nativeId: candidate.nativeId,
|
||||
name: candidate.container.name,
|
||||
integration,
|
||||
app,
|
||||
},
|
||||
layers: [
|
||||
{ layer: "docker" as const, status: "available" as const, nextAction: "none" as const },
|
||||
{
|
||||
layer: "integrationConfiguration" as const,
|
||||
status: integrationHealthStatus(candidate.state, candidate.match?.kind, Boolean(integration)),
|
||||
nextAction: getIntegrationConfigurationNextAction(candidate.state, candidate.nextAction),
|
||||
},
|
||||
{
|
||||
layer: "authentication" as const,
|
||||
status: "notObserved" as const,
|
||||
nextAction: getAuthenticationNextAction(
|
||||
Boolean(integration),
|
||||
Boolean(candidate.match),
|
||||
candidate.nextAction,
|
||||
),
|
||||
},
|
||||
{
|
||||
layer: "apiRequest" as const,
|
||||
status: "notObserved" as const,
|
||||
nextAction: integration ? ("openIntegrationDiagnostics" as const) : ("none" as const),
|
||||
},
|
||||
{
|
||||
layer: "appRepresentation" as const,
|
||||
status: app ? ("linked" as const) : ("missing" as const),
|
||||
nextAction: getAppRepresentationNextAction(
|
||||
Boolean(app),
|
||||
candidate.representation.signals.ambiguous,
|
||||
candidate.nextAction,
|
||||
),
|
||||
},
|
||||
{
|
||||
layer: "widgetConfiguration" as const,
|
||||
status: getWidgetConfigurationStatus(itemCount),
|
||||
nextAction: itemCount === 0 ? ("addWidget" as const) : ("none" as const),
|
||||
},
|
||||
{
|
||||
layer: "widgetQuery" as const,
|
||||
status: "notObserved" as const,
|
||||
nextAction: itemCount !== undefined && itemCount > 0 ? ("openBoard" as const) : ("none" as const),
|
||||
},
|
||||
],
|
||||
status: candidate.state,
|
||||
nextAction: candidate.nextAction,
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeName = (value: string) => value.trim().toLowerCase();
|
||||
|
||||
interface ReconciliationStateInput {
|
||||
integrationMoved: boolean;
|
||||
linked: boolean;
|
||||
represented: boolean;
|
||||
recognized: boolean;
|
||||
}
|
||||
|
||||
const getReconciliationState = ({
|
||||
integrationMoved,
|
||||
linked,
|
||||
represented,
|
||||
recognized,
|
||||
}: ReconciliationStateInput): DockerReconciliationState => {
|
||||
if (integrationMoved) return "moved";
|
||||
if (linked) return "linked";
|
||||
if (represented) return "represented";
|
||||
if (recognized) return "newRecognized";
|
||||
return "newApp";
|
||||
};
|
||||
|
||||
const getCandidateNextAction = (
|
||||
state: DockerReconciliationState,
|
||||
integrationAmbiguous: boolean,
|
||||
appAmbiguous: boolean,
|
||||
) => {
|
||||
if (integrationAmbiguous) return "reviewIntegration" as const;
|
||||
if (appAmbiguous) return "viewRepresentation" as const;
|
||||
return nextActionForState(state);
|
||||
};
|
||||
|
||||
const nextActionForState = (state: DockerReconciliationState): DockerReconciliationNextAction => {
|
||||
if (state === "newRecognized") return "setupIntegration";
|
||||
if (state === "newApp") return "createApp";
|
||||
if (state === "moved") return "reviewIntegration";
|
||||
return "viewRepresentation";
|
||||
};
|
||||
|
||||
const getIntegrationConfigurationNextAction = (
|
||||
state: DockerReconciliationState,
|
||||
nextAction: DockerReconciliationNextAction,
|
||||
) => {
|
||||
if (nextAction === "reviewIntegration") return "reviewIntegration" as const;
|
||||
if (state === "newRecognized") return "setupIntegration" as const;
|
||||
return "none" as const;
|
||||
};
|
||||
|
||||
const getAuthenticationNextAction = (
|
||||
hasIntegration: boolean,
|
||||
hasMatch: boolean,
|
||||
nextAction: DockerReconciliationNextAction,
|
||||
) => {
|
||||
if (hasIntegration) return "testConnection" as const;
|
||||
if (nextAction === "reviewIntegration") return "reviewIntegration" as const;
|
||||
if (hasMatch) return "setupIntegration" as const;
|
||||
return "none" as const;
|
||||
};
|
||||
|
||||
const getAppRepresentationNextAction = (
|
||||
hasApp: boolean,
|
||||
ambiguous: boolean,
|
||||
nextAction: DockerReconciliationNextAction,
|
||||
) => {
|
||||
if (hasApp) return "none" as const;
|
||||
if (ambiguous && nextAction === "viewRepresentation") return "viewRepresentation" as const;
|
||||
return "createApp" as const;
|
||||
};
|
||||
|
||||
const getWidgetConfigurationStatus = (itemCount: number | undefined) => {
|
||||
if (itemCount === undefined) return "notApplicable" as const;
|
||||
if (itemCount > 0) return "linked" as const;
|
||||
return "unused" as const;
|
||||
};
|
||||
|
||||
const integrationHealthStatus = (
|
||||
state: DockerReconciliationState,
|
||||
kind: IntegrationKind | undefined,
|
||||
hasIntegration: boolean,
|
||||
) => {
|
||||
if (!kind) return "notApplicable" as const;
|
||||
if (state === "moved") return "changed" as const;
|
||||
if (hasIntegration) return "configured" as const;
|
||||
return "missing" as const;
|
||||
};
|
||||
@@ -2,92 +2,140 @@ 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 { 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 all Docker containers with their state, image, CPU/memory usage, and ports",
|
||||
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())
|
||||
.query(async () => {
|
||||
const innerHandler = dockerContainersRequestHandler.handler({});
|
||||
.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 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 }) => {
|
||||
await Promise.allSettled(
|
||||
input.ids.map(async (id) => {
|
||||
const container = await getContainerOrThrowAsync(id);
|
||||
await container.start();
|
||||
}),
|
||||
);
|
||||
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 }) => {
|
||||
await Promise.allSettled(
|
||||
input.ids.map(async (id) => {
|
||||
const container = await getContainerOrThrowAsync(id);
|
||||
await container.stop();
|
||||
}),
|
||||
);
|
||||
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 }) => {
|
||||
await Promise.allSettled(
|
||||
input.ids.map(async (id) => {
|
||||
const container = await getContainerOrThrowAsync(id);
|
||||
await container.restart();
|
||||
}),
|
||||
);
|
||||
return await performDockerContainerActionsAsync(input.targets, "restart");
|
||||
}),
|
||||
removeAll: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
@@ -95,18 +143,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 }) => {
|
||||
await Promise.allSettled(
|
||||
input.ids.map(async (id) => {
|
||||
const container = await getContainerOrThrowAsync(id);
|
||||
await container.remove();
|
||||
}),
|
||||
);
|
||||
return await performDockerContainerActionsAsync(input.targets, "remove");
|
||||
}),
|
||||
logs: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
@@ -114,18 +157,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({
|
||||
@@ -143,11 +187,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;
|
||||
@@ -155,7 +200,7 @@ export const dockerRouter = createTRPCRouter({
|
||||
const initializeStreamAsync = async () => {
|
||||
try {
|
||||
cleanupFn = await streamContainerLogsAsync(
|
||||
input.id,
|
||||
input,
|
||||
input.tail ?? 200,
|
||||
(data) => {
|
||||
if (isSubscribed) {
|
||||
@@ -195,36 +240,56 @@ 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,
|
||||
) => {
|
||||
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",
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
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",
|
||||
});
|
||||
if (results.some(({ success }) => success)) {
|
||||
dockerContainersRequestHandler.invalidateCache();
|
||||
}
|
||||
|
||||
return foundContainer;
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user