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,
|
||||
}));
|
||||
|
||||
|
||||
Reference in new issue
Block a user