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;
};
+130 -65
View File
@@ -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;