feat: make each plugin an npm workspace built by a termix-plugin CLI

This commit is contained in:
LukeGus committed 2026-09-22 15:09:50 -05:00
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");
});
});