mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: make each plugin an npm workspace built by a termix-plugin CLI
This commit is contained in:
1 parent
4758016f41
commit
5796186a1c
448 files changed
+3087
-2003
No files matched your search
@@ -1,92 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
deriveEnabledWidgets,
|
||||
defaultLayoutFromWidgets,
|
||||
isMetricCardId,
|
||||
isManagerCardId,
|
||||
METRIC_CARD_IDS,
|
||||
} from "../../../types/host-metrics.js";
|
||||
|
||||
describe("deriveEnabledWidgets", () => {
|
||||
it("returns only metric ids, in canonical order, deduped", () => {
|
||||
const slots = [
|
||||
{ id: "firewall" },
|
||||
{ id: "cpu" },
|
||||
{ id: "cpu" },
|
||||
{ id: "memory" },
|
||||
];
|
||||
expect(deriveEnabledWidgets(slots)).toEqual(["cpu", "memory", "firewall"]);
|
||||
});
|
||||
|
||||
it("excludes manager card ids (mobile contract: never leak managers)", () => {
|
||||
const slots = [
|
||||
{ id: "cpu" },
|
||||
{ id: "service_manager" },
|
||||
{ id: "log_viewer" },
|
||||
{ id: "disk" },
|
||||
];
|
||||
const out = deriveEnabledWidgets(slots);
|
||||
expect(out).toEqual(["cpu", "disk"]);
|
||||
for (const id of out) expect(isMetricCardId(id)).toBe(true);
|
||||
});
|
||||
|
||||
it("output is always a subset of the 10 known WidgetTypes", () => {
|
||||
const slots = METRIC_CARD_IDS.map((id) => ({ id })).concat([
|
||||
{ id: "user_manager" } as { id: (typeof METRIC_CARD_IDS)[number] },
|
||||
{ id: "bogus" } as { id: (typeof METRIC_CARD_IDS)[number] },
|
||||
]);
|
||||
const out = deriveEnabledWidgets(slots);
|
||||
expect(out).toEqual(METRIC_CARD_IDS);
|
||||
expect(out.length).toBe(METRIC_CARD_IDS.length);
|
||||
});
|
||||
|
||||
it("empty slots -> empty widgets", () => {
|
||||
expect(deriveEnabledWidgets([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultLayoutFromWidgets", () => {
|
||||
it("builds slots in canonical order with dense ordering", () => {
|
||||
const layout = defaultLayoutFromWidgets(["disk", "cpu", "memory"]);
|
||||
expect(layout.slots.map((s) => s.id)).toEqual(["cpu", "memory", "disk"]);
|
||||
expect(layout.slots.map((s) => s.order)).toEqual([0, 1, 2]);
|
||||
expect(layout.columns).toBe(3);
|
||||
});
|
||||
|
||||
it("ignores unknown widget ids", () => {
|
||||
const layout = defaultLayoutFromWidgets(["cpu", "nope", "service_manager"]);
|
||||
expect(layout.slots.map((s) => s.id)).toEqual(["cpu"]);
|
||||
});
|
||||
|
||||
it("assigns valid colSpans (1..3)", () => {
|
||||
const layout = defaultLayoutFromWidgets([...METRIC_CARD_IDS]);
|
||||
for (const s of layout.slots) {
|
||||
expect([1, 2, 3]).toContain(s.colSpan);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the opt-in gpu card when deriving enabledWidgets", () => {
|
||||
expect(deriveEnabledWidgets([{ id: "gpu" }, { id: "cpu" }])).toEqual([
|
||||
"cpu",
|
||||
"gpu",
|
||||
]);
|
||||
});
|
||||
|
||||
it("round-trips: deriveEnabledWidgets(defaultLayout) === input order", () => {
|
||||
const layout = defaultLayoutFromWidgets(["ports", "cpu", "firewall"]);
|
||||
expect(deriveEnabledWidgets(layout.slots)).toEqual([
|
||||
"cpu",
|
||||
"ports",
|
||||
"firewall",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("card id guards", () => {
|
||||
it("classifies metric vs manager ids", () => {
|
||||
expect(isMetricCardId("cpu")).toBe(true);
|
||||
expect(isMetricCardId("service_manager")).toBe(false);
|
||||
expect(isManagerCardId("service_manager")).toBe(true);
|
||||
expect(isManagerCardId("cpu")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
toFixedNum,
|
||||
kibToGiB,
|
||||
encodePowerShellCommand,
|
||||
} from "../../../hosts/metrics-shared/common-utils.js";
|
||||
|
||||
describe("toFixedNum", () => {
|
||||
it("rounds to the requested digit count", () => {
|
||||
expect(toFixedNum(3.14159, 2)).toBe(3.14);
|
||||
expect(toFixedNum(3.14159, 0)).toBe(3);
|
||||
expect(toFixedNum(2.5, 0)).toBe(3);
|
||||
});
|
||||
|
||||
it("defaults to 2 digits", () => {
|
||||
expect(toFixedNum(1.23456)).toBe(1.23);
|
||||
});
|
||||
|
||||
it("returns null for non-finite or non-number input", () => {
|
||||
expect(toFixedNum(null)).toBeNull();
|
||||
expect(toFixedNum(undefined)).toBeNull();
|
||||
expect(toFixedNum(NaN)).toBeNull();
|
||||
expect(toFixedNum(Infinity)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("kibToGiB", () => {
|
||||
it("converts kibibytes to gibibytes", () => {
|
||||
expect(kibToGiB(1024 * 1024)).toBe(1);
|
||||
expect(kibToGiB(0)).toBe(0);
|
||||
expect(kibToGiB(2 * 1024 * 1024)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encodePowerShellCommand", () => {
|
||||
it("UTF-16LE base64 encodes the script, decodable back to the source", () => {
|
||||
const script = "Write-Output 'hello'";
|
||||
const encoded = encodePowerShellCommand(script);
|
||||
expect(Buffer.from(encoded, "base64").toString("utf16le")).toBe(script);
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock("../../../hosts/metrics-shared/common-utils.js", () => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
execElevated,
|
||||
ElevationError,
|
||||
} from "../../../hosts/metrics-shared/exec-elevated.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
const fakeClient = {} as Client;
|
||||
|
||||
function result(stdout: string, stderr = "", code: number | null = 0) {
|
||||
return { stdout, stderr, code };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execCommand.mockReset();
|
||||
});
|
||||
|
||||
describe("execElevated (no force)", () => {
|
||||
it("returns the direct result when the command succeeds unprivileged", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("hello", "", 0));
|
||||
const r = await execElevated(fakeClient, "echo hello", "pw");
|
||||
expect(r.usedSudo).toBe(false);
|
||||
expect(r.stdout).toBe("hello");
|
||||
expect(execCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT escalate when stdout merely contains scary words", async () => {
|
||||
// A non-zero exit whose OUTPUT contains 'permission denied' but stderr does
|
||||
// not should be surfaced as-is, never retried under sudo.
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result("log line: permission denied for user foo", "", 1),
|
||||
);
|
||||
const r = await execElevated(fakeClient, "grep denied /tmp/app.log", "pw");
|
||||
expect(r.usedSudo).toBe(false);
|
||||
expect(execCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("escalates when stderr indicates a permission problem", async () => {
|
||||
execCommand
|
||||
.mockResolvedValueOnce(result("", "Permission denied", 1))
|
||||
.mockResolvedValueOnce(result("__TX_SUDO_OK__\nelevated output", "", 0));
|
||||
const r = await execElevated(fakeClient, "cat /etc/shadow", "pw");
|
||||
expect(r.usedSudo).toBe(true);
|
||||
expect(r.stdout).toBe("elevated output");
|
||||
expect(execCommand).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("throws SUDO_REQUIRED when elevation is needed but no password is set", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("", "Permission denied", 1));
|
||||
await expect(
|
||||
execElevated(fakeClient, "cat /etc/shadow", undefined),
|
||||
).rejects.toMatchObject({ code: "SUDO_REQUIRED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("execElevated (forced)", () => {
|
||||
it("strips the success marker from stdout", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result("__TX_SUDO_OK__\nthe real output\n", "", 0),
|
||||
);
|
||||
const r = await execElevated(fakeClient, "id", "pw", { forceSudo: true });
|
||||
expect(r.stdout).toBe("the real output\n");
|
||||
expect(r.usedSudo).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT throw when command output contains 'incorrect password' but sudo authenticated", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result(
|
||||
"__TX_SUDO_OK__\nUser entered an incorrect password earlier",
|
||||
"",
|
||||
0,
|
||||
),
|
||||
);
|
||||
const r = await execElevated(fakeClient, "tail /var/log/auth.log", "pw", {
|
||||
forceSudo: true,
|
||||
});
|
||||
expect(r.usedSudo).toBe(true);
|
||||
expect(r.stdout).toContain("incorrect password");
|
||||
});
|
||||
|
||||
it("throws SUDO_FAILED on a real wrong-password (no marker, sudo stderr)", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result("", "sudo: 1 incorrect password attempt", 1),
|
||||
);
|
||||
await expect(
|
||||
execElevated(fakeClient, "id", "wrong", { forceSudo: true }),
|
||||
).rejects.toMatchObject({ code: "SUDO_FAILED" });
|
||||
});
|
||||
|
||||
it("throws NOT_SUDOER when the user is not in sudoers", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result("", "deploy is not in the sudoers file.", 1),
|
||||
);
|
||||
await expect(
|
||||
execElevated(fakeClient, "id", "pw", { forceSudo: true }),
|
||||
).rejects.toMatchObject({ code: "NOT_SUDOER" });
|
||||
});
|
||||
|
||||
it("throws SUDO_REQUIRED when forced without a password", async () => {
|
||||
await expect(
|
||||
execElevated(fakeClient, "id", undefined, { forceSudo: true }),
|
||||
).rejects.toBeInstanceOf(ElevationError);
|
||||
expect(execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isHostKeyVerificationError,
|
||||
statusAfterAuthentication,
|
||||
statusAfterReachabilityCheck,
|
||||
} from "../../../hosts/metrics-shared/host-status.js";
|
||||
|
||||
describe("isHostKeyVerificationError", () => {
|
||||
it.each([
|
||||
"Host denied (verification failed)",
|
||||
"Host key changed - please connect via Terminal to verify the new key",
|
||||
])("classifies %s", (message) => {
|
||||
expect(isHostKeyVerificationError(new Error(message))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not classify ordinary authentication failures", () => {
|
||||
expect(isHostKeyVerificationError(new Error("Permission denied"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host availability status", () => {
|
||||
it("does not call a TCP-reachable host online before authentication", () => {
|
||||
expect(statusAfterReachabilityCheck(true)).toBe("reachable");
|
||||
});
|
||||
|
||||
it("keeps a verified host online across later reachability checks", () => {
|
||||
expect(statusAfterReachabilityCheck(true, "online")).toBe("online");
|
||||
});
|
||||
|
||||
it("marks successful authentication online", () => {
|
||||
expect(statusAfterAuthentication(true, "reachable")).toBe("online");
|
||||
});
|
||||
|
||||
it("downgrades failed authentication without hiding reachability", () => {
|
||||
expect(statusAfterAuthentication(false, "online")).toBe("reachable");
|
||||
expect(statusAfterAuthentication(false, "offline")).toBe("offline");
|
||||
});
|
||||
});
|
||||
@@ -1,267 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
authFailureTracker,
|
||||
canStartInitialMetrics,
|
||||
ConcurrentLimiter,
|
||||
HostPollCache,
|
||||
metricsConcurrencyFor,
|
||||
} from "../../../hosts/metrics-shared/state.js";
|
||||
|
||||
describe("auth failure tracking", () => {
|
||||
it("permanently pauses polling after a host key mismatch", () => {
|
||||
const hostId = 991266;
|
||||
authFailureTracker.recordFailure(hostId, "HOST_KEY", true);
|
||||
|
||||
expect(authFailureTracker.shouldSkip(hostId)).toBe(true);
|
||||
expect(authFailureTracker.getSkipReason(hostId)).toContain(
|
||||
"host key changed",
|
||||
);
|
||||
|
||||
authFailureTracker.reset(hostId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initial metrics admission", () => {
|
||||
it("requires both an active viewer and a confirmed online status", () => {
|
||||
expect(canStartInitialMetrics("online", true)).toBe(true);
|
||||
expect(canStartInitialMetrics("reachable", true)).toBe(true);
|
||||
expect(canStartInitialMetrics("offline", true)).toBe(false);
|
||||
expect(canStartInitialMetrics(undefined, true)).toBe(false);
|
||||
expect(canStartInitialMetrics("online", false)).toBe(false);
|
||||
expect(canStartInitialMetrics(undefined, true, false)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConcurrentLimiter", () => {
|
||||
it("never exceeds max concurrent runners", async () => {
|
||||
const limiter = new ConcurrentLimiter(2);
|
||||
let peak = 0;
|
||||
let current = 0;
|
||||
|
||||
const job = async () => {
|
||||
current += 1;
|
||||
peak = Math.max(peak, current);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
current -= 1;
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
limiter.run(job),
|
||||
limiter.run(job),
|
||||
limiter.run(job),
|
||||
limiter.run(job),
|
||||
]);
|
||||
|
||||
expect(peak).toBeLessThanOrEqual(2);
|
||||
expect(limiter.activeCount).toBe(0);
|
||||
expect(limiter.pendingCount).toBe(0);
|
||||
});
|
||||
|
||||
it("runs waiters in FIFO order after a slot frees", async () => {
|
||||
const limiter = new ConcurrentLimiter(1);
|
||||
const order: number[] = [];
|
||||
|
||||
const first = limiter.run(async () => {
|
||||
order.push(1);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
});
|
||||
const second = limiter.run(async () => {
|
||||
order.push(2);
|
||||
});
|
||||
const third = limiter.run(async () => {
|
||||
order.push(3);
|
||||
});
|
||||
|
||||
await Promise.all([first, second, third]);
|
||||
expect(order).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("rejects invalid maxConcurrent", () => {
|
||||
expect(() => new ConcurrentLimiter(0)).toThrow(/maxConcurrent/);
|
||||
});
|
||||
|
||||
describe("setLimit", () => {
|
||||
it("releases queued waiters as soon as the ceiling is raised", async () => {
|
||||
const limiter = new ConcurrentLimiter(1);
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
const release: Array<() => void> = [];
|
||||
|
||||
const job = () =>
|
||||
limiter.run(async () => {
|
||||
running += 1;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise<void>((r) => release.push(r));
|
||||
running -= 1;
|
||||
});
|
||||
|
||||
const jobs = [job(), job(), job(), job()];
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(peak).toBe(1);
|
||||
expect(limiter.pendingCount).toBe(3);
|
||||
|
||||
// Widening must drain the backlog without waiting for the running job.
|
||||
limiter.setLimit(4);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(peak).toBe(4);
|
||||
expect(limiter.pendingCount).toBe(0);
|
||||
|
||||
release.forEach((fn) => fn());
|
||||
await Promise.all(jobs);
|
||||
expect(limiter.activeCount).toBe(0);
|
||||
});
|
||||
|
||||
it("does not over-release beyond the new ceiling", async () => {
|
||||
const limiter = new ConcurrentLimiter(1);
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
const release: Array<() => void> = [];
|
||||
// Later waves of woken jobs enqueue their own resolvers, so draining has
|
||||
// to keep going until nothing is left rather than flushing a snapshot.
|
||||
const drain = async () => {
|
||||
while (release.length > 0) {
|
||||
release.splice(0).forEach((fn) => fn());
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
};
|
||||
|
||||
const job = () =>
|
||||
limiter.run(async () => {
|
||||
running += 1;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise<void>((r) => release.push(r));
|
||||
running -= 1;
|
||||
});
|
||||
|
||||
const jobs = [job(), job(), job(), job(), job()];
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
limiter.setLimit(3);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(peak).toBe(3);
|
||||
expect(limiter.pendingCount).toBe(2);
|
||||
|
||||
await drain();
|
||||
await Promise.all(jobs);
|
||||
expect(limiter.activeCount).toBe(0);
|
||||
expect(limiter.pendingCount).toBe(0);
|
||||
});
|
||||
|
||||
it("lets running work finish when the ceiling shrinks", async () => {
|
||||
const limiter = new ConcurrentLimiter(4);
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
const release: Array<() => void> = [];
|
||||
const drain = async () => {
|
||||
while (release.length > 0) {
|
||||
release.splice(0).forEach((fn) => fn());
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
};
|
||||
|
||||
const job = () =>
|
||||
limiter.run(async () => {
|
||||
running += 1;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise<void>((r) => release.push(r));
|
||||
running -= 1;
|
||||
});
|
||||
|
||||
const jobs = [job(), job(), job(), job(), job(), job()];
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(peak).toBe(4);
|
||||
|
||||
// Shrinking never kills in-flight work; it applies to later releases.
|
||||
limiter.setLimit(2);
|
||||
expect(limiter.activeCount).toBe(4);
|
||||
|
||||
await drain();
|
||||
await Promise.all(jobs);
|
||||
expect(limiter.activeCount).toBe(0);
|
||||
expect(limiter.pendingCount).toBe(0);
|
||||
// The two queued jobs ran only after the shrink, so they never pushed
|
||||
// occupancy back up to the old width.
|
||||
expect(peak).toBe(4);
|
||||
});
|
||||
|
||||
it("rejects an invalid new limit", () => {
|
||||
const limiter = new ConcurrentLimiter(2);
|
||||
expect(() => limiter.setLimit(0)).toThrow(/maxConcurrent/);
|
||||
expect(limiter.limit).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("metricsConcurrencyFor", () => {
|
||||
it("keeps a floor for small installs", () => {
|
||||
expect(metricsConcurrencyFor(0, {})).toBe(5);
|
||||
expect(metricsConcurrencyFor(1, {})).toBe(5);
|
||||
expect(metricsConcurrencyFor(60, {})).toBe(5);
|
||||
});
|
||||
|
||||
it("scales up with the fleet", () => {
|
||||
expect(metricsConcurrencyFor(200, {})).toBe(10);
|
||||
expect(metricsConcurrencyFor(500, {})).toBe(25);
|
||||
});
|
||||
|
||||
it("caps so a huge fleet cannot exhaust the host", () => {
|
||||
expect(metricsConcurrencyFor(100000, {})).toBe(50);
|
||||
});
|
||||
|
||||
it("lets an operator override the sizing", () => {
|
||||
expect(metricsConcurrencyFor(1000, { METRICS_POLL_CONCURRENCY: "8" })).toBe(
|
||||
8,
|
||||
);
|
||||
});
|
||||
|
||||
it("still caps an oversized override", () => {
|
||||
expect(
|
||||
metricsConcurrencyFor(10, { METRICS_POLL_CONCURRENCY: "9999" }),
|
||||
).toBe(50);
|
||||
});
|
||||
|
||||
it("ignores a nonsense override", () => {
|
||||
expect(
|
||||
metricsConcurrencyFor(500, { METRICS_POLL_CONCURRENCY: "abc" }),
|
||||
).toBe(25);
|
||||
});
|
||||
|
||||
it("sweeps 500 hosts inside a 30s interval, which the old fixed 5 could not", () => {
|
||||
const POLL_MS = 400;
|
||||
const hosts = 500;
|
||||
const sweepAt = (c: number) => Math.ceil(hosts / c) * POLL_MS;
|
||||
|
||||
expect(sweepAt(5)).toBeGreaterThan(30_000);
|
||||
expect(sweepAt(metricsConcurrencyFor(hosts, {}))).toBeLessThan(30_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HostPollCache", () => {
|
||||
it("returns cached host within TTL for the same user", () => {
|
||||
const cache = new HostPollCache<{ id: number; name: string }>(60_000);
|
||||
cache.set(1, "user-a", { id: 1, name: "alpha" });
|
||||
expect(cache.get(1, "user-a")).toEqual({ id: 1, name: "alpha" });
|
||||
expect(cache.get(1, "user-b")).toBeNull();
|
||||
});
|
||||
|
||||
it("expires entries after TTL", () => {
|
||||
vi.useFakeTimers();
|
||||
const cache = new HostPollCache<{ id: number }>(1_000);
|
||||
cache.set(7, "u", { id: 7 });
|
||||
expect(cache.get(7, "u")).toEqual({ id: 7 });
|
||||
vi.advanceTimersByTime(1_001);
|
||||
expect(cache.get(7, "u")).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("invalidate drops a host or the whole cache", () => {
|
||||
const cache = new HostPollCache<{ id: number }>(60_000);
|
||||
cache.set(1, "u", { id: 1 });
|
||||
cache.set(2, "u", { id: 2 });
|
||||
cache.invalidate(1);
|
||||
expect(cache.get(1, "u")).toBeNull();
|
||||
expect(cache.get(2, "u")).toEqual({ id: 2 });
|
||||
cache.invalidate();
|
||||
expect(cache.get(2, "u")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,148 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const historyCreate = vi.fn();
|
||||
const historyPrune = vi.fn();
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
getCurrentSettingValue: () => null,
|
||||
createCurrentProxmoxNodeHistoryRepository: () => ({
|
||||
create: historyCreate,
|
||||
pruneOlderThan: historyPrune,
|
||||
}),
|
||||
}));
|
||||
|
||||
const collectProxmoxStats = vi.fn();
|
||||
vi.mock("../../../hosts/metrics/proxmox/collect-proxmox-stats.js", () => ({
|
||||
collectProxmoxStats: (...args: unknown[]) => collectProxmoxStats(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
ProxmoxPollingManager,
|
||||
parseProxmoxStatsConfig,
|
||||
} from "../../../hosts/metrics/proxmox-stats-polling.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
interface TestHost {
|
||||
id: number;
|
||||
userId: string;
|
||||
proxmoxStatsConfig?: string | null;
|
||||
}
|
||||
|
||||
function snapshot(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
node: {
|
||||
cpu: { percent: 10, cores: 4, load: [0.1, 0.2, 0.3] },
|
||||
memory: { percent: 20, usedGiB: 2, totalGiB: 8 },
|
||||
disk: { percent: 30, usedGiB: 30, totalGiB: 100 },
|
||||
uptime: { seconds: 100, formatted: "0d 0h 1m" },
|
||||
system: { hostname: "pve1", kernel: "6.8", pveVersion: "8.2" },
|
||||
},
|
||||
network: { interfaces: [{ name: "eth0", rxBytes: "10", txBytes: "20" }] },
|
||||
guests: { guests: [], counts: { running: 0, stopped: 0, total: 0 } },
|
||||
storage: { pools: [] },
|
||||
cluster: { clustered: false },
|
||||
lastChecked: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
historyCreate.mockReset();
|
||||
historyPrune.mockReset();
|
||||
collectProxmoxStats.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("parseProxmoxStatsConfig", () => {
|
||||
it("returns default poll interval when config is missing", () => {
|
||||
expect(parseProxmoxStatsConfig(null)).toEqual({
|
||||
nodeName: null,
|
||||
pollInterval: 60,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a JSON string config", () => {
|
||||
expect(
|
||||
parseProxmoxStatsConfig('{"nodeName":"pve1","pollInterval":30}'),
|
||||
).toEqual({ nodeName: "pve1", pollInterval: 30 });
|
||||
});
|
||||
|
||||
it("falls back to defaults on malformed JSON", () => {
|
||||
expect(parseProxmoxStatsConfig("{not json")).toEqual({
|
||||
nodeName: null,
|
||||
pollInterval: 60,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProxmoxPollingManager", () => {
|
||||
function makeManager(host: TestHost) {
|
||||
const fetchHostById = vi.fn(async () => host);
|
||||
const withSshConnection = vi.fn(
|
||||
async (_host: TestHost, fn: (client: Client) => Promise<unknown>) =>
|
||||
fn({} as Client),
|
||||
);
|
||||
const manager = new ProxmoxPollingManager<TestHost>({
|
||||
fetchHostById,
|
||||
withSshConnection,
|
||||
});
|
||||
return { manager, fetchHostById, withSshConnection };
|
||||
}
|
||||
|
||||
it("starts polling and caches a snapshot when the first viewer registers", async () => {
|
||||
const host: TestHost = { id: 1, userId: "user-1" };
|
||||
collectProxmoxStats.mockResolvedValue(snapshot());
|
||||
const { manager, withSshConnection } = makeManager(host);
|
||||
|
||||
manager.registerViewer(1, "viewer-1", "user-1");
|
||||
// registerViewer kicks off polling via a fire-and-forget promise chain.
|
||||
await vi.waitFor(() => {
|
||||
expect(withSshConnection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const cached = manager.getStats(1);
|
||||
expect(cached?.data.node.cpu.percent).toBe(10);
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
it("stops polling once the last viewer unregisters", async () => {
|
||||
const host: TestHost = { id: 2, userId: "user-1" };
|
||||
collectProxmoxStats.mockResolvedValue(snapshot());
|
||||
const { manager } = makeManager(host);
|
||||
|
||||
manager.registerViewer(2, "viewer-a", "user-1");
|
||||
manager.registerViewer(2, "viewer-b", "user-1");
|
||||
await vi.waitFor(() => expect(manager.getStats(2)).toBeDefined());
|
||||
|
||||
manager.unregisterViewer(2, "viewer-a");
|
||||
// one viewer left - stats stay cached
|
||||
expect(manager.getStats(2)).toBeDefined();
|
||||
|
||||
manager.unregisterViewer(2, "viewer-b");
|
||||
// Cached snapshot is retained (not cleared) but the interval is stopped;
|
||||
// registering a new viewer must restart polling.
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
it("updateHeartbeat returns false for an unknown session", () => {
|
||||
const { manager } = makeManager({ id: 3, userId: "user-1" });
|
||||
expect(manager.updateHeartbeat("nope")).toBe(false);
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
it("records an error snapshot when collection fails, without throwing", async () => {
|
||||
const host: TestHost = { id: 4, userId: "user-1" };
|
||||
collectProxmoxStats.mockRejectedValue(new Error("pvesh not found"));
|
||||
const { manager } = makeManager(host);
|
||||
|
||||
manager.registerViewer(4, "viewer-1", "user-1");
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.getError(4)?.error).toBe("pvesh not found");
|
||||
});
|
||||
expect(manager.getStats(4)).toBeUndefined();
|
||||
manager.destroy();
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock("../../../../hosts/metrics-shared/common-utils.js", () => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
}));
|
||||
|
||||
import { collectProxmoxClusterHealth } from "../../../../hosts/metrics/proxmox/cluster-health-collector.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
const fakeClient = {} as Client;
|
||||
|
||||
function result(stdout: string, code: number | null = 0) {
|
||||
return { stdout, stderr: "", code };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execCommand.mockReset();
|
||||
});
|
||||
|
||||
describe("collectProxmoxClusterHealth", () => {
|
||||
it("reports clustered:false for a standalone node (no cluster entry)", async () => {
|
||||
execCommand.mockResolvedValueOnce(result(JSON.stringify([]), 0));
|
||||
const res = await collectProxmoxClusterHealth(fakeClient);
|
||||
expect(res).toEqual({ clustered: false });
|
||||
});
|
||||
|
||||
it("parses a clustered response with quorum and node entries", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result(
|
||||
JSON.stringify([
|
||||
{ type: "cluster", name: "prod-cluster", quorate: 1, nodes: 3 },
|
||||
{ type: "node", name: "pve1", online: 1, local: 1, ip: "10.0.0.1" },
|
||||
{ type: "node", name: "pve2", online: 0, local: 0, ip: "10.0.0.2" },
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const res = await collectProxmoxClusterHealth(fakeClient);
|
||||
expect(res.clustered).toBe(true);
|
||||
if (res.clustered) {
|
||||
expect(res.quorate).toBe(true);
|
||||
expect(res.clusterName).toBe("prod-cluster");
|
||||
expect(res.nodes).toHaveLength(2);
|
||||
expect(res.nodes[0]).toEqual({
|
||||
name: "pve1",
|
||||
online: true,
|
||||
local: true,
|
||||
ip: "10.0.0.1",
|
||||
});
|
||||
expect(res.nodes[1].online).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns clustered:false when pvesh fails", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("", 1));
|
||||
const res = await collectProxmoxClusterHealth(fakeClient);
|
||||
expect(res).toEqual({ clustered: false });
|
||||
});
|
||||
|
||||
it("returns clustered:false on malformed JSON", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("not json", 0));
|
||||
const res = await collectProxmoxClusterHealth(fakeClient);
|
||||
expect(res).toEqual({ clustered: false });
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock("../../../../hosts/metrics-shared/common-utils.js", () => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
toFixedNum: (n: number | null | undefined, digits = 2) => {
|
||||
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
||||
return Number(n.toFixed(digits));
|
||||
},
|
||||
}));
|
||||
|
||||
import { collectProxmoxStats } from "../../../../hosts/metrics/proxmox/collect-proxmox-stats.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
const fakeClient = {} as Client;
|
||||
|
||||
function result(stdout: string, code: number | null = 0) {
|
||||
return { stdout, stderr: "", code };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execCommand.mockReset();
|
||||
});
|
||||
|
||||
describe("collectProxmoxStats", () => {
|
||||
it("throws a distinguishable error when pvesh is missing", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("missing"));
|
||||
|
||||
await expect(collectProxmoxStats(fakeClient, null)).rejects.toThrow(
|
||||
/pvesh not found/i,
|
||||
);
|
||||
// Only the pvesh-presence check should have run - no node resolution or
|
||||
// per-collector execs once that check fails.
|
||||
expect(execCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("auto-detects the node name via hostname when none is configured", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("ok")); // pvesh check
|
||||
execCommand.mockResolvedValueOnce(result("pve-auto\n")); // hostname
|
||||
// Five collectors run concurrently after that; give each a benign failing
|
||||
// response so the aggregator still resolves with null-filled sub-shapes.
|
||||
execCommand.mockResolvedValue(result("", 1));
|
||||
|
||||
const snapshot = await collectProxmoxStats(fakeClient, null);
|
||||
expect(snapshot.lastChecked).toBeTruthy();
|
||||
expect(snapshot.node).toBeDefined();
|
||||
expect(snapshot.guests).toBeDefined();
|
||||
expect(snapshot.storage).toBeDefined();
|
||||
expect(snapshot.cluster).toEqual({ clustered: false });
|
||||
});
|
||||
|
||||
it("uses the configured node name when it is safe, skipping hostname detection", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("ok")); // pvesh check
|
||||
execCommand.mockResolvedValue(result("", 1)); // all collector calls fail benignly
|
||||
|
||||
await collectProxmoxStats(fakeClient, "my-node");
|
||||
|
||||
// hostname auto-detection command should never have been issued.
|
||||
const calls = execCommand.mock.calls.map((c) => c[1] as string);
|
||||
expect(calls).not.toContain("hostname");
|
||||
});
|
||||
|
||||
it("rejects an unsafe configured node name", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("ok")); // pvesh check
|
||||
|
||||
await expect(collectProxmoxStats(fakeClient, "bad;node")).rejects.toThrow(
|
||||
/valid Proxmox node name/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock("../../../../hosts/metrics-shared/common-utils.js", () => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
toFixedNum: (n: number | null | undefined, digits = 2) => {
|
||||
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
||||
return Number(n.toFixed(digits));
|
||||
},
|
||||
}));
|
||||
|
||||
import { collectProxmoxGuestsSummary } from "../../../../hosts/metrics/proxmox/guests-collector.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
const fakeClient = {} as Client;
|
||||
|
||||
function result(stdout: string, code: number | null = 0) {
|
||||
return { stdout, stderr: "", code };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execCommand.mockReset();
|
||||
});
|
||||
|
||||
describe("collectProxmoxGuestsSummary", () => {
|
||||
it("filters to the target node, excludes templates, and computes percentages", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result(
|
||||
JSON.stringify([
|
||||
{
|
||||
type: "qemu",
|
||||
node: "pve1",
|
||||
vmid: 100,
|
||||
name: "vm-a",
|
||||
status: "running",
|
||||
cpu: 0.25,
|
||||
mem: 2 * 1024 ** 3,
|
||||
maxmem: 4 * 1024 ** 3,
|
||||
disk: 10 * 1024 ** 3,
|
||||
maxdisk: 40 * 1024 ** 3,
|
||||
uptime: 3600,
|
||||
},
|
||||
{
|
||||
type: "lxc",
|
||||
node: "pve1",
|
||||
vmid: 101,
|
||||
name: "ct-b",
|
||||
status: "stopped",
|
||||
cpu: 0,
|
||||
mem: 0,
|
||||
maxmem: 512 * 1024 ** 2,
|
||||
disk: 0,
|
||||
maxdisk: 8 * 1024 ** 3,
|
||||
uptime: 0,
|
||||
},
|
||||
{
|
||||
// different node - excluded
|
||||
type: "qemu",
|
||||
node: "pve2",
|
||||
vmid: 200,
|
||||
name: "elsewhere",
|
||||
status: "running",
|
||||
},
|
||||
{
|
||||
// template - excluded
|
||||
type: "qemu",
|
||||
node: "pve1",
|
||||
vmid: 999,
|
||||
name: "template",
|
||||
status: "stopped",
|
||||
template: 1,
|
||||
},
|
||||
{
|
||||
// non-guest resource type - excluded
|
||||
type: "storage",
|
||||
node: "pve1",
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const res = await collectProxmoxGuestsSummary(fakeClient, "pve1");
|
||||
|
||||
expect(res.guests).toHaveLength(2);
|
||||
expect(res.counts).toEqual({ running: 1, stopped: 1, total: 2 });
|
||||
|
||||
const vmA = res.guests.find((g) => g.vmid === 100)!;
|
||||
expect(vmA.cpuPercent).toBe(25);
|
||||
expect(vmA.memPercent).toBe(50);
|
||||
expect(vmA.diskPercent).toBe(25);
|
||||
});
|
||||
|
||||
it("reports null disk fields (not a false 0%) when maxdisk is 0", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result(
|
||||
JSON.stringify([
|
||||
{
|
||||
type: "qemu",
|
||||
node: "pve1",
|
||||
vmid: 100,
|
||||
name: "vm-no-agent",
|
||||
status: "running",
|
||||
cpu: 0.1,
|
||||
mem: 1024,
|
||||
maxmem: 2048,
|
||||
disk: 0,
|
||||
maxdisk: 0,
|
||||
uptime: 10,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const res = await collectProxmoxGuestsSummary(fakeClient, "pve1");
|
||||
expect(res.guests[0].diskPercent).toBeNull();
|
||||
expect(res.guests[0].diskUsedGiB).toBeNull();
|
||||
expect(res.guests[0].diskTotalGiB).toBeNull();
|
||||
});
|
||||
|
||||
it("returns an empty guest list when there are no matching resources", async () => {
|
||||
execCommand.mockResolvedValueOnce(result(JSON.stringify([]), 0));
|
||||
const res = await collectProxmoxGuestsSummary(fakeClient, "pve1");
|
||||
expect(res.guests).toEqual([]);
|
||||
expect(res.counts).toEqual({ running: 0, stopped: 0, total: 0 });
|
||||
});
|
||||
|
||||
it("returns an empty result when pvesh is missing (non-zero exit)", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("", 127));
|
||||
const res = await collectProxmoxGuestsSummary(fakeClient, "pve1");
|
||||
expect(res.guests).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns an empty result on malformed JSON", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("{not json", 0));
|
||||
const res = await collectProxmoxGuestsSummary(fakeClient, "pve1");
|
||||
expect(res.guests).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an unsafe node name before running any command", async () => {
|
||||
const res = await collectProxmoxGuestsSummary(fakeClient, "../etc");
|
||||
expect(res.guests).toEqual([]);
|
||||
expect(execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock("../../../../hosts/metrics-shared/common-utils.js", () => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
}));
|
||||
|
||||
import { collectProxmoxNodeNetwork } from "../../../../hosts/metrics/proxmox/node-network-collector.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
const fakeClient = {} as Client;
|
||||
|
||||
function result(stdout: string, code: number | null = 0) {
|
||||
return { stdout, stderr: "", code };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execCommand.mockReset();
|
||||
});
|
||||
|
||||
describe("collectProxmoxNodeNetwork", () => {
|
||||
it("rejects an unsafe node name before running any command", async () => {
|
||||
const res = await collectProxmoxNodeNetwork(fakeClient, "bad;name");
|
||||
expect(res.interfaces).toEqual([]);
|
||||
expect(execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to /proc/net/dev when pvesh netstat fails", async () => {
|
||||
// First call: pvesh netstat -> failure.
|
||||
execCommand.mockResolvedValueOnce(result("", 1));
|
||||
// Fallback calls: ip addr, ip link, /proc/net/dev.
|
||||
execCommand.mockResolvedValueOnce(result("eth0 10.0.0.5/24\n"));
|
||||
execCommand.mockResolvedValueOnce(result("eth0 UP\n"));
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result(
|
||||
"Inter-| Receive\n" +
|
||||
" face |bytes packets\n" +
|
||||
"eth0: 123456 10 0 0 0 0 0 0 654321 20 0 0 0 0 0 0\n",
|
||||
),
|
||||
);
|
||||
|
||||
const res = await collectProxmoxNodeNetwork(fakeClient, "pve1");
|
||||
expect(res.interfaces).toHaveLength(1);
|
||||
expect(res.interfaces[0]).toMatchObject({
|
||||
name: "eth0",
|
||||
ip: "10.0.0.5",
|
||||
state: "UP",
|
||||
rxBytes: "123456",
|
||||
txBytes: "654321",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to /proc/net/dev when pvesh returns unparseable data", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("not json", 0));
|
||||
execCommand.mockResolvedValueOnce(result(""));
|
||||
execCommand.mockResolvedValueOnce(result(""));
|
||||
execCommand.mockResolvedValueOnce(result("Inter-| Receive\n face |\n"));
|
||||
|
||||
const res = await collectProxmoxNodeNetwork(fakeClient, "pve1");
|
||||
expect(res.interfaces).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock("../../../../hosts/metrics-shared/common-utils.js", () => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
toFixedNum: (n: number | null | undefined, digits = 2) => {
|
||||
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
||||
return Number(n.toFixed(digits));
|
||||
},
|
||||
}));
|
||||
|
||||
import { collectProxmoxNodeStatus } from "../../../../hosts/metrics/proxmox/node-status-collector.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
const fakeClient = {} as Client;
|
||||
|
||||
function result(stdout: string, code: number | null = 0) {
|
||||
return { stdout, stderr: "", code };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execCommand.mockReset();
|
||||
});
|
||||
|
||||
describe("collectProxmoxNodeStatus", () => {
|
||||
it("parses a healthy pvesh /nodes/{node}/status response", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result(
|
||||
JSON.stringify({
|
||||
cpu: 0.15,
|
||||
cpuinfo: { cores: 8 },
|
||||
loadavg: ["0.5", "0.6", "0.7"],
|
||||
memory: { used: 4 * 1024 ** 3, total: 16 * 1024 ** 3 },
|
||||
rootfs: { used: 20 * 1024 ** 3, total: 100 * 1024 ** 3 },
|
||||
uptime: 90061,
|
||||
hostname: "pve1",
|
||||
kversion: "Linux 6.8.0",
|
||||
pveversion: "pve-manager/8.2.0",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const res = await collectProxmoxNodeStatus(fakeClient, "pve1");
|
||||
|
||||
expect(res.cpu.percent).toBe(15);
|
||||
expect(res.cpu.cores).toBe(8);
|
||||
expect(res.cpu.load).toEqual([0.5, 0.6, 0.7]);
|
||||
expect(res.memory.percent).toBe(25);
|
||||
expect(res.memory.usedGiB).toBeCloseTo(4, 1);
|
||||
expect(res.memory.totalGiB).toBeCloseTo(16, 1);
|
||||
expect(res.disk.percent).toBe(20);
|
||||
expect(res.uptime.seconds).toBe(90061);
|
||||
expect(res.uptime.formatted).toBe("1d 1h 1m");
|
||||
expect(res.system.hostname).toBe("pve1");
|
||||
expect(res.system.kernel).toBe("Linux 6.8.0");
|
||||
expect(res.system.pveVersion).toBe("pve-manager/8.2.0");
|
||||
});
|
||||
|
||||
it("returns a fully null-filled shape when pvesh exits non-zero", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("", 1));
|
||||
const res = await collectProxmoxNodeStatus(fakeClient, "pve1");
|
||||
expect(res.cpu.percent).toBeNull();
|
||||
expect(res.memory.percent).toBeNull();
|
||||
expect(res.disk.percent).toBeNull();
|
||||
expect(res.uptime.seconds).toBeNull();
|
||||
expect(res.system.hostname).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null-filled shape on malformed JSON", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("not json", 0));
|
||||
const res = await collectProxmoxNodeStatus(fakeClient, "pve1");
|
||||
expect(res.cpu.percent).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an unsafe node name before running any command", async () => {
|
||||
const res = await collectProxmoxNodeStatus(fakeClient, "pve1; rm -rf /");
|
||||
expect(res.cpu.percent).toBeNull();
|
||||
expect(execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock("../../../../hosts/metrics-shared/common-utils.js", () => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
toFixedNum: (n: number | null | undefined, digits = 2) => {
|
||||
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
||||
return Number(n.toFixed(digits));
|
||||
},
|
||||
}));
|
||||
|
||||
import { collectProxmoxStorage } from "../../../../hosts/metrics/proxmox/storage-collector.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
const fakeClient = {} as Client;
|
||||
|
||||
function result(stdout: string, code: number | null = 0) {
|
||||
return { stdout, stderr: "", code };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execCommand.mockReset();
|
||||
});
|
||||
|
||||
describe("collectProxmoxStorage", () => {
|
||||
it("parses storage pools with usage percentages", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result(
|
||||
JSON.stringify([
|
||||
{
|
||||
storage: "local",
|
||||
type: "dir",
|
||||
active: 1,
|
||||
enabled: 1,
|
||||
used: 20 * 1024 ** 3,
|
||||
total: 100 * 1024 ** 3,
|
||||
avail: 80 * 1024 ** 3,
|
||||
},
|
||||
{
|
||||
storage: "local-zfs",
|
||||
type: "zfspool",
|
||||
active: 0,
|
||||
enabled: 1,
|
||||
used: 0,
|
||||
total: 0,
|
||||
avail: 0,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const res = await collectProxmoxStorage(fakeClient, "pve1");
|
||||
expect(res.pools).toHaveLength(2);
|
||||
expect(res.pools[0].name).toBe("local");
|
||||
expect(res.pools[0].active).toBe(true);
|
||||
expect(res.pools[0].percent).toBe(20);
|
||||
expect(res.pools[1].active).toBe(false);
|
||||
expect(res.pools[1].percent).toBeNull();
|
||||
});
|
||||
|
||||
it("returns an empty pool list when pvesh fails", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("", 1));
|
||||
const res = await collectProxmoxStorage(fakeClient, "pve1");
|
||||
expect(res.pools).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns an empty pool list on malformed JSON", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("nope", 0));
|
||||
const res = await collectProxmoxStorage(fakeClient, "pve1");
|
||||
expect(res.pools).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an unsafe node name before running any command", async () => {
|
||||
const res = await collectProxmoxStorage(fakeClient, "$(whoami)");
|
||||
expect(res.pools).toEqual([]);
|
||||
expect(execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseTailscaleCheckBanner,
|
||||
isTailscaleCheckCompleteBanner,
|
||||
} from "../../hosts/tailscale-check.js";
|
||||
|
||||
describe("parseTailscaleCheckBanner", () => {
|
||||
it("extracts the login URL from a real check-mode banner", () => {
|
||||
const banner =
|
||||
"# Tailscale SSH requires an additional check.\n# To authenticate, visit: https://login.tailscale.com/a/lefcb2f3377403\n";
|
||||
|
||||
const result = parseTailscaleCheckBanner(banner);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.url).toBe("https://login.tailscale.com/a/lefcb2f3377403");
|
||||
});
|
||||
|
||||
it("strips comment markers from the message it returns", () => {
|
||||
const banner =
|
||||
"# Tailscale SSH requires an additional check.\n# To authenticate, visit: https://login.tailscale.com/a/abc123\n";
|
||||
|
||||
const result = parseTailscaleCheckBanner(banner);
|
||||
|
||||
expect(result?.message).toBe(
|
||||
"Tailscale SSH requires an additional check.\nTo authenticate, visit: https://login.tailscale.com/a/abc123",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for an ordinary MOTD banner", () => {
|
||||
const banner =
|
||||
"Welcome to Ubuntu 24.04 LTS\nLast login: Tue Aug 5 09:12:03 2026\n";
|
||||
|
||||
expect(parseTailscaleCheckBanner(banner)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a lookalike URL on another host", () => {
|
||||
const banner =
|
||||
"# To authenticate, visit: https://login.tailscale.com.evil.example/a/abc123\n";
|
||||
|
||||
expect(parseTailscaleCheckBanner(banner)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty input", () => {
|
||||
expect(parseTailscaleCheckBanner("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTailscaleCheckCompleteBanner", () => {
|
||||
it("recognises the completion banner", () => {
|
||||
expect(
|
||||
isTailscaleCheckCompleteBanner(
|
||||
"# Authentication checked with Tailscale SSH.",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("recognises the completion banner with a time suffix", () => {
|
||||
expect(
|
||||
isTailscaleCheckCompleteBanner(
|
||||
"Authentication checked with Tailscale SSH. Time since last authentication: 0s",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match the check-required banner", () => {
|
||||
expect(
|
||||
isTailscaleCheckCompleteBanner(
|
||||
"# Tailscale SSH requires an additional check.",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not match empty input", () => {
|
||||
expect(isTailscaleCheckCompleteBanner("")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
import net from "node:net";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Exercises establishDirectTunnel's local branch against a REAL TCP listener.
|
||||
* The two behaviours here are the ones a mocked test cannot show:
|
||||
*
|
||||
* - under sourcePort 0 the kernel assigns the port, so the runtime has to
|
||||
* record what it actually bound rather than the 0 it was asked for; and
|
||||
* - an idle tunnel closes itself and REMOVES its runtime entry, so nothing
|
||||
* keeps handing out a port whose listener is gone.
|
||||
*
|
||||
* The SSH half is stubbed -- these behaviours live entirely in the listener
|
||||
* and the runtime bookkeeping.
|
||||
*/
|
||||
const sshPrimitives = vi.hoisted(() => ({
|
||||
forwardOut: vi.fn(async () => {
|
||||
throw new Error("not used in these tests");
|
||||
}),
|
||||
pipeTunnelStreams: vi.fn(),
|
||||
handleSocks5Connect: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../hosts/tunnel/ssh-primitives.js", () => sshPrimitives);
|
||||
|
||||
function tunnelConfig(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: "web:7:e1",
|
||||
scope: "s2s",
|
||||
mode: "local",
|
||||
tunnelType: "local",
|
||||
bindHost: "127.0.0.1",
|
||||
sourcePort: 0,
|
||||
endpointHost: "127.0.0.1",
|
||||
endpointPort: 9,
|
||||
...overrides,
|
||||
} as never;
|
||||
}
|
||||
|
||||
const fakeClient = () => ({ on: vi.fn(), end: vi.fn() }) as never;
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("establishDirectTunnel records the port it actually bound", () => {
|
||||
it("reports the kernel-assigned port, never the requested 0", async () => {
|
||||
const manager = await import("../../hosts/tunnel/manager.js");
|
||||
await manager.establishDirectTunnel(fakeClient(), tunnelConfig());
|
||||
|
||||
const runtime = manager.activeTunnelRuntimes.get("web:7:e1");
|
||||
expect(runtime).toBeDefined();
|
||||
// The whole point: 0 means "pick one for me", so a runtime advertising 0
|
||||
// would make every caller that reuses the port fail.
|
||||
expect(runtime?.bindPort).toBeGreaterThan(0);
|
||||
|
||||
// And it is genuinely listening there.
|
||||
const port = runtime!.bindPort as number;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect(port, "127.0.0.1", () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
socket.once("error", reject);
|
||||
});
|
||||
|
||||
runtime?.close();
|
||||
manager.activeTunnelRuntimes.delete("web:7:e1");
|
||||
});
|
||||
|
||||
it("honours a fixed source port when one is asked for", async () => {
|
||||
const manager = await import("../../hosts/tunnel/manager.js");
|
||||
// Borrow a free port, release it, then ask for it explicitly.
|
||||
const probe = net.createServer();
|
||||
const wanted = await new Promise<number>((resolve) => {
|
||||
probe.listen(0, "127.0.0.1", () =>
|
||||
resolve((probe.address() as { port: number }).port),
|
||||
);
|
||||
});
|
||||
await new Promise<void>((resolve) => probe.close(() => resolve()));
|
||||
|
||||
await manager.establishDirectTunnel(
|
||||
fakeClient(),
|
||||
tunnelConfig({ name: "web:7:fixed", sourcePort: wanted }),
|
||||
);
|
||||
const runtime = manager.activeTunnelRuntimes.get("web:7:fixed");
|
||||
expect(runtime?.bindPort).toBe(wanted);
|
||||
|
||||
runtime?.close();
|
||||
manager.activeTunnelRuntimes.delete("web:7:fixed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("idle close", () => {
|
||||
it("removes the runtime entry, not just the listener", async () => {
|
||||
// Real timers deliberately: establishDirectTunnel awaits real socket I/O,
|
||||
// and mixing fake timers with that is a known route to a hanging test.
|
||||
const manager = await import("../../hosts/tunnel/manager.js");
|
||||
await manager.establishDirectTunnel(
|
||||
fakeClient(),
|
||||
tunnelConfig({ name: "web:7:idle", idleTimeoutMs: 60 }),
|
||||
);
|
||||
expect(manager.activeTunnelRuntimes.has("web:7:idle")).toBe(true);
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
// Deleting the entry is the assertion. close() alone would stop the
|
||||
// listener but leave the entry behind, so the open route would keep
|
||||
// returning a port nothing is listening on.
|
||||
expect(manager.activeTunnelRuntimes.has("web:7:idle")).toBe(false);
|
||||
},
|
||||
{ timeout: 4000, interval: 25 },
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a tunnel with no idle timeout running", async () => {
|
||||
// Without this, an idle-close bug that fires unconditionally would still
|
||||
// pass the test above.
|
||||
const manager = await import("../../hosts/tunnel/manager.js");
|
||||
await manager.establishDirectTunnel(
|
||||
fakeClient(),
|
||||
tunnelConfig({ name: "web:7:forever" }),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(manager.activeTunnelRuntimes.has("web:7:forever")).toBe(true);
|
||||
|
||||
manager.activeTunnelRuntimes.get("web:7:forever")?.close();
|
||||
manager.activeTunnelRuntimes.delete("web:7:forever");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user