enhance release v2 experience

This commit is contained in:
ajnart committed 2026-08-14 02:18:46 +02:00
1 parent 9b4970583e
commit ef2ed6c602
152 files changed
+7236 -764

No files matched your search

@@ -0,0 +1,30 @@
import { describe, expect, test } from "vitest";
import { getTrackedFeatureProperties } from "./analytics-properties";
describe("getTrackedFeatureProperties", () => {
test("keeps setup telemetry anonymous and strips configuration-shaped properties", () => {
expect(
getTrackedFeatureProperties(
"setup:widget-completed",
{
entryPoint: "board",
outcome: "completed",
elapsedMs: 1200,
hasBoardContext: true,
userId: "supplied-user",
url: "https://private.example",
recordId: "secret-id",
},
"session-user",
),
).toEqual({ entryPoint: "board", outcome: "completed", elapsedMs: 1200, hasBoardContext: true });
});
test("preserves the existing identified contract for non-setup events", () => {
expect(getTrackedFeatureProperties("theme-changed", { theme: "dark" }, "user-1")).toEqual({
theme: "dark",
userId: "user-1",
});
});
});
@@ -0,0 +1,22 @@
const setupPropertyKeys = new Set([
"entryPoint",
"intent",
"outcome",
"elapsedMs",
"hasBoardContext",
"canResolveInline",
]);
export const getTrackedFeatureProperties = (
feature: string,
properties: Record<string, unknown> | undefined,
userId: string,
) => {
if (!feature.startsWith("setup:")) return { ...properties, userId };
return Object.fromEntries(
Object.entries(properties ?? {}).filter(
([key, value]) => setupPropertyKeys.has(key) && ["string", "number", "boolean"].includes(typeof value),
),
);
};
+6 -4
View File
@@ -7,6 +7,7 @@ import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc";
import { getTrackedFeatureProperties } from "./analytics-properties";
const getInstanceId = async (): Promise<string | null> => {
if (env.NO_EXTERNAL_CONNECTION) return null;
@@ -34,10 +35,11 @@ export const analyticsRouter = createTRPCRouter({
const instanceId = await getInstanceId();
if (!instanceId) return;
trackEvent(instanceId, `feature:${input.feature}`, {
...input.properties,
userId: ctx.session.user.id,
});
trackEvent(
instanceId,
`feature:${input.feature}`,
getTrackedFeatureProperties(input.feature, input.properties, ctx.session.user.id),
);
}),
heartbeat: protectedProcedure.mutation(async ({ ctx }) => {
+6 -4
View File
@@ -566,9 +566,11 @@ export const boardRouter = createTRPCRouter({
},
})
.input(boardCreateSchema)
.output(z.object({ boardId: z.string() }))
.output(z.object({ boardId: z.string(), name: z.string(), layoutId: z.string() }))
.mutation(async ({ ctx, input }) => {
const boardId = createId();
const mobileLayoutId = createId();
const baseLayoutId = createId();
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, ctx.session.user.id),
@@ -594,7 +596,7 @@ export const boardRouter = createTRPCRouter({
});
createBoardCollection.layouts.push(
{
id: createId(),
id: mobileLayoutId,
name: "Mobile",
columnCount: 3,
leftGutterColumnCount: 0,
@@ -604,7 +606,7 @@ export const boardRouter = createTRPCRouter({
boardId,
},
{
id: createId(),
id: baseLayoutId,
name: "Base",
columnCount: input.columnCount,
leftGutterColumnCount: 0,
@@ -621,7 +623,7 @@ export const boardRouter = createTRPCRouter({
await ctx.db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, ctx.session.user.id));
}
return { boardId };
return { boardId, name: input.name, layoutId: baseLayoutId };
}),
duplicateBoard: permissionRequiredProcedure
.requiresPermission("board-create")
@@ -0,0 +1,222 @@
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 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 ? integrations.filter(({ kind }) => kind === match.kind) : [];
const exactIntegrationMatches = sameKindIntegrations.filter(({ url }) => {
const normalizedUrl = normalizeDockerServiceUrl(url);
return normalizedUrl ? candidateUrls.has(normalizedUrl) : false;
});
const nameIntegrationMatches = uniqueName
? sameKindIntegrations.filter(({ name }) => normalizeName(name) === normalizeName(container.name))
: [];
const exactIntegration = exactIntegrationMatches.length === 1 ? exactIntegrationMatches[0] : undefined;
const nameIntegration = nameIntegrationMatches.length === 1 ? nameIntegrationMatches[0] : undefined;
const integration = exactIntegration ?? nameIntegration;
const exactAppMatches = apps.filter(({ href }) => {
const normalizedUrl = normalizeDockerServiceUrl(href);
return normalizedUrl ? candidateUrls.has(normalizedUrl) : false;
});
const nameAppMatches = uniqueName
? apps.filter(({ name }) => normalizeName(name) === normalizeName(container.name))
: [];
const exactApp = exactAppMatches.length === 1 ? exactAppMatches[0] : undefined;
const nameApp = 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 state: DockerReconciliationState = linked
? "linked"
: integration && !exactUrl
? "moved"
: integration || app
? "represented"
: match
? "newRecognized"
: "newApp";
return {
candidateKey: container.resourceId,
endpointId: container.endpointId,
endpointName: container.endpointName,
nativeId: container.id,
container,
match,
urlCandidates,
state,
nextAction: nextActionForState(state),
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,
nameMatch: Boolean(nameIntegration || nameApp),
linked,
ambiguous:
exactIntegrationMatches.length > 1 ||
nameIntegrationMatches.length > 1 ||
exactAppMatches.length > 1 ||
nameAppMatches.length > 1,
},
},
};
});
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) => ({
key: candidate.candidateKey,
identity: {
endpointId: candidate.endpointId,
nativeId: candidate.nativeId,
name: candidate.container.name,
integration: candidate.representation.integration,
app: candidate.representation.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(candidate.representation.integration),
),
nextAction:
candidate.state === "moved"
? ("reviewIntegration" as const)
: candidate.state === "newRecognized"
? ("setupIntegration" as const)
: ("none" as const),
},
{
layer: "authentication" as const,
status: "notObserved" as const,
nextAction: candidate.representation.integration
? ("testConnection" as const)
: candidate.match
? ("setupIntegration" as const)
: ("none" as const),
},
{
layer: "apiRequest" as const,
status: "notObserved" as const,
nextAction: candidate.representation.integration
? ("openIntegrationDiagnostics" as const)
: ("none" as const),
},
{
layer: "appRepresentation" as const,
status: candidate.representation.app ? ("linked" as const) : ("missing" as const),
nextAction: candidate.representation.app ? ("none" as const) : ("createApp" as const),
},
{
layer: "widgetConfiguration" as const,
status: candidate.representation.integration
? candidate.representation.integration.itemCount > 0
? ("linked" as const)
: ("unused" as const)
: ("notApplicable" as const),
nextAction:
candidate.representation.integration && candidate.representation.integration.itemCount === 0
? ("addWidget" as const)
: ("none" as const),
},
{
layer: "widgetQuery" as const,
status: "notObserved" as const,
nextAction:
candidate.representation.integration && candidate.representation.integration.itemCount > 0
? ("openBoard" as const)
: ("none" as const),
},
],
status: candidate.state,
nextAction: candidate.nextAction,
})),
};
};
const normalizeName = (value: string) => value.trim().toLowerCase();
const nextActionForState = (state: DockerReconciliationState): DockerReconciliationNextAction => {
if (state === "newRecognized") return "setupIntegration";
if (state === "newApp") return "createApp";
if (state === "moved") return "reviewIntegration";
return "viewRepresentation";
};
const integrationHealthStatus = (
state: DockerReconciliationState,
kind: IntegrationKind | undefined,
hasIntegration: boolean,
) => {
if (!kind) return "notApplicable" as const;
if (state === "moved") return "changed" as const;
return hasIntegration ? ("configured" as const) : ("missing" as const);
};
+75 -70
View File
@@ -2,19 +2,33 @@ 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 { DockerSingleton } from "@homarr/docker";
import type { ContainerState, DockerContainerTarget, Port } from "@homarr/docker";
import {
dockerContainersRequestHandler,
findDockerContainerAsync,
getContainerLogsAsync,
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),
});
export const dockerRouter = createTRPCRouter({
reconcileServices: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.query(async ({ ctx }) => await getDockerReconciliationAsync(ctx.db)),
getServiceHealth: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.query(async ({ ctx }) => await getDockerServiceHealthAsync(ctx.db)),
getContainers: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
@@ -31,7 +45,8 @@ export const dockerRouter = createTRPCRouter({
const { data, timestamp } = result;
return {
containers: data satisfies DockerContainer[],
containers: data.containers satisfies DockerContainer[],
endpoints: data.endpoints,
timestamp,
};
}),
@@ -40,58 +55,39 @@ export const dockerRouter = createTRPCRouter({
.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: z.array(dockerContainerTargetSchema) }))
.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: z.array(dockerContainerTargetSchema) }))
.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: z.array(dockerContainerTargetSchema) }))
.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 +95,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: z.array(dockerContainerTargetSchema) }))
.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 +109,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 +139,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 +152,7 @@ export const dockerRouter = createTRPCRouter({
const initializeStreamAsync = async () => {
try {
cleanupFn = await streamContainerLogsAsync(
input.id,
input,
input.tail ?? 200,
(data) => {
if (isSubscribed) {
@@ -200,36 +192,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;
@@ -368,6 +368,16 @@ export const integrationRouter = createTRPCRouter({
kind: input.kind,
url: input.url,
});
return {
integration: {
id: integrationId,
name: input.name,
kind: input.kind,
url: input.url,
},
appId,
};
}),
update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
@@ -0,0 +1,90 @@
import { KubeConfig } from "@kubernetes/client-node";
import { describe, expect, test, vi } from "vitest";
import { KubernetesClientRegistry, KubernetesContextNotFoundError } from "./kubernetes-client";
const createRegistry = () => {
const kubeConfig = new KubeConfig();
const contextNames = ["home", "remote", "metrics-degraded"];
kubeConfig.loadFromOptions({
clusters: contextNames.map((name) => ({ name, server: `https://${name}.example` })),
users: contextNames.map((name) => ({ name, token: `${name}-token` })),
contexts: contextNames.map((name) => ({ name, cluster: name, user: name })),
currentContext: "home",
});
return new KubernetesClientRegistry(kubeConfig);
};
describe("KubernetesClientRegistry", () => {
test("isolates duplicate resource names by context", async () => {
const registry = createRegistry();
const home = registry.getClient("home");
const remote = registry.getClient("remote");
vi.spyOn(home.coreApi, "listNamespace").mockResolvedValue({ items: [{ metadata: { name: "default" } }] } as never);
vi.spyOn(remote.coreApi, "listNamespace").mockResolvedValue({
items: [{ metadata: { name: "default" } }],
} as never);
const [homeNamespaces, remoteNamespaces] = await Promise.all([
home.coreApi.listNamespace(),
remote.coreApi.listNamespace(),
]);
expect(homeNamespaces.items[0]?.metadata?.name).toBe("default");
expect(remoteNamespaces.items[0]?.metadata?.name).toBe("default");
expect(home).not.toBe(remote);
expect(home.kubeConfig.getCurrentContext()).toBe("home");
expect(remote.kubeConfig.getCurrentContext()).toBe("remote");
});
test("lists unavailable and metrics-degraded contexts without failing healthy contexts", async () => {
const registry = createRegistry();
for (const contextId of ["home", "remote", "metrics-degraded"]) {
const client = registry.getClient(contextId);
vi.spyOn(client.versionApi, "getCode").mockResolvedValue({} as never);
vi.spyOn(client.metricsApi, "getNodeMetrics").mockResolvedValue({ items: [] } as never);
}
registry.getClient("remote").versionApi.getCode = vi.fn().mockRejectedValue(new Error("offline"));
registry.getClient("metrics-degraded").metricsApi.getNodeMetrics = vi
.fn()
.mockRejectedValue(new Error("no metrics"));
const result = await registry.getContextsAsync();
expect(result.defaultContextId).toBe("home");
expect(result.contexts).toEqual([
{ contextId: "home", name: "home", status: "available", metricsStatus: "available", isDefault: true },
{ contextId: "remote", name: "remote", status: "unavailable", metricsStatus: "unknown", isDefault: false },
{
contextId: "metrics-degraded",
name: "metrics-degraded",
status: "degraded",
metricsStatus: "unavailable",
isDefault: false,
},
]);
});
test("rejects unknown context IDs", () => {
expect(() => createRegistry().getClient("missing")).toThrow(KubernetesContextNotFoundError);
});
test("uses the first configured context when current-context is empty", async () => {
const kubeConfig = new KubeConfig();
kubeConfig.loadFromOptions({
clusters: [{ name: "first", server: "https://first.example" }],
users: [{ name: "first", token: "token" }],
contexts: [{ name: "first", cluster: "first", user: "first" }],
currentContext: "",
});
const registry = new KubernetesClientRegistry(kubeConfig);
const client = registry.getClient("first");
vi.spyOn(client.versionApi, "getCode").mockResolvedValue({} as never);
vi.spyOn(client.metricsApi, "getNodeMetrics").mockResolvedValue({ items: [] } as never);
const result = await registry.getContextsAsync();
expect(result.defaultContextId).toBe("first");
expect(result.contexts[0]).toEqual(expect.objectContaining({ contextId: "first", isDefault: true }));
});
});
@@ -3,69 +3,132 @@ import { CoreV1Api, KubeConfig, Metrics, NetworkingV1Api, VersionApi } from "@ku
import { env } from "../../env";
export interface KubernetesContextStatus {
contextId: string;
name: string;
status: "available" | "degraded" | "unavailable";
metricsStatus: "available" | "unavailable" | "unknown";
isDefault: boolean;
}
export class KubernetesClient {
private static instance: KubernetesClient | null = null;
private static registry: KubernetesClientRegistry | null = null;
public kubeConfig: KubeConfig;
public coreApi: CoreV1Api;
public networkingApi: NetworkingV1Api;
public metricsApi: Metrics;
public versionApi: VersionApi;
private constructor() {
this.kubeConfig = new KubeConfig();
if (process.env.NODE_ENV === "development") {
this.kubeConfig.loadFromDefault();
} else {
this.kubeConfig.loadFromCluster();
const currentCluster = this.kubeConfig.getCurrentCluster();
if (!currentCluster) throw new Error("No cluster configuration found");
const token = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8");
const caData = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", "utf8");
const clusterWithCA = {
...currentCluster,
name: `${currentCluster.name}-service-account`,
caData,
};
const serviceAccountUser = {
name: env.KUBERNETES_SERVICE_ACCOUNT_NAME ?? "default-sa",
token,
};
this.kubeConfig.clusters = [];
this.kubeConfig.users = [];
this.kubeConfig.addCluster(clusterWithCA);
this.kubeConfig.addUser(serviceAccountUser);
const currentContext = this.kubeConfig.getCurrentContext();
const originalContext = this.kubeConfig.getContextObject(currentContext);
if (!originalContext) throw new Error("No context found");
const updatedContext = {
...originalContext,
name: `${originalContext.name}-service-account`,
cluster: clusterWithCA.name,
user: serviceAccountUser.name,
};
this.kubeConfig.contexts = [];
this.kubeConfig.addContext(updatedContext);
this.kubeConfig.setCurrentContext(updatedContext.name);
}
this.coreApi = this.kubeConfig.makeApiClient(CoreV1Api);
this.networkingApi = this.kubeConfig.makeApiClient(NetworkingV1Api);
this.metricsApi = new Metrics(this.kubeConfig);
this.versionApi = this.kubeConfig.makeApiClient(VersionApi);
public constructor(kubeConfig: KubeConfig) {
this.kubeConfig = kubeConfig;
this.coreApi = kubeConfig.makeApiClient(CoreV1Api);
this.networkingApi = kubeConfig.makeApiClient(NetworkingV1Api);
this.metricsApi = new Metrics(kubeConfig);
this.versionApi = kubeConfig.makeApiClient(VersionApi);
}
public static getInstance(): KubernetesClient {
KubernetesClient.instance ??= new KubernetesClient();
return KubernetesClient.instance;
public static getInstance(contextId: string): KubernetesClient {
return KubernetesClient.getRegistry().getClient(contextId);
}
public static async getContextsAsync() {
return await KubernetesClient.getRegistry().getContextsAsync();
}
private static getRegistry() {
KubernetesClient.registry ??= new KubernetesClientRegistry(loadKubeConfig());
return KubernetesClient.registry;
}
}
export class KubernetesClientRegistry {
private readonly clients = new Map<string, KubernetesClient>();
private readonly defaultContextId: string;
public constructor(kubeConfig: KubeConfig) {
const defaultContextId = kubeConfig.getCurrentContext() || kubeConfig.contexts[0]?.name;
if (!defaultContextId) throw new Error("No Kubernetes context is configured");
this.defaultContextId = defaultContextId;
for (const context of kubeConfig.contexts) {
const contextConfig = new KubeConfig();
contextConfig.loadFromOptions({
clusters: kubeConfig.clusters,
users: kubeConfig.users,
contexts: kubeConfig.contexts,
currentContext: context.name,
});
this.clients.set(context.name, new KubernetesClient(contextConfig));
}
}
public getClient(contextId: string) {
const client = this.clients.get(contextId);
if (!client) throw new KubernetesContextNotFoundError(contextId);
return client;
}
public async getContextsAsync() {
const contexts = await Promise.all(
[...this.clients.entries()].map(async ([contextId, client]): Promise<KubernetesContextStatus> => {
const [api, metrics] = await Promise.allSettled([
client.versionApi.getCode(),
client.metricsApi.getNodeMetrics(),
]);
const status =
api.status === "rejected" ? "unavailable" : metrics.status === "rejected" ? "degraded" : "available";
return {
contextId,
name: contextId,
status,
metricsStatus:
api.status === "rejected" ? "unknown" : metrics.status === "rejected" ? "unavailable" : "available",
isDefault: contextId === this.defaultContextId,
};
}),
);
return { contexts, defaultContextId: this.defaultContextId };
}
}
export class KubernetesContextNotFoundError extends Error {
public constructor(contextId: string) {
super(`Kubernetes context '${contextId}' was not found`);
}
}
const loadKubeConfig = () => {
const kubeConfig = new KubeConfig();
if (process.env.NODE_ENV === "development" || process.env.KUBECONFIG) {
kubeConfig.loadFromDefault();
return kubeConfig;
}
kubeConfig.loadFromCluster();
const currentCluster = kubeConfig.getCurrentCluster();
if (!currentCluster) throw new Error("No cluster configuration found");
const token = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8");
const caData = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", "utf8");
const clusterWithCA = { ...currentCluster, name: `${currentCluster.name}-service-account`, caData };
const serviceAccountUser = { name: env.KUBERNETES_SERVICE_ACCOUNT_NAME ?? "default-sa", token };
const currentContext = kubeConfig.getContextObject(kubeConfig.getCurrentContext());
if (!currentContext) throw new Error("No context found");
const updatedContext = {
...currentContext,
name: `${currentContext.name}-service-account`,
cluster: clusterWithCA.name,
user: serviceAccountUser.name,
};
kubeConfig.loadFromOptions({
clusters: [clusterWithCA],
users: [serviceAccountUser],
contexts: [updatedContext],
currentContext: updatedContext.name,
});
return kubeConfig;
};
@@ -0,0 +1,17 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { KubernetesClient, KubernetesContextNotFoundError } from "./kubernetes-client";
export const kubernetesContextInput = z.object({ contextId: z.string().min(1) });
export const getKubernetesClient = (contextId: string) => {
try {
return KubernetesClient.getInstance(contextId);
} catch (error) {
if (error instanceof KubernetesContextNotFoundError) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
throw error;
}
};
@@ -0,0 +1,13 @@
import { describe, expect, test } from "vitest";
import { calculateResourcePercentage } from "./resource-percentage";
describe("calculateResourcePercentage", () => {
test("calculates a rounded percentage", () => {
expect(calculateResourcePercentage(1, 3)).toBe(33.33);
});
test("returns zero when no capacity is available", () => {
expect(calculateResourcePercentage(1, 0)).toBe(0);
});
});
@@ -0,0 +1,2 @@
export const calculateResourcePercentage = (used: number, available: number) =>
available > 0 ? Number(((used / available) * 100).toFixed(2)) : 0;
@@ -5,7 +5,8 @@ import type { ClusterResourceCount, KubernetesCluster } from "@homarr/definition
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
import { KubernetesClient } from "../kubernetes-client";
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
import { calculateResourcePercentage } from "../resource-percentage";
import { CpuResourceParser } from "../resource-parser/cpu-resource-parser";
import { MemoryResourceParser } from "../resource-parser/memory-resource-parser";
@@ -13,14 +14,17 @@ export const clusterRouter = createTRPCRouter({
getCluster: permissionRequiredProcedure
.requiresPermission("admin")
.concat(kubernetesMiddleware())
.query(async (): Promise<KubernetesCluster> => {
const { coreApi, metricsApi, versionApi, kubeConfig } = KubernetesClient.getInstance();
.input(kubernetesContextInput)
.query(async ({ input }): Promise<KubernetesCluster> => {
const { coreApi, metricsApi, versionApi, kubeConfig } = getKubernetesClient(input.contextId);
try {
const versionInfo = await versionApi.getCode();
const nodes = await coreApi.listNode();
const nodeMetricsClient = await metricsApi.getNodeMetrics();
const listPodForAllNamespaces = await coreApi.listPodForAllNamespaces();
const [versionInfo, nodes, listPodForAllNamespaces, nodeMetricsClient] = await Promise.all([
versionApi.getCode(),
coreApi.listNode(),
coreApi.listPodForAllNamespaces(),
metricsApi.getNodeMetrics().catch(() => null),
]);
let totalCPUCapacity = 0;
let totalCPUAllocatable = 0;
@@ -48,7 +52,7 @@ export const clusterRouter = createTRPCRouter({
totalMemoryAllocatable += memoryAllocatable;
const nodeName = node.metadata?.name;
const nodeMetric = nodeMetricsClient.items.find((metric) => metric.metadata.name === nodeName);
const nodeMetric = nodeMetricsClient?.items.find((metric) => metric.metadata.name === nodeName);
if (nodeMetric) {
const cpuUsage = cpuResourceParser.parse(nodeMetric.usage.cpu);
totalCPUUsage += cpuUsage;
@@ -61,13 +65,11 @@ export const clusterRouter = createTRPCRouter({
const reservedCPU = totalCPUCapacity - totalCPUAllocatable;
const reservedMemory = totalMemoryCapacity - totalMemoryAllocatable;
const reservedCPUPercentage = (reservedCPU / totalCPUCapacity) * 100;
const reservedMemoryPercentage = (reservedMemory / totalMemoryCapacity) * 100;
const usagePercentageAllocatable = (totalCPUUsage / totalCPUAllocatable) * 100;
const usagePercentageMemoryAllocatable = (totalMemoryUsage / totalMemoryAllocatable) * 100;
const usedPodsPercentage = (listPodForAllNamespaces.items.length / totalCapacityPods) * 100;
const reservedCPUPercentage = calculateResourcePercentage(reservedCPU, totalCPUCapacity);
const reservedMemoryPercentage = calculateResourcePercentage(reservedMemory, totalMemoryCapacity);
const usagePercentageAllocatable = calculateResourcePercentage(totalCPUUsage, totalCPUAllocatable);
const usagePercentageMemoryAllocatable = calculateResourcePercentage(totalMemoryUsage, totalMemoryAllocatable);
const usedPodsPercentage = calculateResourcePercentage(listPodForAllNamespaces.items.length, totalCapacityPods);
return {
name: kubeConfig.getCurrentContext(),
@@ -75,50 +77,59 @@ export const clusterRouter = createTRPCRouter({
kubernetesVersion: versionInfo.gitVersion,
architecture: versionInfo.platform,
nodeCount: nodes.items.length,
metricsAvailable: nodeMetricsClient !== null,
capacity: [
{
type: "CPU",
resourcesStats: [
{
percentageValue: Number(reservedCPUPercentage.toFixed(2)),
percentageValue: reservedCPUPercentage,
type: "Reserved",
capacityUnit: "Cores",
usedValue: Number(reservedCPU.toFixed(2)),
maxUsedValue: Number(totalCPUCapacity.toFixed(2)),
},
{
percentageValue: Number(usagePercentageAllocatable.toFixed(2)),
type: "Used",
capacityUnit: "Cores",
usedValue: Number(totalCPUUsage.toFixed(2)),
maxUsedValue: Number(totalCPUAllocatable.toFixed(2)),
},
...(nodeMetricsClient
? [
{
percentageValue: usagePercentageAllocatable,
type: "Used" as const,
capacityUnit: "Cores",
usedValue: Number(totalCPUUsage.toFixed(2)),
maxUsedValue: Number(totalCPUAllocatable.toFixed(2)),
},
]
: []),
],
},
{
type: "Memory",
resourcesStats: [
{
percentageValue: Number(reservedMemoryPercentage.toFixed(2)),
percentageValue: reservedMemoryPercentage,
type: "Reserved",
capacityUnit: "GiB",
usedValue: Number(reservedMemory.toFixed(2)),
maxUsedValue: Number(totalMemoryCapacity.toFixed(2)),
},
{
percentageValue: Number(usagePercentageMemoryAllocatable.toFixed(2)),
type: "Used",
capacityUnit: "GiB",
usedValue: Number(totalMemoryUsage.toFixed(2)),
maxUsedValue: Number(totalMemoryAllocatable.toFixed(2)),
},
...(nodeMetricsClient
? [
{
percentageValue: usagePercentageMemoryAllocatable,
type: "Used" as const,
capacityUnit: "GiB",
usedValue: Number(totalMemoryUsage.toFixed(2)),
maxUsedValue: Number(totalMemoryAllocatable.toFixed(2)),
},
]
: []),
],
},
{
type: "Pods",
resourcesStats: [
{
percentageValue: Number(usedPodsPercentage.toFixed(2)),
percentageValue: usedPodsPercentage,
type: "Used",
usedValue: listPodForAllNamespaces.items.length,
maxUsedValue: totalCapacityPods,
@@ -137,8 +148,10 @@ export const clusterRouter = createTRPCRouter({
}),
getClusterResourceCounts: permissionRequiredProcedure
.requiresPermission("admin")
.query(async (): Promise<ClusterResourceCount[]> => {
const { coreApi, networkingApi } = KubernetesClient.getInstance();
.concat(kubernetesMiddleware())
.input(kubernetesContextInput)
.query(async ({ input }): Promise<ClusterResourceCount[]> => {
const { coreApi, networkingApi } = getKubernetesClient(input.contextId);
try {
const [pods, ingresses, services, configMaps, namespaces, nodes, secrets, volumes] = await Promise.all([
@@ -4,14 +4,15 @@ import type { KubernetesBaseResource } from "@homarr/definitions";
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
import { KubernetesClient } from "../kubernetes-client";
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
export const configMapsRouter = createTRPCRouter({
getConfigMaps: permissionRequiredProcedure
.requiresPermission("admin")
.concat(kubernetesMiddleware())
.query(async (): Promise<KubernetesBaseResource[]> => {
const { coreApi } = KubernetesClient.getInstance();
.input(kubernetesContextInput)
.query(async ({ input }): Promise<KubernetesBaseResource[]> => {
const { coreApi } = getKubernetesClient(input.contextId);
try {
const configMaps = await coreApi.listConfigMapForAllNamespaces();
@@ -0,0 +1,10 @@
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
import { KubernetesClient } from "../kubernetes-client";
export const contextsRouter = createTRPCRouter({
getContexts: permissionRequiredProcedure
.requiresPermission("admin")
.concat(kubernetesMiddleware())
.query(async () => await KubernetesClient.getContextsAsync()),
});
@@ -5,14 +5,15 @@ import type { KubernetesIngress, KubernetesIngressPath, KubernetesIngressRuleAnd
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
import { KubernetesClient } from "../kubernetes-client";
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
export const ingressesRouter = createTRPCRouter({
getIngresses: permissionRequiredProcedure
.requiresPermission("admin")
.concat(kubernetesMiddleware())
.query(async (): Promise<KubernetesIngress[]> => {
const { networkingApi } = KubernetesClient.getInstance();
.input(kubernetesContextInput)
.query(async ({ input }): Promise<KubernetesIngress[]> => {
const { networkingApi } = getKubernetesClient(input.contextId);
try {
const ingresses = await networkingApi.listIngressForAllNamespaces();
@@ -3,6 +3,7 @@ import { lazy } from "@trpc/server";
import { createTRPCRouter } from "../../../trpc";
export const kubernetesRouter = createTRPCRouter({
contexts: lazy(() => import("./contexts").then((mod) => mod.contextsRouter)),
nodes: lazy(() => import("./nodes").then((mod) => mod.nodesRouter)),
cluster: lazy(() => import("./cluster").then((mod) => mod.clusterRouter)),
namespaces: lazy(() => import("./namespaces").then((mod) => mod.namespacesRouter)),
@@ -4,14 +4,15 @@ import type { KubernetesNamespace, KubernetesNamespaceState } from "@homarr/defi
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
import { KubernetesClient } from "../kubernetes-client";
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
export const namespacesRouter = createTRPCRouter({
getNamespaces: permissionRequiredProcedure
.requiresPermission("admin")
.concat(kubernetesMiddleware())
.query(async (): Promise<KubernetesNamespace[]> => {
const { coreApi } = KubernetesClient.getInstance();
.input(kubernetesContextInput)
.query(async ({ input }): Promise<KubernetesNamespace[]> => {
const { coreApi } = getKubernetesClient(input.contextId);
try {
const namespaces = await coreApi.listNamespace();
@@ -4,7 +4,8 @@ import type { KubernetesNode, KubernetesNodeState } from "@homarr/definitions";
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
import { KubernetesClient } from "../kubernetes-client";
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
import { calculateResourcePercentage } from "../resource-percentage";
import { CpuResourceParser } from "../resource-parser/cpu-resource-parser";
import { MemoryResourceParser } from "../resource-parser/memory-resource-parser";
@@ -12,12 +13,15 @@ export const nodesRouter = createTRPCRouter({
getNodes: permissionRequiredProcedure
.requiresPermission("admin")
.concat(kubernetesMiddleware())
.query(async (): Promise<KubernetesNode[]> => {
const { coreApi, metricsApi } = KubernetesClient.getInstance();
.input(kubernetesContextInput)
.query(async ({ input }): Promise<KubernetesNode[]> => {
const { coreApi, metricsApi } = getKubernetesClient(input.contextId);
try {
const nodes = await coreApi.listNode();
const nodeMetricsClient = await metricsApi.getNodeMetrics();
const [nodes, nodeMetricsClient] = await Promise.all([
coreApi.listNode(),
metricsApi.getNodeMetrics().catch(() => null),
]);
const cpuResourceParser = new CpuResourceParser();
const memoryResourceParser = new MemoryResourceParser();
@@ -34,20 +38,26 @@ export const nodesRouter = createTRPCRouter({
let cpuUsage = 0;
let memoryUsage = 0;
const nodeMetric = nodeMetricsClient.items.find((metric) => metric.metadata.name === name);
const nodeMetric = nodeMetricsClient?.items.find((metric) => metric.metadata.name === name);
if (nodeMetric) {
cpuUsage += cpuResourceParser.parse(nodeMetric.usage.cpu);
memoryUsage += memoryResourceParser.parse(nodeMetric.usage.memory);
}
const usagePercentageCPUAllocatable = (cpuUsage / cpuAllocatable) * 100;
const usagePercentageMemoryAllocatable = (memoryUsage / memoryAllocatable) * 100;
const usagePercentageCPUAllocatable = nodeMetric
? calculateResourcePercentage(cpuUsage, cpuAllocatable)
: null;
const usagePercentageMemoryAllocatable = nodeMetric
? calculateResourcePercentage(memoryUsage, memoryAllocatable)
: null;
return {
name,
status,
allocatableCpuPercentage: Number(usagePercentageCPUAllocatable.toFixed(0)),
allocatableRamPercentage: Number(usagePercentageMemoryAllocatable.toFixed(0)),
allocatableCpuPercentage:
usagePercentageCPUAllocatable === null ? null : Math.round(usagePercentageCPUAllocatable),
allocatableRamPercentage:
usagePercentageMemoryAllocatable === null ? null : Math.round(usagePercentageMemoryAllocatable),
podsCount: Number(node.status?.capacity?.pods),
operatingSystem: node.status?.nodeInfo?.operatingSystem,
architecture: node.status?.nodeInfo?.architecture,
@@ -7,7 +7,7 @@ import type { KubernetesPod } from "@homarr/definitions";
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
import { KubernetesClient } from "../kubernetes-client";
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
const logger = createLogger({ module: "podsRouter" });
@@ -15,8 +15,9 @@ export const podsRouter = createTRPCRouter({
getPods: permissionRequiredProcedure
.requiresPermission("admin")
.concat(kubernetesMiddleware())
.query(async (): Promise<KubernetesPod[]> => {
const { coreApi, kubeConfig } = KubernetesClient.getInstance();
.input(kubernetesContextInput)
.query(async ({ input }): Promise<KubernetesPod[]> => {
const { coreApi, kubeConfig } = getKubernetesClient(input.contextId);
try {
const podsResp = await coreApi.listPodForAllNamespaces();
@@ -4,14 +4,15 @@ import type { KubernetesSecret } from "@homarr/definitions";
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
import { KubernetesClient } from "../kubernetes-client";
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
export const secretsRouter = createTRPCRouter({
getSecrets: permissionRequiredProcedure
.requiresPermission("admin")
.concat(kubernetesMiddleware())
.query(async (): Promise<KubernetesSecret[]> => {
const { coreApi } = KubernetesClient.getInstance();
.input(kubernetesContextInput)
.query(async ({ input }): Promise<KubernetesSecret[]> => {
const { coreApi } = getKubernetesClient(input.contextId);
try {
const secrets = await coreApi.listSecretForAllNamespaces();
@@ -4,14 +4,15 @@ import type { KubernetesService } from "@homarr/definitions";
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
import { KubernetesClient } from "../kubernetes-client";
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
export const servicesRouter = createTRPCRouter({
getServices: permissionRequiredProcedure
.requiresPermission("admin")
.concat(kubernetesMiddleware())
.query(async (): Promise<KubernetesService[]> => {
const { coreApi } = KubernetesClient.getInstance();
.input(kubernetesContextInput)
.query(async ({ input }): Promise<KubernetesService[]> => {
const { coreApi } = getKubernetesClient(input.contextId);
try {
const services = await coreApi.listServiceForAllNamespaces();
@@ -4,14 +4,15 @@ import type { KubernetesVolume } from "@homarr/definitions";
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
import { KubernetesClient } from "../kubernetes-client";
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
export const volumesRouter = createTRPCRouter({
getVolumes: permissionRequiredProcedure
.requiresPermission("admin")
.concat(kubernetesMiddleware())
.query(async (): Promise<KubernetesVolume[]> => {
const { coreApi } = KubernetesClient.getInstance();
.input(kubernetesContextInput)
.query(async ({ input }): Promise<KubernetesVolume[]> => {
const { coreApi } = getKubernetesClient(input.contextId);
try {
const volumes = await coreApi.listPersistentVolumeClaimForAllNamespaces();
+6 -1
View File
@@ -636,7 +636,7 @@ describe("createBoard should create a new board", () => {
});
// Act
await caller.createBoard({ name: "newBoard", columnCount: 24, isPublic: true });
const result = await caller.createBoard({ name: "newBoard", columnCount: 24, isPublic: true });
// Assert
const dbBoard = await db.query.boards.findFirst({
@@ -663,6 +663,11 @@ describe("createBoard should create a new board", () => {
expect.objectContaining({ name: "Base", role: "base", columnCount: 24, breakpoint: 768 }),
]),
);
expect(result).toEqual({
boardId: dbBoard?.id,
name: "newBoard",
layoutId: dbBoard?.layouts.find((layout) => layout.role === "base")?.id,
});
});
test("should throw error when user has no board-create permission", async () => {
@@ -6,10 +6,44 @@ import { objectKeys } from "@homarr/common";
import type { Database } from "@homarr/db";
import type { GroupPermissionKey } from "@homarr/definitions";
import { getPermissionsWithChildren } from "@homarr/definitions";
import type { DockerEndpointStatus } from "@homarr/docker";
import type { RouterInputs } from "../../..";
import type { RouterInputs, RouterOutputs } from "../../..";
import { dockerRouter } from "../../docker/docker-router";
const findDockerContainerAsyncMock = vi.hoisted(() => vi.fn());
const hasDockerEndpointCapabilityMock = vi.hoisted(() => vi.fn(() => true));
const getDockerDataAsyncMock = vi.hoisted(() =>
vi.fn<
() => Promise<{
data: {
containers: RouterOutputs["docker"]["getContainers"]["containers"];
endpoints: DockerEndpointStatus[];
};
timestamp: Date;
}>
>(async () => ({
data: { containers: [], endpoints: [] },
timestamp: new Date(),
})),
);
const findIntegrationsAsyncMock = vi.fn(
async (): Promise<
{
id: string;
name: string;
kind: "sonarr";
url: string;
appId: string | null;
items?: { itemId: string }[];
secrets?: { kind: "apiKey" }[];
}[]
> => [],
);
const findAppsAsyncMock = vi.fn(
async (): Promise<{ id: string; name: string; href: string | null; iconUrl: string }[]> => [],
);
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
vi.mock("@homarr/docker", () => ({
@@ -30,6 +64,8 @@ vi.mock("@homarr/docker", () => ({
},
}));
vi.mock("@homarr/request-handler/docker", () => ({
findDockerContainerAsync: findDockerContainerAsyncMock,
hasDockerEndpointCapability: hasDockerEndpointCapabilityMock,
getContainerLogsAsync: async () => {
await Promise.resolve();
return "logs";
@@ -40,9 +76,7 @@ vi.mock("@homarr/request-handler/docker", () => ({
},
dockerContainersRequestHandler: {
handler: () => ({
getDataAsync: async () => {
return await Promise.resolve({ data: [], timestamp: new Date() });
},
getDataAsync: getDockerDataAsyncMock,
}),
},
}));
@@ -67,20 +101,43 @@ const procedureKeys = objectKeys(dockerRouter._def.procedures);
const validInputs: {
[key in (typeof procedureKeys)[number]]: RouterInputs["docker"][key];
} = {
reconcileServices: undefined,
getServiceHealth: undefined,
getContainers: undefined,
startAll: { ids: ["1"] },
stopAll: { ids: ["1"] },
restartAll: { ids: ["1"] },
removeAll: { ids: ["1"] },
logs: { id: "1", tail: 200 },
subscribeLogs: { id: "1", tail: 200 },
startAll: { targets: [{ endpointId: "one", id: "1" }] },
stopAll: { targets: [{ endpointId: "one", id: "1" }] },
restartAll: { targets: [{ endpointId: "one", id: "1" }] },
removeAll: { targets: [{ endpointId: "one", id: "1" }] },
logs: { endpointId: "one", id: "1", tail: 200 },
subscribeLogs: { endpointId: "one", id: "1", tail: 200 },
};
const database = {
query: {
integrations: { findMany: findIntegrationsAsyncMock },
apps: { findMany: findAppsAsyncMock },
},
} as unknown as Database;
const createDockerContainer = () => ({
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
restart: vi.fn().mockResolvedValue(undefined),
remove: vi.fn().mockResolvedValue(undefined),
});
const createAdminCaller = () =>
dockerRouter.createCaller({
db: database,
deviceType: undefined,
session: createSessionWithPermissions("admin"),
});
describe("All procedures should only be accessible for users with admin permission", () => {
test.each(procedureKeys)("Procedure %s should be accessible for users with admin permission", async (procedure) => {
// Arrange
const caller = dockerRouter.createCaller({
db: null as unknown as Database,
db: database,
deviceType: undefined,
session: createSessionWithPermissions("admin"),
});
@@ -122,3 +179,205 @@ describe("All procedures should only be accessible for users with admin permissi
await expect(act()).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED" }));
});
});
describe("Docker action targeting", () => {
test("enforces endpoint capabilities before running a lifecycle action", async () => {
hasDockerEndpointCapabilityMock.mockReturnValueOnce(false);
const result = await createAdminCaller().stopAll({ targets: [{ endpointId: "readonly", id: "one" }] });
expect(result).toEqual([
{
target: { endpointId: "readonly", id: "one" },
success: false,
error: "Endpoint does not permit lifecycle actions",
},
]);
expect(findDockerContainerAsyncMock).not.toHaveBeenCalled();
});
test("targets duplicate container IDs on their explicit endpoints", async () => {
const first = createDockerContainer();
const second = createDockerContainer();
findDockerContainerAsyncMock.mockImplementation(async ({ endpointId }) =>
endpointId === "first" ? first : second,
);
const result = await createAdminCaller().startAll({
targets: [
{ endpointId: "first", id: "duplicate" },
{ endpointId: "second", id: "duplicate" },
],
});
expect(first.start).toHaveBeenCalledOnce();
expect(second.start).toHaveBeenCalledOnce();
expect(result).toEqual([
{ target: { endpointId: "first", id: "duplicate" }, success: true },
{ target: { endpointId: "second", id: "duplicate" }, success: true },
]);
});
test("reports partial failures per target", async () => {
const healthy = createDockerContainer();
const failing = createDockerContainer();
failing.restart.mockRejectedValue(new Error("proxy denied restart"));
findDockerContainerAsyncMock.mockImplementation(async ({ endpointId }) =>
endpointId === "healthy" ? healthy : failing,
);
const result = await createAdminCaller().restartAll({
targets: [
{ endpointId: "healthy", id: "one" },
{ endpointId: "failing", id: "two" },
],
});
expect(result).toEqual([
{ target: { endpointId: "healthy", id: "one" }, success: true },
{
target: { endpointId: "failing", id: "two" },
success: false,
error: "proxy denied restart",
},
]);
});
});
test("distinguishes unavailable endpoints from an empty inventory", async () => {
getDockerDataAsyncMock.mockResolvedValueOnce({
data: {
containers: [],
endpoints: [{ id: "remote", name: "Remote Docker", status: "unavailable" }],
},
timestamp: new Date(),
});
const result = await createAdminCaller().getContainers();
expect(result.containers).toEqual([]);
expect(result.endpoints).toEqual([{ id: "remote", name: "Remote Docker", status: "unavailable" }]);
});
test("keeps multiple recognized services of the same integration kind", async () => {
getDockerDataAsyncMock.mockResolvedValueOnce({
data: {
containers: [
createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr-home", publicPort: 8989 }),
createInventoryContainer({ endpointId: "remote", id: "two", name: "sonarr-remote", publicPort: 18989 }),
],
endpoints: [
{ id: "home", name: "Home", status: "available" },
{ id: "remote", name: "Remote", status: "available" },
] satisfies DockerEndpointStatus[],
},
timestamp: new Date(),
});
const result = await createAdminCaller().reconcileServices();
expect(result.candidates).toHaveLength(2);
expect(result.candidates.map(({ match }) => match?.kind)).toEqual(["sonarr", "sonarr"]);
expect(result.candidates.map(({ candidateKey }) => candidateKey)).toEqual(["home:one", "remote:two"]);
});
test("preserves unavailable endpoints beside available service candidates", async () => {
getDockerDataAsyncMock.mockResolvedValueOnce({
data: {
containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })],
endpoints: [
{ id: "home", name: "Home", status: "available" },
{ id: "remote", name: "Remote", status: "unavailable" },
] satisfies DockerEndpointStatus[],
},
timestamp: new Date(),
});
const result = await createAdminCaller().reconcileServices();
expect(result.candidates).toHaveLength(1);
expect(result.endpoints).toContainEqual({ id: "remote", name: "Remote", status: "unavailable" });
});
test("does not adopt an ambiguous existing integration", async () => {
getDockerDataAsyncMock.mockResolvedValueOnce({
data: {
containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })],
endpoints: [{ id: "home", name: "Home", status: "available" }],
},
timestamp: new Date(),
});
findIntegrationsAsyncMock.mockResolvedValueOnce([
{ id: "first", name: "Sonarr one", kind: "sonarr", url: "http://home.example:8989", appId: null },
{ id: "second", name: "Sonarr two", kind: "sonarr", url: "http://home.example:8989", appId: null },
]);
const result = await createAdminCaller().reconcileServices();
expect(result.candidates[0]?.representation.integration).toBeNull();
expect(result.candidates[0]?.representation.signals.ambiguous).toBe(true);
expect(result.candidates[0]?.state).toBe("newRecognized");
});
test("projects persisted service layers without inventing runtime health", async () => {
getDockerDataAsyncMock.mockResolvedValueOnce({
data: {
containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })],
endpoints: [{ id: "home", name: "Home", status: "available" }],
},
timestamp: new Date(),
});
findIntegrationsAsyncMock.mockResolvedValueOnce([
{
id: "integration",
name: "Sonarr",
kind: "sonarr",
url: "http://home.example:8989",
appId: "app",
items: [{ itemId: "widget" }],
secrets: [{ kind: "apiKey" }],
},
]);
findAppsAsyncMock.mockResolvedValueOnce([
{ id: "app", name: "Sonarr", href: "http://home.example:8989", iconUrl: "" },
]);
const result = await createAdminCaller().getServiceHealth();
expect(result.services[0]?.layers).toEqual(
expect.arrayContaining([
{ layer: "docker", status: "available", nextAction: "none" },
{ layer: "integrationConfiguration", status: "configured", nextAction: "none" },
{ layer: "authentication", status: "notObserved", nextAction: "testConnection" },
{ layer: "apiRequest", status: "notObserved", nextAction: "openIntegrationDiagnostics" },
{ layer: "appRepresentation", status: "linked", nextAction: "none" },
{ layer: "widgetConfiguration", status: "linked", nextAction: "none" },
{ layer: "widgetQuery", status: "notObserved", nextAction: "openBoard" },
]),
);
});
const createInventoryContainer = ({
endpointId,
id,
name,
publicPort,
}: {
endpointId: string;
id: string;
name: string;
publicPort: number;
}) => ({
id,
endpointId,
endpointName: endpointId,
resourceId: `${endpointId}:${id}`,
name,
host: `${endpointId}.example:2375`,
state: "running" as const,
image: "lscr.io/linuxserver/sonarr:latest",
iconUrl: null,
cpuUsage: 0,
memoryUsage: 0,
ports: [{ IP: "0.0.0.0", PrivatePort: 8989, PublicPort: publicPort, Type: "tcp" }],
});
@@ -312,7 +312,7 @@ describe("create should create a new integration", () => {
const fakeNow = new Date("2023-07-01T00:00:00Z");
vi.useFakeTimers();
vi.setSystemTime(fakeNow);
await caller.create(input);
const result = await caller.create(input);
vi.useRealTimers();
const dbIntegration = await db.query.integrations.findFirst();
@@ -327,6 +327,15 @@ describe("create should create a new integration", () => {
expect(dbSecret!.kind).toBe(input.secrets[0]!.kind);
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(dbSecret!.updatedAt).toEqual(fakeNow);
expect(result).toEqual({
integration: {
id: dbIntegration!.id,
name: input.name,
kind: input.kind,
url: input.url,
},
appId: null,
});
});
test("with create integration access should not create a search engine for media request search integrations", async () => {
@@ -347,7 +356,7 @@ describe("create should create a new integration", () => {
const fakeNow = new Date("2023-07-01T00:00:00Z");
vi.useFakeTimers();
vi.setSystemTime(fakeNow);
await caller.create(input);
const result = await caller.create(input);
vi.useRealTimers();
const dbIntegration = await db.query.integrations.findFirst();
@@ -363,6 +372,7 @@ describe("create should create a new integration", () => {
expect(dbSecret!.kind).toBe(input.secrets[0]!.kind);
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(dbSecret!.updatedAt).toEqual(fakeNow);
expect(result).toMatchObject({ appId: dbIntegration!.appId, integration: { id: dbIntegration!.id } });
expect(dbSearchEngine).toBeUndefined();
});