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

@@ -1,9 +1,18 @@
import type { ContainerStats } from "dockerode";
import { describe, expect, test, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
import { DockerSingleton } from "@homarr/docker";
import { calculateCpuUsage, calculateMemoryUsage } from "../docker";
import {
calculateCpuUsage,
calculateMemoryUsage,
clearCpuUsageCountersCacheForTesting,
dockerContainersRequestHandler,
getDockerEndpointsAsync,
getContainersWithStatsAsync,
hasDockerEndpointCapability,
} from "../docker";
vi.mock("@homarr/db", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
@@ -64,16 +73,42 @@ describe("calculateCpuUsage", () => {
const stats = createStats({
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 2000 }, system_cpu_usage: 10000 },
});
// (2000 / 10000) * 4 * 100 = 80
expect(calculateCpuUsage(stats)).toBe(80);
// (2000 / 10000) * 100 = 20
expect(calculateCpuUsage(stats)).toBe(20);
});
test("should handle fractional CPU usage", () => {
const stats = createStats({
cpu_stats: { online_cpus: 2, cpu_usage: { total_usage: 500 }, system_cpu_usage: 100000 },
});
// (500 / 100000) * 2 * 100 = 1
expect(calculateCpuUsage(stats)).toBe(1);
// (500 / 100000) * 100 = 0.5
expect(calculateCpuUsage(stats)).toBe(0.5);
});
test("should use previous poll counters when precpu_stats is zeroed (one-shot mode)", () => {
const stats = createStats({
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 3_000 }, system_cpu_usage: 20_000 },
// Docker's one-shot response contains precpu_stats with all-zero values
precpu_stats: { cpu_usage: { total_usage: 0 }, system_cpu_usage: 0 },
});
// Delta vs previous poll: cpu 3_000 - 1_000 = 2_000, system 20_000 - 10_000 = 10_000 → 20%
expect(calculateCpuUsage(stats, { totalUsage: 1_000, systemUsage: 10_000 })).toBe(20);
});
test("should prefer a valid precpu_stats baseline over previous poll counters", () => {
const stats = createStats({
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 3_000 }, system_cpu_usage: 20_000 },
precpu_stats: { cpu_usage: { total_usage: 2_500 }, system_cpu_usage: 15_000 },
});
// precpu deltas: cpu 500, system 5_000 → 10%; the previous counters must be ignored
expect(calculateCpuUsage(stats, { totalUsage: 1_000, systemUsage: 10_000 })).toBe(10);
});
test("should return 0 when current counters are lower than the previous poll (container restart)", () => {
const stats = createStats({
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 100 }, system_cpu_usage: 20_000 },
});
expect(calculateCpuUsage(stats, { totalUsage: 1_000, systemUsage: 10_000 })).toBe(0);
});
});
@@ -119,3 +154,194 @@ describe("calculateMemoryUsage", () => {
expect(calculateMemoryUsage(stats)).toBe(512);
});
});
describe("getContainersWithStatsAsync", () => {
beforeEach(() => {
clearCpuUsageCountersCacheForTesting();
});
test("computes current CPU usage from successive one-shot polls", async () => {
let totalUsage = 1_000_000;
let systemUsage = 10_000_000;
const dockerInstance = createDockerInstance("local", async () => [
{
Id: "busy",
Image: "immich:latest",
Labels: {},
Names: ["/immich"],
State: "running",
Ports: [],
},
]);
dockerInstance.instance.getContainer = (() => ({
stats: async () => ({
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: totalUsage }, system_cpu_usage: systemUsage },
memory_stats: { usage: 0 },
}),
})) as never;
vi.spyOn(DockerSingleton, "getInstances").mockReturnValue([dockerInstance] as never);
vi.spyOn(DockerSingleton, "findInstance").mockReturnValue(dockerInstance as never);
try {
// First poll has no baseline: falls back to the cumulative ratio (10%)
const first = await getContainersWithStatsAsync(50);
expect(first.containers).toEqual([expect.objectContaining({ id: "busy", cpuUsage: 10 })]);
// Next poll used 2 of 4 core-seconds → current usage 50%, not a lifetime average
totalUsage += 2_000_000;
systemUsage += 4_000_000;
const second = await getContainersWithStatsAsync(50);
expect(second.containers).toEqual([expect.objectContaining({ id: "busy", cpuUsage: 50 })]);
} finally {
vi.restoreAllMocks();
}
});
test("queries only selected Docker endpoints and leaves an empty selection as all", async () => {
const firstListContainers = vi.fn(async () => []);
const secondListContainers = vi.fn(async () => []);
vi.spyOn(DockerSingleton, "getInstances").mockReturnValue([
createDockerInstance("first", firstListContainers),
createDockerInstance("second", secondListContainers),
] as never);
vi.spyOn(DockerSingleton, "getInitializationFailures").mockReturnValue([]);
try {
const selectedResult = await getContainersWithStatsAsync(50, ["second"]);
expect(firstListContainers).not.toHaveBeenCalled();
expect(secondListContainers).toHaveBeenCalledOnce();
expect(selectedResult.endpoints.map(({ id }) => id)).toEqual(["second"]);
await getContainersWithStatsAsync(50, []);
expect(firstListContainers).toHaveBeenCalledOnce();
expect(secondListContainers).toHaveBeenCalledTimes(2);
} finally {
vi.restoreAllMocks();
}
});
test("marks a timed-out endpoint unavailable without blocking healthy endpoints", async () => {
vi.useFakeTimers();
try {
vi.spyOn(DockerSingleton, "getInstances").mockReturnValue([
createDockerInstance("stalled", () => new Promise(() => undefined)),
createDockerInstance("healthy", async () => []),
] as never);
const resultPromise = getContainersWithStatsAsync(50);
await vi.advanceTimersByTimeAsync(50);
const result = await resultPromise;
expect(result.endpoints).toEqual([
expect.objectContaining({ id: "stalled", status: "unavailable" }),
expect.objectContaining({ id: "healthy", status: "available" }),
]);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
});
test("marks an endpoint degraded and returns zeroed stats when its stats request times out", async () => {
vi.useFakeTimers();
try {
const dockerInstance = createDockerInstance("healthy", async () => [
{
Id: "container-1",
Image: "sonarr:latest",
Labels: {},
Names: ["/sonarr"],
State: "running",
Ports: [],
},
]);
let observedSignal: AbortSignal | undefined;
dockerInstance.instance.getContainer = (() => ({
stats: (options: { abortSignal?: AbortSignal }) => {
observedSignal = options.abortSignal;
return new Promise(() => undefined);
},
})) as never;
vi.spyOn(DockerSingleton, "getInstances").mockReturnValue([dockerInstance] as never);
vi.spyOn(DockerSingleton, "findInstance").mockReturnValue(dockerInstance as never);
const resultPromise = getContainersWithStatsAsync(50);
await vi.advanceTimersByTimeAsync(50);
const result = await resultPromise;
expect(observedSignal?.aborted).toBe(true);
expect(result.containers).toEqual([expect.objectContaining({ id: "container-1", cpuUsage: 0, memoryUsage: 0 })]);
expect(result.endpoints).toEqual([expect.objectContaining({ id: "healthy", status: "degraded" })]);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
});
test("includes endpoints that failed during TLS initialization", async () => {
vi.spyOn(DockerSingleton, "getInstances").mockReturnValue([]);
vi.spyOn(DockerSingleton, "getInitializationFailures").mockReturnValue([
{
host: "broken.example:2376",
descriptor: {
id: "broken-tls",
name: "Broken TLS",
kind: "docker",
transport: { type: "tls", host: "broken.example", port: 2376, caPath: "/missing/ca.pem" },
capabilities: ["inventory"],
scope: "admin",
source: "environment",
},
},
]);
const result = await getContainersWithStatsAsync(50);
expect(result.endpoints).toEqual([
expect.objectContaining({ id: "broken-tls", status: "unavailable", transport: "tls" }),
]);
vi.restoreAllMocks();
});
});
test("demo mode advertises and filters the same inventory-only endpoint", async () => {
const previousDemoMode = process.env.DEMO_MODE;
process.env.DEMO_MODE = "true";
try {
expect(getDockerEndpointsAsync()).toEqual([
expect.objectContaining({ id: "demo", name: "Demo Docker", capabilities: ["inventory"] }),
]);
const selected = await dockerContainersRequestHandler.handler({ endpointIds: ["demo"] }).getDataAsync();
expect(selected.data.endpoints).toEqual([expect.objectContaining({ id: "demo" })]);
expect(selected.data.containers.length).toBeGreaterThan(0);
const excluded = await dockerContainersRequestHandler.handler({ endpointIds: ["other"] }).getDataAsync();
expect(excluded.data).toEqual({ containers: [], endpoints: [] });
expect(hasDockerEndpointCapability("demo", "inventory")).toBe(true);
expect(hasDockerEndpointCapability("demo", "logs")).toBe(false);
expect(hasDockerEndpointCapability("other", "inventory")).toBe(false);
} finally {
dockerContainersRequestHandler.invalidateCache();
if (previousDemoMode === undefined) delete process.env.DEMO_MODE;
else process.env.DEMO_MODE = previousDemoMode;
}
});
const createDockerInstance = (endpointId: string, listContainers: () => Promise<unknown[]>) => ({
endpointId,
endpointName: endpointId,
host: endpointId,
descriptor: {
kind: "docker",
transport: { type: "socket" },
capabilities: ["inventory"],
source: "environment",
scope: "admin",
},
instance: { listContainers, getContainer: () => ({}) },
});
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { Response } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { getLatestMatchingReleaseAsync } from "../release-providers";
vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
vi.mock("@homarr/core/infrastructure/logs", () => ({ createLogger: () => ({ warn: vi.fn() }) }));
const mockedFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const limitMessage = "Only the first 1000 results are available";
const mockHistoryFailure = (failurePage: number, status: number, message: string) => {
const pages: number[] = [];
mockedFetch.mockImplementation(async (input) => {
let requestUrl = input.toString();
if (typeof input === "object" && "url" in input) requestUrl = String(input.url);
const url = new URL(requestUrl);
if (!url.pathname.endsWith("/releases")) {
return new Response(JSON.stringify({ created_at: "2020-01-01T00:00:00Z" }), {
headers: { "content-type": "application/json" },
});
}
const page = Number(url.searchParams.get("page") ?? 1);
pages.push(page);
if (page === failurePage) {
return new Response(JSON.stringify({ message }), { status, headers: { "content-type": "application/json" } });
}
const releases = Array.from({ length: 100 }, (_, index) => {
const offset = (page - 1) * 100 + index;
return {
tag_name: `v${1001 - offset}`,
published_at: new Date(Date.UTC(2026, 0, 1) - offset * 1000).toISOString(),
html_url: "https://example.com/release",
body: "",
prerelease: false,
};
});
url.searchParams.set("page", String(page + 1));
return new Response(JSON.stringify(releases), {
headers: { "content-type": "application/json", link: `<${url}>; rel="next"` },
});
});
return pages;
};
const request = (versionRegex?: string) =>
getLatestMatchingReleaseAsync({ id: "fixture", provider: "github", identifier: "owner/repo", versionRegex });
describe("GitHub release history limits", () => {
beforeEach(() => {
mockedFetch.mockReset();
});
test.each([
{ versionRegex: undefined, expected: "v1001" },
{ versionRegex: "^v2$", expected: "v2" },
])(
"retains matching release $expected when the next page reaches GitHub's cap",
async ({ versionRegex, expected }) => {
const pages = mockHistoryFailure(11, 422, limitMessage);
const result = await request(versionRegex);
expect(result.success).toBe(true);
if (!result.success) throw new Error(result.error.message);
expect(result.data.latestRelease).toBe(expected);
expect(pages).toEqual(Array.from({ length: 11 }, (_, index) => index + 1));
},
);
test.each([
{ page: 2, status: 422, message: "Validation Failed" },
{ page: 2, status: 401, message: "Bad credentials" },
{ page: 1, status: 422, message: limitMessage },
])("does not hide an API error on page $page: $message", async ({ page, status, message }) => {
mockHistoryFailure(page, status, message);
const result = await request();
expect(result.success).toBe(false);
if (result.success) throw new Error("Expected API failure");
expect(result.error.code).toBe("unexpected");
expect(result.error.message).toContain(message);
});
});
@@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({
createIntegrationAsync: vi.fn(),
}));
vi.mock("@homarr/integrations", () => ({
vi.mock("@homarr/integrations/factory", () => ({
createIntegrationAsync: mocks.createIntegrationAsync,
}));