mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: add host-scripts entry to the plugin sdk
This commit is contained in:
1 parent
3d92725ce6
commit
a4d8a52928
16 files changed
+2882
-11
No files matched your search
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
classifyRuntimeError,
|
||||
composeArgsFor,
|
||||
containerCommand,
|
||||
countContainers,
|
||||
diffContainerStates,
|
||||
dockerArgs,
|
||||
parseComposeProjects,
|
||||
parseContainerList,
|
||||
parseContainerStates,
|
||||
parseContainerStats,
|
||||
parseRuntimeVersion,
|
||||
parseServices,
|
||||
parseStackStatus,
|
||||
stackStateFromServices,
|
||||
} from "@termix-ssh/plugin-sdk/host-scripts";
|
||||
|
||||
describe("containerCommand", () => {
|
||||
it("extends PATH on POSIX hosts", () => {
|
||||
const command = containerCommand("podman", "ps");
|
||||
expect(command.startsWith('PATH="')).toBe(true);
|
||||
expect(command.endsWith(" podman ps")).toBe(true);
|
||||
});
|
||||
|
||||
it("runs the plain CLI on Windows hosts", () => {
|
||||
expect(containerCommand("docker", "ps -a", true)).toBe("docker ps -a");
|
||||
});
|
||||
|
||||
it("falls back to docker for an unknown runtime", () => {
|
||||
expect(containerCommand(undefined, "ps", true)).toBe("docker ps");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dockerArgs", () => {
|
||||
it("builds the container commands", () => {
|
||||
expect(dockerArgs.inspect("web")).toBe("inspect web");
|
||||
expect(dockerArgs.action("restart", "web")).toBe("restart web");
|
||||
expect(dockerArgs.remove("web", true)).toBe("rm -f web");
|
||||
expect(dockerArgs.list(true)).toContain("ps -a --format '{");
|
||||
expect(dockerArgs.stats("web")).toContain("stats web --no-stream");
|
||||
});
|
||||
|
||||
it("refuses container ids that could break the command line", () => {
|
||||
expect(() => dockerArgs.inspect("web; rm -rf /")).toThrow();
|
||||
expect(() => dockerArgs.logs("$(id)")).toThrow();
|
||||
expect(() => dockerArgs.action("restart", "-rf")).toThrow();
|
||||
expect(() => dockerArgs.action("kill" as never, "web")).toThrow();
|
||||
});
|
||||
|
||||
it("only passes sane log filters", () => {
|
||||
expect(
|
||||
dockerArgs.logs("web", {
|
||||
tail: 50.7,
|
||||
timestamps: true,
|
||||
since: "2026-10-01T00:00:00Z",
|
||||
}),
|
||||
).toBe("logs web --tail 50 --timestamps --since 2026-10-01T00:00:00Z 2>&1");
|
||||
expect(dockerArgs.logs("web", { since: "1h; id" })).toBe("logs web 2>&1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("docker parsing", () => {
|
||||
it("reads the version and sorts errors", () => {
|
||||
expect(parseRuntimeVersion("Docker version 27.3.1, build ce12230")).toBe(
|
||||
"27.3.1",
|
||||
);
|
||||
expect(parseRuntimeVersion("podman version 5.2.0")).toBe("5.2.0");
|
||||
expect(parseRuntimeVersion("nope")).toBe("unknown");
|
||||
expect(classifyRuntimeError("bash: docker: command not found")).toBe(
|
||||
"NOT_INSTALLED",
|
||||
);
|
||||
expect(
|
||||
classifyRuntimeError(
|
||||
"Cannot connect to the Docker daemon at unix:///var/run/docker.sock",
|
||||
),
|
||||
).toBe("DAEMON_NOT_RUNNING");
|
||||
expect(
|
||||
classifyRuntimeError("permission denied while trying to connect"),
|
||||
).toBe("PERMISSION_DENIED");
|
||||
expect(classifyRuntimeError("something else")).toBe("DOCKER_ERROR");
|
||||
});
|
||||
|
||||
it("skips lines that are not JSON in the list", () => {
|
||||
const rows = parseContainerList(
|
||||
'{"id":"a","name":"web"}\nWARNING: x\n{"id":"b","name":"db"}\n',
|
||||
);
|
||||
expect(rows.map((r) => r.name)).toEqual(["web", "db"]);
|
||||
});
|
||||
|
||||
it("splits the stats columns", () => {
|
||||
const stats = parseContainerStats(
|
||||
'{"cpu":"1.5%","memory":"10MiB / 1GiB","memoryPercent":"1%","netIO":"1kB / 2kB","blockIO":"0B / 0B","pids":"3"}',
|
||||
);
|
||||
expect(stats).toMatchObject({
|
||||
cpu: "1.5%",
|
||||
memoryUsed: "10MiB",
|
||||
memoryLimit: "1GiB",
|
||||
netInput: "1kB",
|
||||
netOutput: "2kB",
|
||||
pids: "3",
|
||||
});
|
||||
});
|
||||
|
||||
const ps = [
|
||||
`{"name":"web","state":"running","status":"Up 2 hours"}`,
|
||||
`{"name":"db","state":"running","status":"Up 2 hours (unhealthy)"}`,
|
||||
`{"name":"old","state":"exited","status":"Exited (0)"}`,
|
||||
].join("\n");
|
||||
|
||||
it("counts running, stopped and unhealthy containers", () => {
|
||||
expect(countContainers(ps)).toEqual({
|
||||
running: 2,
|
||||
stopped: 1,
|
||||
unhealthy: 1,
|
||||
total: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("turns two snapshots into events", () => {
|
||||
const before = parseContainerStates(ps);
|
||||
const after = parseContainerStates(
|
||||
[
|
||||
`{"name":"web","state":"exited","status":"Exited (1)"}`,
|
||||
`{"name":"db","state":"running","status":"Up (unhealthy)"}`,
|
||||
`{"name":"old","state":"running","status":"Up 1 second"}`,
|
||||
`{"name":"new","state":"running","status":"Up"}`,
|
||||
].join("\n"),
|
||||
);
|
||||
expect(diffContainerStates(before, after)).toEqual([
|
||||
{ container: "web", event: "exited" },
|
||||
{ container: "old", event: "started" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compose", () => {
|
||||
it("reads stack status text", () => {
|
||||
expect(parseStackStatus("running(2), exited(1)")).toEqual({
|
||||
state: "partial",
|
||||
running: 2,
|
||||
total: 3,
|
||||
});
|
||||
expect(parseStackStatus("paused(2)")).toMatchObject({ state: "paused" });
|
||||
expect(parseStackStatus("")).toMatchObject({ state: "not-deployed" });
|
||||
});
|
||||
|
||||
it("reads projects from compose ls", () => {
|
||||
const stacks = parseComposeProjects(
|
||||
JSON.stringify([
|
||||
{
|
||||
Name: "web",
|
||||
Status: "running(2)",
|
||||
ConfigFiles: "/opt/stacks/web/compose.yaml",
|
||||
},
|
||||
{ Name: "Bad Name", Status: "running(1)", ConfigFiles: "" },
|
||||
]),
|
||||
);
|
||||
expect(stacks).toEqual([
|
||||
{
|
||||
name: "web",
|
||||
managed: false,
|
||||
state: "running",
|
||||
running: 2,
|
||||
total: 2,
|
||||
composeFile: "/opt/stacks/web/compose.yaml",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads services from compose ps, one line or one array", () => {
|
||||
const row = {
|
||||
Service: "app",
|
||||
Name: "web-app-1",
|
||||
ID: "abc",
|
||||
Image: "nginx",
|
||||
State: "running",
|
||||
Status: "Up",
|
||||
Health: "",
|
||||
Publishers: [
|
||||
{ PublishedPort: 8080, TargetPort: 80 },
|
||||
{ PublishedPort: 8080, TargetPort: 80 },
|
||||
{ PublishedPort: 0, TargetPort: 443 },
|
||||
],
|
||||
};
|
||||
const lines = parseServices(JSON.stringify(row));
|
||||
expect(lines[0]).toMatchObject({ service: "app", ports: "8080:80" });
|
||||
expect(
|
||||
parseServices(JSON.stringify([row, { ...row, State: "exited" }])),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
stackStateFromServices(
|
||||
parseServices(JSON.stringify([row, { ...row, State: "exited" }])),
|
||||
),
|
||||
).toEqual({
|
||||
state: "partial",
|
||||
running: 1,
|
||||
total: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps actions to compose commands", () => {
|
||||
expect(composeArgsFor("redeploy", "app", {})).toEqual([
|
||||
"pull app",
|
||||
"up -d --remove-orphans app",
|
||||
]);
|
||||
expect(
|
||||
composeArgsFor("destroy", undefined, { removeVolumes: true }),
|
||||
).toEqual(["down --remove-orphans -v"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildFilesystemList,
|
||||
filterExcludedFilesystems,
|
||||
findWorstMountIndex,
|
||||
liveCmd,
|
||||
mergeMonitoredFilesystems,
|
||||
parseCpuLine,
|
||||
parseDfLines,
|
||||
parseLive,
|
||||
selectPrimaryFilesystem,
|
||||
} from "@termix-ssh/plugin-sdk/host-scripts";
|
||||
|
||||
describe("parseCpuLine", () => {
|
||||
it("parses a standard /proc/stat cpu line", () => {
|
||||
// user nice system idle iowait irq softirq
|
||||
const result = parseCpuLine("cpu 100 0 50 800 30 0 20");
|
||||
expect(result).toBeDefined();
|
||||
// idle = idle(800) + iowait(30)
|
||||
expect(result?.idle).toBe(830);
|
||||
// total = sum of all fields
|
||||
expect(result?.total).toBe(100 + 0 + 50 + 800 + 30 + 0 + 20);
|
||||
});
|
||||
|
||||
it("tolerates leading/trailing whitespace", () => {
|
||||
const result = parseCpuLine(" cpu 1 2 3 4 ");
|
||||
expect(result?.total).toBe(10);
|
||||
expect(result?.idle).toBe(4);
|
||||
});
|
||||
|
||||
it("returns undefined for non-cpu lines", () => {
|
||||
expect(parseCpuLine("cpu0 1 2 3 4")).toBeUndefined();
|
||||
expect(parseCpuLine("intr 12345")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when there are fewer than 4 numeric fields", () => {
|
||||
expect(parseCpuLine("cpu 1 2 3")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDfLines", () => {
|
||||
it("parses df -T -P output into rows", () => {
|
||||
const output =
|
||||
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
|
||||
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n";
|
||||
const rows = parseDfLines(output);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].mount).toBe("/");
|
||||
expect(rows[0].type).toBe("ext4");
|
||||
expect(rows[1].mount).toBe("/data");
|
||||
});
|
||||
|
||||
it("filters out pseudo filesystems", () => {
|
||||
const output =
|
||||
"tmpfs tmpfs 8000 0 8000 0% /dev/shm\n" +
|
||||
"overlay overlay 100 50 50 50% /\n" +
|
||||
"/dev/sda1 ext4 100 50 50 50% /mnt/data\n";
|
||||
const rows = parseDfLines(output);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].mount).toBe("/mnt/data");
|
||||
});
|
||||
|
||||
it("captures the filesystem type for network shares", () => {
|
||||
const output =
|
||||
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
|
||||
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n";
|
||||
const rows = parseDfLines(output);
|
||||
expect(rows[0].type).toBe("nfs4");
|
||||
expect(rows[1].type).toBe("cifs");
|
||||
});
|
||||
|
||||
it("keeps full image and firmware mounts out of disk usage", () => {
|
||||
const rows = parseDfLines(
|
||||
"/dev/loop0 squashfs 100 100 0 100% /snap/core/1\n" +
|
||||
"efivarfs efivarfs 100 89 11 89% /sys/firmware/efi/efivars\n" +
|
||||
"/dev/sda1 ext4 1000 400 600 40% /\n",
|
||||
);
|
||||
expect(rows.map((row) => row.mount)).toEqual(["/"]);
|
||||
expect(findWorstMountIndex(rows)).toEqual({
|
||||
index: 0,
|
||||
usedBytes: 400,
|
||||
totalBytes: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it("retains real storage without requiring a /dev source", () => {
|
||||
const rows = parseDfLines(
|
||||
"/dev/sda1 btrfs 1000 400 600 40% /\n" +
|
||||
"tank/data zfs 2000 1000 1000 50% /data\n" +
|
||||
"nas:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
|
||||
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n",
|
||||
);
|
||||
expect(rows.map((row) => row.type)).toEqual([
|
||||
"btrfs",
|
||||
"zfs",
|
||||
"nfs4",
|
||||
"cifs",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findWorstMountIndex", () => {
|
||||
it("picks the most-utilized mount, not just the first row", () => {
|
||||
const rows = parseDfLines(
|
||||
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
|
||||
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n",
|
||||
);
|
||||
const worst = findWorstMountIndex(rows);
|
||||
expect(worst.index).toBe(1);
|
||||
expect(worst.totalBytes).toBe(15393162788864);
|
||||
expect(worst.usedBytes).toBe(15239230844928);
|
||||
});
|
||||
|
||||
it("falls back to the only mount available", () => {
|
||||
const rows = parseDfLines("/dev/sda1 ext4 100 30 70 30% /\n");
|
||||
const worst = findWorstMountIndex(rows);
|
||||
expect(worst.index).toBe(0);
|
||||
});
|
||||
|
||||
it("skips rows with invalid or zero totals", () => {
|
||||
const rows = parseDfLines(
|
||||
"/dev/sda1 ext4 0 0 0 0% /broken\n" +
|
||||
"/dev/sda2 ext4 100 40 60 40% /ok\n",
|
||||
);
|
||||
const worst = findWorstMountIndex(rows);
|
||||
expect(worst.index).toBe(1);
|
||||
});
|
||||
|
||||
it("returns index -1 when there are no usable rows", () => {
|
||||
const worst = findWorstMountIndex([]);
|
||||
expect(worst.index).toBe(-1);
|
||||
expect(worst.totalBytes).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
const BYTES_OUTPUT =
|
||||
"/dev/nvme0n1p2 ext4 1000 400 600 40% /\n" +
|
||||
"/dev/nvme1n1p1 ext4 2000 1900 100 95% /data\n";
|
||||
const HUMAN_OUTPUT =
|
||||
"/dev/nvme0n1p2 ext4 1.0K 400 600 40% /\n" +
|
||||
"/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n";
|
||||
|
||||
describe("buildFilesystemList", () => {
|
||||
it("returns every real filesystem with byte maths and human strings", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines(BYTES_OUTPUT),
|
||||
parseDfLines(HUMAN_OUTPUT),
|
||||
);
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list[0]).toMatchObject({
|
||||
mount: "/",
|
||||
type: "ext4",
|
||||
percent: 40,
|
||||
usedHuman: "400",
|
||||
totalHuman: "1.0K",
|
||||
availableHuman: "600",
|
||||
usedBytes: 400,
|
||||
totalBytes: 1000,
|
||||
});
|
||||
expect(list[1]).toMatchObject({ mount: "/data", percent: 95 });
|
||||
});
|
||||
|
||||
it("matches human rows by mount when the row counts differ", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines(BYTES_OUTPUT),
|
||||
parseDfLines("/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n"),
|
||||
);
|
||||
expect(list[0].totalHuman).toBeNull();
|
||||
expect(list[1].totalHuman).toBe("2.0K");
|
||||
});
|
||||
|
||||
it("drops filesystems with a zero or invalid total", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines(
|
||||
"/dev/sda1 ext4 0 0 0 0% /broken\n/dev/sda2 ext4 100 40 60 40% /ok\n",
|
||||
),
|
||||
[],
|
||||
);
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].mount).toBe("/ok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectPrimaryFilesystem", () => {
|
||||
it("prefers root over a fuller secondary mount", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines(BYTES_OUTPUT),
|
||||
parseDfLines(HUMAN_OUTPUT),
|
||||
);
|
||||
expect(selectPrimaryFilesystem(list)?.mount).toBe("/");
|
||||
});
|
||||
|
||||
it("falls back to the most-utilized mount when there is no root", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines(
|
||||
"/dev/sda1 ext4 1000 100 900 10% /mnt/a\n" +
|
||||
"/dev/sda2 ext4 1000 800 200 80% /mnt/b\n",
|
||||
),
|
||||
[],
|
||||
);
|
||||
expect(selectPrimaryFilesystem(list)?.mount).toBe("/mnt/b");
|
||||
});
|
||||
|
||||
it("returns null for an empty list", () => {
|
||||
expect(selectPrimaryFilesystem([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterExcludedFilesystems", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines(
|
||||
"/dev/sda1 ext4 1000 400 600 40% /\n" +
|
||||
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
|
||||
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n",
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
it("returns the same list when no mounts are excluded", () => {
|
||||
expect(filterExcludedFilesystems(list)).toHaveLength(3);
|
||||
expect(filterExcludedFilesystems(list, [])).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("excludes an exact mount path match", () => {
|
||||
const filtered = filterExcludedFilesystems(list, ["/mnt/nas"]);
|
||||
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
|
||||
});
|
||||
|
||||
it("excludes by filesystem type substring, case-insensitively", () => {
|
||||
const filtered = filterExcludedFilesystems(list, ["NFS"]);
|
||||
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
|
||||
});
|
||||
|
||||
it("supports excluding multiple network filesystem types at once", () => {
|
||||
const filtered = filterExcludedFilesystems(list, ["nfs", "cifs"]);
|
||||
expect(filtered.map((fs) => fs.mount)).toEqual(["/"]);
|
||||
});
|
||||
|
||||
it("ignores blank/whitespace-only entries", () => {
|
||||
const filtered = filterExcludedFilesystems(list, [" ", ""]);
|
||||
expect(filtered).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeMonitoredFilesystems", () => {
|
||||
it("adds an arbitrary path with a user label", () => {
|
||||
const detected = buildFilesystemList(
|
||||
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
|
||||
[],
|
||||
);
|
||||
const custom = buildFilesystemList(
|
||||
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
|
||||
[],
|
||||
);
|
||||
const result = mergeMonitoredFilesystems(
|
||||
detected,
|
||||
[{ path: "/config", label: "Home Assistant" }],
|
||||
custom,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1]).toMatchObject({
|
||||
mount: "/config",
|
||||
label: "Home Assistant",
|
||||
totalBytes: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it("labels a path that is already a detected mount", () => {
|
||||
const detected = buildFilesystemList(
|
||||
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /data\n"),
|
||||
[],
|
||||
);
|
||||
const result = mergeMonitoredFilesystems(
|
||||
detected,
|
||||
[{ path: "/data", label: "Media" }],
|
||||
detected,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].label).toBe("Media");
|
||||
});
|
||||
});
|
||||
|
||||
const out = (
|
||||
agg: [number, number],
|
||||
core: [number, number],
|
||||
rx: number,
|
||||
up: number,
|
||||
procs: string,
|
||||
page = "4096",
|
||||
) =>
|
||||
[
|
||||
`cpu ${agg[0]} 0 0 ${agg[1]} 0 0 0 0 0 0\ncpu0 ${core[0]} 0 0 ${core[1]} 0 0 0 0 0 0`,
|
||||
"MemTotal: 1000 kB\nMemAvailable: 400 kB\nSwapTotal: 0 kB\nSwapFree: 0 kB",
|
||||
`0.1 0.2 0.3 1/2 3\n${up} 5`,
|
||||
`Inter-|\n eth0: ${rx} 0 0 0 0 0 0 0 ${rx * 2} 0\n lo: 9 0 0 0 0 0 0 0 9 0`,
|
||||
page,
|
||||
procs,
|
||||
].join("\n@@\n");
|
||||
|
||||
const proc = (pid: number, ticks: number, start: number, name = "java") =>
|
||||
`${pid}\t${ticks}\t10\t${start}\tR\t1\t${name}\n`;
|
||||
|
||||
describe("parseLive extras", () => {
|
||||
it("reports state, parent, CPU mix, cache and the running count", () => {
|
||||
const mk = (user: number, sys: number, wait: number, idle: number) =>
|
||||
out([user, idle], [user, idle], 1, 10, procs).replace(
|
||||
/cpu {2}\d+ 0 0 \d+ 0 0 0 0/,
|
||||
`cpu ${user} 0 ${sys} ${idle} ${wait} 0 0 0`,
|
||||
);
|
||||
const procs = "5\t1\t10\t7\tR\t2\tjava\n6\t1\t10\t8\tS\t5\tsh\n";
|
||||
const a = parseLive(mk(0, 0, 0, 0), undefined, 0);
|
||||
const b = parseLive(mk(30, 10, 10, 50), a.next, 1000);
|
||||
expect(b.sample.cpuMix).toMatchObject({ user: 30, system: 10, iowait: 10 });
|
||||
expect(b.sample.running).toBe(1);
|
||||
expect(b.sample.procs.find((p) => p.pid === 6)).toMatchObject({
|
||||
state: "S",
|
||||
ppid: 5,
|
||||
});
|
||||
expect(b.sample.memCachedKb).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("liveCmd", () => {
|
||||
it("asks for the page size once, then passes the cached number", () => {
|
||||
expect(liveCmd()).toContain('pg="$(getconf PAGESIZE)"');
|
||||
expect(liveCmd(16384)).toContain("pg=16384 ");
|
||||
expect(liveCmd(16384)).not.toContain("getconf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLive", () => {
|
||||
it("has no rates on the first sample", () => {
|
||||
const { sample } = parseLive(
|
||||
out([100, 100], [100, 100], 1000, 10, proc(7, 0, 1)),
|
||||
undefined,
|
||||
);
|
||||
expect(sample.cpu).toBeNull();
|
||||
expect(sample.net[0].rxBps).toBeNull();
|
||||
expect(sample.memUsedKb).toBe(600);
|
||||
});
|
||||
|
||||
it("derives cpu, per-core, net and process rates from the delta", () => {
|
||||
const a = parseLive(
|
||||
out([100, 100], [100, 100], 1000, 10, proc(7, 0, 1)),
|
||||
undefined,
|
||||
);
|
||||
// aggregate: +50 busy, +50 idle; core 0: +25 busy, +75 idle
|
||||
const { sample } = parseLive(
|
||||
out([150, 150], [125, 175], 3000, 11, proc(7, 50, 1)),
|
||||
a.next,
|
||||
);
|
||||
expect(sample.cpu).toBeCloseTo(50, 0);
|
||||
expect(sample.cores[0]).toBeCloseTo(25, 0);
|
||||
expect(sample.net).toEqual([{ name: "eth0", rxBps: 2000, txBps: 4000 }]);
|
||||
expect(sample.procs[0]).toMatchObject({ pid: 7, name: "java", memKb: 40 });
|
||||
expect(sample.procs[0].cpu).toBeCloseTo(50, 0);
|
||||
});
|
||||
|
||||
it("does not charge a reused pid with the old process's ticks", () => {
|
||||
const a = parseLive(
|
||||
out([100, 100], [100, 100], 1000, 10, proc(7, 5, 1)),
|
||||
undefined,
|
||||
);
|
||||
// same pid, new start time, lower ticks must not look like a huge delta
|
||||
const { sample } = parseLive(
|
||||
out([150, 150], [125, 175], 3000, 11, proc(7, 9000, 2)),
|
||||
a.next,
|
||||
);
|
||||
expect(sample.procs[0].cpu).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the cached page size when a run omits it, and keeps names with tabs", () => {
|
||||
const a = parseLive(
|
||||
out([1, 1], [1, 1], 1, 10, proc(1, 0, 1), "16384"),
|
||||
undefined,
|
||||
);
|
||||
expect(a.next.pageBytes).toBe(16384);
|
||||
const b = parseLive(
|
||||
out([2, 2], [2, 2], 2, 11, proc(1, 0, 1, "a b"), ""),
|
||||
a.next,
|
||||
);
|
||||
expect(b.next.pageBytes).toBe(16384);
|
||||
expect(b.sample.procs[0]).toMatchObject({ name: "a\tb", memKb: 160 });
|
||||
});
|
||||
|
||||
it("starts rates again after a reboot", () => {
|
||||
const a = parseLive(
|
||||
out([100, 100], [100, 100], 5000, 900, proc(7, 0, 1)),
|
||||
undefined,
|
||||
);
|
||||
const { sample } = parseLive(
|
||||
out([10, 10], [10, 10], 20, 5, proc(7, 0, 1)),
|
||||
a.next,
|
||||
);
|
||||
expect(sample.cpu).toBeNull();
|
||||
expect(sample.net[0].rxBps).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isUnit,
|
||||
journalArgs,
|
||||
parseBoots,
|
||||
parseDetect,
|
||||
parseEntry,
|
||||
parseIsActive,
|
||||
parseJournal,
|
||||
parseJournalQuery,
|
||||
parseOpenRcList,
|
||||
parseSystemdTime,
|
||||
parseTimers,
|
||||
parseUnitList,
|
||||
parseUsage,
|
||||
shellQuote as q,
|
||||
vacuumScript,
|
||||
} from "@termix-ssh/plugin-sdk/host-scripts";
|
||||
|
||||
describe("names", () => {
|
||||
it("quotes for bash", () => {
|
||||
expect(q("a'b")).toBe(`'a'\\''b'`);
|
||||
});
|
||||
|
||||
it("takes unit names and refuses anything else", () => {
|
||||
expect(isUnit("nginx.service")).toBe(true);
|
||||
expect(isUnit("getty@tty1.service")).toBe(true);
|
||||
expect(isUnit("dev-disk-by\\x2duuid-1.device")).toBe(true);
|
||||
expect(isUnit("-rf.service")).toBe(false);
|
||||
expect(isUnit("a;b.service")).toBe(false);
|
||||
expect(isUnit("a b.service")).toBe(false);
|
||||
expect(isUnit("nginx")).toBe(false);
|
||||
expect(isUnit("$(id).service")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("systemd parsing", () => {
|
||||
const list = [
|
||||
"cron.service loaded active running Regular background program",
|
||||
"● bad.service loaded failed failed Broken thing",
|
||||
"getty@tty1.service loaded active running Getty on tty1",
|
||||
"@@TERMIX_FILES@@",
|
||||
"cron.service enabled enabled",
|
||||
"bad.service disabled enabled",
|
||||
"getty@.service enabled enabled",
|
||||
"old.service masked -",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
it("merges loaded units with unit files and skips templates", () => {
|
||||
const units = parseUnitList(list);
|
||||
expect(units.map((u) => u.name)).toEqual([
|
||||
"bad.service",
|
||||
"cron.service",
|
||||
"getty@tty1.service",
|
||||
"old.service",
|
||||
]);
|
||||
const cron = units.find((u) => u.name === "cron.service")!;
|
||||
expect(cron).toMatchObject({
|
||||
active: "active",
|
||||
sub: "running",
|
||||
description: "Regular background program",
|
||||
fileState: "enabled",
|
||||
preset: "enabled",
|
||||
});
|
||||
expect(units.find((u) => u.name === "bad.service")!.active).toBe("failed");
|
||||
expect(units.find((u) => u.name === "old.service")).toMatchObject({
|
||||
load: "masked",
|
||||
active: "inactive",
|
||||
preset: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads UTC times", () => {
|
||||
expect(parseSystemdTime("Thu 2026-10-22 02:03:51 UTC")).toBe(
|
||||
Date.UTC(2026, 9, 22, 2, 3, 51),
|
||||
);
|
||||
expect(parseSystemdTime("")).toBeNull();
|
||||
expect(parseSystemdTime("n/a")).toBeNull();
|
||||
});
|
||||
|
||||
it("reads timers from systemctl show blocks", () => {
|
||||
const out = [
|
||||
"TimersCalendar={ OnCalendar=*-*-* 06,18:00:00 ; next_elapse=@1792605600 }",
|
||||
"NextElapseUSecRealtime=Thu 2026-10-22 02:03:51 UTC",
|
||||
"LastTriggerUSec=Wed 2026-10-21 17:02:58 UTC",
|
||||
"Id=apt-daily.timer",
|
||||
"Triggers=apt-daily.service",
|
||||
"ActiveState=active",
|
||||
"",
|
||||
"TimersMonotonic={ OnUnitActiveUSec=1d ; next_elapse=1d }",
|
||||
"TimersMonotonic={ OnBootUSec=15min ; next_elapse=15min }",
|
||||
"NextElapseUSecRealtime=",
|
||||
"LastTriggerUSec=",
|
||||
"Id=clean.timer",
|
||||
"Triggers=clean.service",
|
||||
].join("\n");
|
||||
const timers = parseTimers(out);
|
||||
expect(timers[0]).toMatchObject({
|
||||
name: "apt-daily.timer",
|
||||
activates: "apt-daily.service",
|
||||
next: Date.UTC(2026, 9, 22, 2, 3, 51),
|
||||
schedule: ["OnCalendar=*-*-* 06,18:00:00"],
|
||||
});
|
||||
expect(timers[1]).toMatchObject({
|
||||
name: "clean.timer",
|
||||
next: null,
|
||||
last: null,
|
||||
schedule: ["OnUnitActiveUSec=1d", "OnBootUSec=15min"],
|
||||
});
|
||||
});
|
||||
|
||||
it("maps is-active lines to units", () => {
|
||||
const states = parseIsActive(
|
||||
["a.service", "b.service"],
|
||||
"active\nfailed\n",
|
||||
);
|
||||
expect(states.get("a.service")).toBe("active");
|
||||
expect(states.get("b.service")).toBe("failed");
|
||||
});
|
||||
|
||||
it("reads what detect prints", () => {
|
||||
expect(
|
||||
parseDetect(
|
||||
"init=systemd\nversion=257\nos=Debian GNU/Linux 13 (trixie)\nuid=0\njournal=1\nfailed=2\n",
|
||||
),
|
||||
).toMatchObject({
|
||||
init: "systemd",
|
||||
version: "257",
|
||||
root: true,
|
||||
journal: true,
|
||||
failed: 2,
|
||||
});
|
||||
expect(parseDetect("init=weird\nuid=1000\n")).toMatchObject({
|
||||
init: "none",
|
||||
root: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("journal", () => {
|
||||
it("checks filters", () => {
|
||||
const query = parseJournalQuery({
|
||||
unit: ["nginx.service", "ssh.service"],
|
||||
priority: "3",
|
||||
since: "-1h",
|
||||
boot: "-1",
|
||||
grep: "fail'ed",
|
||||
lines: "99999",
|
||||
});
|
||||
expect(query.lines).toBe(5000);
|
||||
const args = journalArgs(query);
|
||||
expect(args).toContain("-u 'nginx.service' -u 'ssh.service'");
|
||||
expect(args).toContain("-p 3");
|
||||
expect(args).toContain("--since '-1h'");
|
||||
expect(args).toContain("-b '-1'");
|
||||
expect(args).toContain(`-g 'fail'\\''ed'`);
|
||||
expect(() => parseJournalQuery({ unit: "x;rm" })).toThrow();
|
||||
expect(() => parseJournalQuery({ priority: "9" })).toThrow();
|
||||
expect(() => parseJournalQuery({ since: "$(id)" })).toThrow();
|
||||
expect(() => parseJournalQuery({ boot: "abc" })).toThrow();
|
||||
});
|
||||
|
||||
it("parses entries, binary messages included", () => {
|
||||
const entry = parseEntry(
|
||||
JSON.stringify({
|
||||
__REALTIME_TIMESTAMP: "1792612429684000",
|
||||
PRIORITY: "3",
|
||||
_SYSTEMD_UNIT: "x.service",
|
||||
SYSLOG_IDENTIFIER: "x",
|
||||
_PID: "12",
|
||||
MESSAGE: [104, 105],
|
||||
}),
|
||||
);
|
||||
expect(entry).toEqual({
|
||||
time: 1792612429684,
|
||||
priority: 3,
|
||||
unit: "x.service",
|
||||
ident: "x",
|
||||
pid: "12",
|
||||
message: "hi",
|
||||
});
|
||||
expect(parseEntry("not json")).toBeNull();
|
||||
});
|
||||
|
||||
it("filters here when journalctl has no -g", () => {
|
||||
const line = (message: string) =>
|
||||
JSON.stringify({ __REALTIME_TIMESTAMP: "1000", MESSAGE: message });
|
||||
const out = `@@TERMIX_NOGREP@@\n${line("Disk Error")}\n${line("ok")}\n`;
|
||||
const query = parseJournalQuery({ grep: "error", lines: "10" });
|
||||
expect(parseJournal(out, query).map((e) => e.message)).toEqual([
|
||||
"Disk Error",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads boots and disk use", () => {
|
||||
const boots = parseBoots(
|
||||
[
|
||||
"IDX BOOT ID FIRST ENTRY LAST ENTRY",
|
||||
"-1 d2785a657f4c4561a1f436db27555b50 Tue 2026-10-20 13:56:45 UTC Wed 2026-10-21 10:37:00 UTC",
|
||||
" 0 61b15588a75b4c0cb8dea321fcc438ab Wed 2026-10-21 10:38:10 UTC Wed 2026-10-21 20:00:00 UTC",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(boots.map((b) => b.offset)).toEqual([0, -1]);
|
||||
expect(boots[0].first).toBe("Wed 2026-10-21 10:38:10 UTC");
|
||||
expect(
|
||||
parseUsage(
|
||||
"Archived and active journals take up 378.6M in the file system.",
|
||||
),
|
||||
).toBe("378.6M");
|
||||
});
|
||||
|
||||
it("only takes sane vacuum values", () => {
|
||||
expect(vacuumScript({ size: "500M" })).toContain("--vacuum-size=500M");
|
||||
expect(vacuumScript({ time: "4w" })).toContain("--vacuum-time=4w");
|
||||
expect(() => vacuumScript({ size: "1; rm -rf /" })).toThrow();
|
||||
expect(() => vacuumScript({})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("openrc", () => {
|
||||
it("maps services and runlevels", () => {
|
||||
const units = parseOpenRcList(
|
||||
[
|
||||
" sshd [ started ]",
|
||||
" crond [ stopped ]",
|
||||
" nginx [ crashed ]",
|
||||
"@@TERMIX_RUNLEVELS@@",
|
||||
" sshd | default",
|
||||
" nginx | ",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(units.find((u) => u.name === "sshd")).toMatchObject({
|
||||
active: "active",
|
||||
fileState: "enabled",
|
||||
});
|
||||
expect(units.find((u) => u.name === "nginx")).toMatchObject({
|
||||
active: "failed",
|
||||
fileState: "disabled",
|
||||
});
|
||||
expect(units.find((u) => u.name === "crond")!.active).toBe("inactive");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
actionScript,
|
||||
errLine,
|
||||
isActiveScript,
|
||||
listUnitsScript,
|
||||
openRcActionScript,
|
||||
parseKeys,
|
||||
passwordSudoScript,
|
||||
scriptError,
|
||||
scriptShell,
|
||||
SUDO_REFUSED_RE,
|
||||
SUDO_WRONG_RE,
|
||||
toBase64,
|
||||
utf8Decode,
|
||||
utf8Encode,
|
||||
watchdog,
|
||||
} from "@termix-ssh/plugin-sdk/host-scripts";
|
||||
|
||||
describe("text helpers", () => {
|
||||
it("encodes base64 like Buffer does", () => {
|
||||
for (const text of [
|
||||
"",
|
||||
"a",
|
||||
"ab",
|
||||
"abc",
|
||||
"héllo wörld",
|
||||
"emoji 🙂 here",
|
||||
"line\nbreak",
|
||||
]) {
|
||||
expect(toBase64(text)).toBe(Buffer.from(text, "utf8").toString("base64"));
|
||||
}
|
||||
});
|
||||
|
||||
it("round trips UTF-8 and replaces bad bytes", () => {
|
||||
const text = "plain, ünïcode, 日本, 🙂";
|
||||
expect(utf8Decode(utf8Encode(text))).toBe(text);
|
||||
expect(Array.from(utf8Encode(text))).toEqual(
|
||||
Array.from(Buffer.from(text, "utf8")),
|
||||
);
|
||||
expect(utf8Decode([0x61, 0xff, 0x62])).toBe("a\ufffdb");
|
||||
expect(utf8Decode([0xe6, 0x97])).toBe("\ufffd");
|
||||
});
|
||||
|
||||
it("reads key=value lines", () => {
|
||||
expect(parseKeys("a=1\nb = two=2\njunk\n")).toEqual({ a: "1", b: "two=2" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("script shells", () => {
|
||||
it("always reads the script from stdin", () => {
|
||||
for (const mode of ["plain", "auto", "root"] as const) {
|
||||
expect(scriptShell(mode)).toContain("bash -s");
|
||||
}
|
||||
expect(scriptShell("auto")).toContain("sudo -n true");
|
||||
expect(scriptShell("root-only")).toContain("SCRIPT_ERR:");
|
||||
});
|
||||
|
||||
it("collects error lines, old and new markers", () => {
|
||||
expect(scriptError("x\nSCRIPT_ERR:one\nSERVICES_ERR:two\n")).toBe(
|
||||
"one\ntwo",
|
||||
);
|
||||
expect(errLine("it's broken")).toBe(`echo 'SCRIPT_ERR:it'\\''s broken'`);
|
||||
});
|
||||
|
||||
it("matches sudo refusals", () => {
|
||||
expect(SUDO_REFUSED_RE.test("sudo: a password is required")).toBe(true);
|
||||
expect(SUDO_WRONG_RE.test("sudo: 1 incorrect password attempt")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the sudo password out of the command line", () => {
|
||||
const { command, stdin } = passwordSudoScript(
|
||||
"systemctl restart 'a.service'\n",
|
||||
"s3cr'et $(id)",
|
||||
);
|
||||
expect(command).not.toContain("s3cr");
|
||||
expect(stdin.split("\n")[0]).toBe("IFS= read -r TERMIX_PW");
|
||||
expect(stdin.split("\n")[1]).toBe("s3cr'et $(id)");
|
||||
expect(stdin).toContain("sudo -k -S -p ''");
|
||||
expect(stdin).not.toContain("systemctl restart");
|
||||
expect(() => passwordSudoScript("x", "a\nb")).toThrow();
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"runs the script and never runs the password",
|
||||
() => {
|
||||
const { stdin } = passwordSudoScript(
|
||||
'echo "ran:$1${TERMIX_PW:-none}"\n',
|
||||
"touch /tmp/should-not-exist",
|
||||
);
|
||||
// No sudo here: swap it for a fake that drops its own stdin line.
|
||||
const fake = stdin.replace(
|
||||
/printf '%s\\n' "\$P" \| sudo -k -S -p '' env PATH="\$PATH" [^\n]* bash -c "\$TERMIX_S"/,
|
||||
'printf "%s\\n" "$P" | { IFS= read -r _; bash -c "$TERMIX_S"; }',
|
||||
);
|
||||
expect(fake).not.toBe(stdin);
|
||||
const out = execFileSync(
|
||||
"bash",
|
||||
["-c", "sudo() { return 1; }; export -f sudo; exec bash -s"],
|
||||
{
|
||||
input: fake,
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(out.trim()).toBe("ran:none");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("unit scripts", () => {
|
||||
it("quotes units and refuses bad names", () => {
|
||||
expect(actionScript("a.service", "enable-now")).toContain(
|
||||
"systemctl enable --now -- 'a.service'",
|
||||
);
|
||||
expect(() => actionScript("a;b.service", "start")).toThrow();
|
||||
expect(isActiveScript(["a.service", "b.timer"])).toContain(
|
||||
"-- 'a.service' 'b.timer'",
|
||||
);
|
||||
expect(() => listUnitsScript("bogus" as never)).toThrow();
|
||||
expect(openRcActionScript("sshd", "enable")).toContain(
|
||||
"rc-update add 'sshd' default",
|
||||
);
|
||||
expect(() => openRcActionScript("sshd", "mask")).toThrow();
|
||||
});
|
||||
|
||||
it("stops a watched command once stdin closes", () => {
|
||||
expect(watchdog("journalctl -f")).toContain("cat >/dev/null");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user