diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index 5479f4c5c..c85e0b687 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.0.21 + +Plugin API 1.12.0. + +### Added + +- `@termix-ssh/plugin-sdk/host-scripts`: Docker, Linux metrics, systemd, journal and OpenRC command builders and parsers, plus `scriptShell()` and `passwordSudoScript()` to send scripts to `bash -s` on stdin. It has no Node or ssh2 imports, so the Termix mobile app runs the same code + ## 1.0.20 Plugin API 1.12.0. diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index 59ab078fe..5777831cb 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -1,6 +1,6 @@ # Termix plugin guide -SDK 1.0.20 | Plugin API 1.12.0 +SDK 1.0.21 | Plugin API 1.12.0 This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside `@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed. @@ -335,7 +335,9 @@ try { `ctx.ssh.withConnection(hostId, { pool, purpose }, fn)` borrows a pooled connection for frequent short commands. `@termix-ssh/plugin-sdk/host-commands` has helpers to detect the OS and run package manager and -sudo commands. Hosts from `ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`. +sudo commands. `@termix-ssh/plugin-sdk/host-scripts` builds Docker, metrics, systemd and journal +commands and parses their output; send a script on stdin to the command `scriptShell()` returns. Hosts from +`ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`. ### The desktop app @@ -905,6 +907,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie | `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` | | `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` | | `@termix-ssh/plugin-sdk/host-commands` | OS detection, package manager and sudo helpers over ssh2 | +| `@termix-ssh/plugin-sdk/host-scripts` | Docker, metrics, systemd and journal command builders and parsers, scripts sent to `bash -s` | | `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth | | `@termix-ssh/plugin-sdk/ssh-keys` | `generateSshKeyPair()`, `detectSshKeyType()`, `publicKeyFromPrivate()`, `validateSshKeyPair()` | | `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use | diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 684db9831..5a9ee7651 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@termix-ssh/plugin-sdk", - "version": "1.0.20", + "version": "1.0.21", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "license": "Apache-2.0", "repository": { @@ -81,6 +81,10 @@ "types": "./dist/host-commands.d.ts", "default": "./dist/host-commands.js" }, + "./host-scripts": { + "types": "./dist/host-scripts/index.d.ts", + "default": "./dist/host-scripts/index.js" + }, "./ssh-certs": { "types": "./dist/ssh-certs.d.ts", "default": "./dist/ssh-certs.js" diff --git a/packages/plugin-sdk/src/host-commands.ts b/packages/plugin-sdk/src/host-commands.ts index b526cf945..c131faa57 100644 --- a/packages/plugin-sdk/src/host-commands.ts +++ b/packages/plugin-sdk/src/host-commands.ts @@ -11,6 +11,9 @@ export type { Client, ClientChannel } from "ssh2"; import type { Client, ClientChannel } from "ssh2"; +// Homebrew and OrbStack paths a non-interactive shell misses. +import { EXTRA_PATH_DIRS } from "./host-scripts/docker.js"; +export { EXTRA_PATH_DIRS }; export interface ExecResult { stdout: string; @@ -114,14 +117,6 @@ export interface PlatformInfo { osPrettyName: string | null; } -/** - * Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile, - * so PATH additions from installers like Homebrew or OrbStack are missing. - * Extend PATH with their common install locations before probing. - */ -export const EXTRA_PATH_DIRS = - '/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"'; - const DOCKER_PROBE = `command -v docker >/dev/null 2>&1` + ` || [ -S /var/run/docker.sock ]` + diff --git a/packages/plugin-sdk/src/host-scripts/docker.ts b/packages/plugin-sdk/src/host-scripts/docker.ts new file mode 100644 index 000000000..efb80eabc --- /dev/null +++ b/packages/plugin-sdk/src/host-scripts/docker.ts @@ -0,0 +1,468 @@ +/** + * Docker and Podman command builders and output parsers. + * + * Container ids and names are checked with CONTAINER_ID_RE before they go on + * a command line, so they need no quoting. Anything free form is quoted. + */ +import { HostScriptError } from "./text.js"; + +export type ContainerRuntime = "docker" | "podman"; + +export function normalizeContainerRuntime(value: unknown): ContainerRuntime { + return value === "podman" ? "podman" : "docker"; +} + +export function getRuntimeLabel(runtime: ContainerRuntime): string { + return runtime === "podman" ? "Podman" : "Docker"; +} + +/** + * Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile, + * so PATH additions from installers like Homebrew or OrbStack are missing. + */ +export const EXTRA_PATH_DIRS = + '/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"'; + +export function containerCommand( + runtime: ContainerRuntime | undefined, + args: string, + isWindows = false, +): string { + const cli = `${normalizeContainerRuntime(runtime)} ${args}`; + // cmd and PowerShell don't understand the POSIX env prefix. + return isWindows ? cli : `PATH="${EXTRA_PATH_DIRS}:$PATH" ${cli}`; +} + +export const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/; +export const DOCKER_TIMESTAMP_RE = /^[0-9T:.Z+-]+$/; + +export function isContainerId(value: unknown): value is string { + return ( + typeof value === "string" && + value.length <= 256 && + CONTAINER_ID_RE.test(value) + ); +} + +function requireContainer(id: string): string { + if (!isContainerId(id)) + throw new HostScriptError("That is not a valid container id"); + return id; +} + +export const CONTAINER_ACTIONS = [ + "start", + "stop", + "restart", + "pause", + "unpause", +] as const; +export type ContainerAction = (typeof CONTAINER_ACTIONS)[number]; + +const LIST_FORMAT_UNIX = `'{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}' `; +const LIST_FORMAT_WINDOWS = `"{\\"id\\":\\"{{.ID}}\\",\\"name\\":\\"{{.Names}}\\",\\"image\\":\\"{{.Image}}\\",\\"status\\":\\"{{.Status}}\\",\\"state\\":\\"{{.State}}\\",\\"ports\\":\\"{{.Ports}}\\",\\"created\\":\\"{{.CreatedAt}}\\"}"`; +const STATS_FORMAT_UNIX = `'{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}' `; +const STATS_FORMAT_WINDOWS = `"{\\"cpu\\":\\"{{.CPUPerc}}\\",\\"memory\\":\\"{{.MemUsage}}\\",\\"memoryPercent\\":\\"{{.MemPerc}}\\",\\"netIO\\":\\"{{.NetIO}}\\",\\"blockIO\\":\\"{{.BlockIO}}\\",\\"pids\\":\\"{{.PIDs}}\\"}"`; + +export function listFormat(isWindows: boolean): string { + return isWindows ? LIST_FORMAT_WINDOWS : LIST_FORMAT_UNIX; +} + +export function statsFormat(isWindows: boolean): string { + return isWindows ? STATS_FORMAT_WINDOWS : STATS_FORMAT_UNIX; +} + +/** One row of `ps --format listFormat()`. */ +export interface ContainerRow { + id: string; + name: string; + image: string; + status: string; + state: string; + ports: string; + created: string; +} + +/** CLI arguments, without the runtime, for each container command. */ +export const dockerArgs = { + version: () => "--version", + ping: () => "ps", + list: (all: boolean, isWindows = false) => + `ps ${all ? "-a " : ""}--format ${listFormat(isWindows)}`, + inspect: (id: string) => `inspect ${requireContainer(id)}`, + stats: (id: string, isWindows = false) => + `stats ${requireContainer(id)} --no-stream --format ${statsFormat(isWindows)}`, + action: (action: ContainerAction, id: string) => { + if (!CONTAINER_ACTIONS.includes(action)) + throw new HostScriptError("Unknown action"); + return `${action} ${requireContainer(id)}`; + }, + remove: (id: string, force: boolean) => + `rm ${force ? "-f " : ""}${requireContainer(id)}`, + logs: ( + id: string, + options: { + tail?: number; + timestamps?: boolean; + since?: string; + until?: string; + } = {}, + ) => { + let args = `logs ${requireContainer(id)}`; + if (options.tail && options.tail > 0) + args += ` --tail ${Math.floor(options.tail)}`; + if (options.timestamps) args += " --timestamps"; + if (options.since && DOCKER_TIMESTAMP_RE.test(options.since)) { + args += ` --since ${options.since}`; + } + if (options.until && DOCKER_TIMESTAMP_RE.test(options.until)) { + args += ` --until ${options.until}`; + } + return `${args} 2>&1`; + }, + states: () => `ps -a --format ${PS_STATE_FORMAT}`, + composeProjects: () => "compose ls -a --format json", +}; + +/** The version from `docker --version` or `podman --version`. */ +export function parseRuntimeVersion(output: string): string { + return output.match(/(?:Docker|podman) version ([^\s,]+)/i)?.[1] ?? "unknown"; +} + +export type RuntimeProblem = + "NOT_INSTALLED" | "DAEMON_NOT_RUNNING" | "PERMISSION_DENIED" | "DOCKER_ERROR"; + +/** Sorts a failed `docker ps` into a reason the UI can explain. */ +export function classifyRuntimeError(message: string): RuntimeProblem { + if ( + /command not found|not found: (docker|podman)|No such file or directory/i.test( + message, + ) + ) { + return "NOT_INSTALLED"; + } + if (message.includes("Cannot connect to the Docker daemon")) + return "DAEMON_NOT_RUNNING"; + if (/permission denied/i.test(message)) return "PERMISSION_DENIED"; + return "DOCKER_ERROR"; +} + +/** Parses `ps --format listFormat()` output, skipping lines that are not JSON. */ +export function parseContainerList(output: string): Record[] { + return output + .split("\n") + .filter((line) => line.trim()) + .flatMap((line) => { + try { + return [JSON.parse(line) as Record]; + } catch { + return []; + } + }); +} + +export interface ContainerStats { + cpu: string; + memoryUsed: string; + memoryLimit: string; + memoryPercent: string; + netInput: string; + netOutput: string; + blockRead: string; + blockWrite: string; + pids: string; +} + +/** Parses one `stats --no-stream --format statsFormat()` line. Throws on bad JSON. */ +export function parseContainerStats(output: string): ContainerStats { + const raw = JSON.parse(output.trim()) as Record; + const split = (value: string | undefined) => { + const [first, second] = (value ?? "").split(" / "); + return [first?.trim() || "0B", second?.trim() || "0B"]; + }; + const [memoryUsed, memoryLimit] = split(raw.memory); + const [netInput, netOutput] = split(raw.netIO); + const [blockRead, blockWrite] = split(raw.blockIO); + return { + cpu: raw.cpu, + memoryUsed, + memoryLimit, + memoryPercent: raw.memoryPercent, + netInput, + netOutput, + blockRead, + blockWrite, + pids: raw.pids, + }; +} + +export interface ContainerState { + /** Docker's own state word: running, exited, restarting, ... */ + state: string; + /** Health from the status text, when the image declares a healthcheck. */ + unhealthy: boolean; +} + +export type DockerEventName = "exited" | "started" | "unhealthy" | "restarting"; + +export const PS_STATE_FORMAT = `'{"name":"{{.Names}}","state":"{{.State}}","status":"{{.Status}}"}'`; + +/** Parses `ps -a --format PS_STATE_FORMAT` output, one JSON object per line. */ +export function parseContainerStates( + output: string, +): Map { + const states = new Map(); + for (const line of output.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const parsed = JSON.parse(trimmed) as { + name?: string; + state?: string; + status?: string; + }; + if (!parsed.name) continue; + states.set(parsed.name, { + state: (parsed.state ?? "").toLowerCase(), + unhealthy: /\(unhealthy\)/i.test(parsed.status ?? ""), + }); + } catch { + // A partial line is not worth failing the whole poll over. + } + } + return states; +} + +/** + * The events a pair of snapshots implies. A container missing from the + * previous snapshot counts as newly seen rather than started, so the first + * poll after a restart does not replay every running container. + */ +export function diffContainerStates( + previous: Map, + current: Map, +): Array<{ container: string; event: DockerEventName }> { + const events: Array<{ container: string; event: DockerEventName }> = []; + for (const [name, now] of current) { + const before = previous.get(name); + if (!before) continue; + if (before.state !== now.state) { + if (now.state === "exited") + events.push({ container: name, event: "exited" }); + else if (now.state === "running") + events.push({ container: name, event: "started" }); + else if (now.state === "restarting") { + events.push({ container: name, event: "restarting" }); + } + } + // A container can go unhealthy while it stays up. + if (!before.unhealthy && now.unhealthy) { + events.push({ container: name, event: "unhealthy" }); + } + } + return events; +} + +export interface ContainerCounts { + running: number; + stopped: number; + unhealthy: number; + total: number; +} + +/** Counts from `ps -a --format PS_STATE_FORMAT` output. */ +export function countContainers(output: string): ContainerCounts { + const states = [...parseContainerStates(output).values()]; + const running = states.filter((s) => s.state === "running").length; + return { + running, + stopped: states.length - running, + unhealthy: states.filter((s) => s.unhealthy).length, + total: states.length, + }; +} + +// Compose stacks + +export const STACK_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/; +export const STACK_SERVICE_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,126}$/; +export const COMPOSE_FILES = [ + "compose.yaml", + "compose.yml", + "docker-compose.yaml", + "docker-compose.yml", +]; + +export const STACK_ACTIONS = [ + "deploy", + "redeploy", + "pull", + "start", + "stop", + "restart", + "pause", + "unpause", + "down", + "destroy", +] as const; +export type StackAction = (typeof STACK_ACTIONS)[number]; + +/** Actions that can target one service of a stack. */ +export const STACK_SERVICE_ACTIONS: StackAction[] = [ + "deploy", + "pull", + "start", + "stop", + "restart", + "pause", + "unpause", +]; + +export type StackState = + "running" | "partial" | "stopped" | "paused" | "not-deployed"; + +export interface StackSummary { + name: string; + managed: boolean; + state: StackState; + running: number; + total: number; + composeFile: string | null; +} + +export interface StackService { + service: string; + name: string; + id: string; + image: string; + state: string; + status: string; + health: string; + ports: string; +} + +/** `compose ls` and `compose ps --format json` print one object per line or one array. */ +export function parseJsonLines(output: string): Record[] { + const text = output.trim(); + if (!text) return []; + if (text.startsWith("[")) { + try { + return JSON.parse(text) as Record[]; + } catch { + return []; + } + } + return text.split("\n").flatMap((line) => { + try { + return [JSON.parse(line) as Record]; + } catch { + return []; + } + }); +} + +const str = (value: unknown): string => + typeof value === "string" ? value : ""; + +export function parseServices(output: string): StackService[] { + return parseJsonLines(output).map((row) => { + const publishers = Array.isArray(row.Publishers) + ? (row.Publishers as Record[]) + : []; + const ports = publishers + .filter((p) => Number(p.PublishedPort) > 0) + .map((p) => `${p.PublishedPort}:${p.TargetPort}`) + .filter((value, index, all) => all.indexOf(value) === index) + .join(", "); + return { + service: str(row.Service), + name: str(row.Name), + id: str(row.ID), + image: str(row.Image), + state: str(row.State), + status: str(row.Status), + health: str(row.Health), + ports, + }; + }); +} + +function stateOf(running: number, paused: number, total: number): StackState { + if (total === 0) return "not-deployed"; + if (paused === total) return "paused"; + if (running === total) return "running"; + if (running > 0) return "partial"; + return "stopped"; +} + +/** Turns the `compose ls` status text ("running(2), exited(1)") into a state. */ +export function parseStackStatus(status: string): { + state: StackState; + running: number; + total: number; +} { + let running = 0; + let paused = 0; + let total = 0; + for (const part of status.split(",")) { + const match = part.trim().match(/^([a-z]+)\((\d+)\)$/i); + if (!match) continue; + const count = Number(match[2]); + total += count; + if (match[1] === "running") running += count; + if (match[1] === "paused") paused += count; + } + return { state: stateOf(running, paused, total), running, total }; +} + +export function stackStateFromServices(services: StackService[]) { + const total = services.length; + const running = services.filter((s) => s.state === "running").length; + const paused = services.filter((s) => s.state === "paused").length; + return { state: stateOf(running, paused, total), running, total }; +} + +/** Stacks from `compose ls -a --format json`, for hosts with no stacks folder. */ +export function parseComposeProjects(output: string): StackSummary[] { + return parseJsonLines(output) + .flatMap((project) => { + const name = str(project.Name); + if (!STACK_NAME_RE.test(name)) return []; + return [ + { + name, + managed: false, + ...parseStackStatus(str(project.Status)), + composeFile: str(project.ConfigFiles).split(",")[0] || null, + }, + ]; + }) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** The compose commands one action runs, in order. */ +export function composeArgsFor( + action: StackAction, + service: string | undefined, + options: { removeVolumes?: boolean }, +): string[] { + const target = service ? ` ${service}` : ""; + switch (action) { + case "deploy": + return [`up -d --remove-orphans${target}`]; + case "redeploy": + return [`pull${target}`, `up -d --remove-orphans${target}`]; + case "pull": + return [`pull${target}`]; + case "start": + case "stop": + case "restart": + case "pause": + case "unpause": + return [`${action}${target}`]; + case "down": + return ["down --remove-orphans"]; + case "destroy": + return [`down --remove-orphans${options.removeVolumes ? " -v" : ""}`]; + } +} diff --git a/packages/plugin-sdk/src/host-scripts/index.ts b/packages/plugin-sdk/src/host-scripts/index.ts new file mode 100644 index 000000000..548e55ef3 --- /dev/null +++ b/packages/plugin-sdk/src/host-scripts/index.ts @@ -0,0 +1,15 @@ +/** + * Host scripts: command builders and output parsers for Docker, Linux + * metrics, systemd, the journal and OpenRC. + * + * Pure TypeScript with no Node or ssh2 imports, so the same code runs in a + * plugin backend and in the mobile app. Run a script by sending it on stdin + * to the command from `scriptShell()`. + */ +export * from "./text.js"; +export * from "./shell.js"; +export * from "./docker.js"; +export * from "./metrics.js"; +export * from "./systemd.js"; +export * from "./journal.js"; +export * from "./openrc.js"; diff --git a/packages/plugin-sdk/src/host-scripts/journal.ts b/packages/plugin-sdk/src/host-scripts/journal.ts new file mode 100644 index 000000000..86fe5faff --- /dev/null +++ b/packages/plugin-sdk/src/host-scripts/journal.ts @@ -0,0 +1,242 @@ +/** journalctl scripts and parsers. */ +import { isUnit } from "./systemd.js"; +import { HostScriptError, shellQuote as q, utf8Decode } from "./text.js"; + +export const MAX_JOURNAL_LINES = 5000; +const FIELDS = "MESSAGE,PRIORITY,_SYSTEMD_UNIT,SYSLOG_IDENTIFIER,_PID,_COMM"; + +export interface JournalQuery { + units: string[]; + priority?: number; + since?: string; + until?: string; + boot?: string; + grep?: string; + lines: number; + kernel: boolean; +} + +export interface JournalEntry { + /** Unix ms. */ + time: number; + priority: number; + unit: string; + ident: string; + pid: string; + message: string; +} + +export interface BootView { + offset: number; + id: string; + first: string; + last: string; +} + +const RELATIVE_RE = /^-\d{1,5}(s|m|min|h|d|w)$/; +const DATE_RE = /^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/; +const WORDS = ["today", "yesterday", "now"]; + +export function isJournalTime(value: string): boolean { + return ( + RELATIVE_RE.test(value) || DATE_RE.test(value) || WORDS.includes(value) + ); +} + +function str(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +/** Reads journal filters from a query string. Anything odd throws a HostScriptError. */ +export function parseJournalQuery( + input: Record, +): JournalQuery { + const rawUnits = Array.isArray(input.unit) ? input.unit : [input.unit]; + const units = rawUnits.map(str).filter(Boolean); + for (const unit of units) { + if (!isUnit(unit)) + throw new HostScriptError("That is not a valid unit name"); + } + if (units.length > 20) throw new HostScriptError("Too many units"); + + const query: JournalQuery = { + units, + lines: 300, + kernel: input.kernel === "true" || input.kernel === true, + }; + const priority = str(input.priority); + if (priority) { + const value = Number(priority); + if (!Number.isInteger(value) || value < 0 || value > 7) { + throw new HostScriptError("priority must be 0 to 7"); + } + query.priority = value; + } + for (const key of ["since", "until"] as const) { + const value = str(input[key]); + if (!value) continue; + if (!isJournalTime(value)) + throw new HostScriptError(`${key} is not a valid time`); + query[key] = value; + } + const boot = str(input.boot); + if (boot) { + if (!/^(-?\d{1,4}|[0-9a-f]{32})$/.test(boot)) + throw new HostScriptError("boot is not valid"); + query.boot = boot; + } + const grep = typeof input.grep === "string" ? input.grep : ""; + if (grep) { + if (grep.length > 200 || /[\r\n\0]/.test(grep)) { + throw new HostScriptError("The search is too long"); + } + query.grep = grep; + } + const lines = Number(str(input.lines) || 300); + if (Number.isFinite(lines) && lines > 0) { + query.lines = Math.min(MAX_JOURNAL_LINES, Math.round(lines)); + } + return query; +} + +/** journalctl arguments for a query, each one quoted. */ +export function journalArgs( + query: Omit & { lines?: number }, + options: { follow?: boolean; useGrep?: boolean } = {}, +): string { + const args = ["journalctl", "--no-pager", "-q", "-o", "json"]; + args.push(`--output-fields=${FIELDS}`); + if (options.follow) args.push("-f"); + if (query.lines) args.push("-n", String(Math.floor(query.lines))); + for (const unit of query.units) { + if (!isUnit(unit)) + throw new HostScriptError("That is not a valid unit name"); + args.push("-u", q(unit)); + } + if (query.kernel) args.push("-k"); + if (query.priority !== undefined) + args.push("-p", String(Math.floor(query.priority))); + if (query.since) args.push("--since", q(query.since)); + if (query.until) args.push("--until", q(query.until)); + if (query.boot) args.push("-b", q(query.boot)); + if (query.grep && options.useGrep !== false) { + args.push("--case-sensitive=false", "-g", q(query.grep)); + } + return args.join(" "); +} + +export function journalScript(query: JournalQuery): string { + // Older journalctl builds have no -g. Then the search runs on our side instead. + const noGrep = journalArgs( + { ...query, lines: query.grep ? MAX_JOURNAL_LINES : query.lines }, + { useGrep: false }, + ); + return ` +command -v journalctl >/dev/null 2>&1 || { echo "SCRIPT_ERR:This host has no journal"; exit 1; } +${journalArgs(query)} 2>/tmp/termix-journal.$$ +RC=$? +if [ $RC -ne 0 ] && grep -qi "pattern" /tmp/termix-journal.$$; then + rm -f /tmp/termix-journal.$$ + echo "@@TERMIX_NOGREP@@" + ${noGrep} + exit 0 +fi +cat /tmp/termix-journal.$$ >&2; rm -f /tmp/termix-journal.$$ +exit $RC +`; +} + +function messageOf(value: unknown): string { + if (typeof value === "string") return value; + if (Array.isArray(value)) { + return utf8Decode(value.filter((n): n is number => typeof n === "number")); + } + return value == null ? "" : String(value); +} + +/** One `journalctl -o json` line, or null when it is not an entry. */ +export function parseEntry(line: string): JournalEntry | null { + const text = line.trim(); + if (!text.startsWith("{")) return null; + let raw: Record; + try { + raw = JSON.parse(text) as Record; + } catch { + return null; + } + const micros = Number(raw.__REALTIME_TIMESTAMP); + const priority = Number(raw.PRIORITY); + return { + time: Number.isFinite(micros) ? Math.floor(micros / 1000) : 0, + priority: Number.isInteger(priority) ? priority : 6, + unit: messageOf(raw._SYSTEMD_UNIT), + ident: messageOf(raw.SYSLOG_IDENTIFIER ?? raw._COMM), + pid: messageOf(raw._PID), + message: messageOf(raw.MESSAGE), + }; +} + +export function parseJournal( + output: string, + query: JournalQuery, +): JournalEntry[] { + const fallback = output.includes("@@TERMIX_NOGREP@@"); + const body = fallback ? output.split("@@TERMIX_NOGREP@@")[1] : output; + let entries = body + .split("\n") + .map(parseEntry) + .filter((entry): entry is JournalEntry => entry !== null); + if (fallback && query.grep) { + const needle = query.grep.toLowerCase(); + entries = entries + .filter((entry) => entry.message.toLowerCase().includes(needle)) + .slice(-query.lines); + } + return entries; +} + +export const BOOTS_SCRIPT = `journalctl --list-boots --no-pager 2>/dev/null | tail -n 50; exit 0\n`; + +export function parseBoots(output: string): BootView[] { + const boots: BootView[] = []; + for (const line of output.split("\n")) { + const match = + /^\s*(-?\d+)\s+([0-9a-f]{32})\s+(\w{3} \S+ \S+ \S+)\s*.?\s*(\w{3} \S+ \S+ \S+)?/.exec( + line, + ); + if (!match) continue; + boots.push({ + offset: Number(match[1]), + id: match[2], + first: match[3], + last: match[4] ?? "", + }); + } + return boots.reverse(); +} + +export const USAGE_SCRIPT = `journalctl --disk-usage 2>&1; exit 0\n`; + +export function parseUsage(output: string): string { + return /take up (\S+)/.exec(output)?.[1] ?? ""; +} + +const SIZE_RE = /^\d{1,6}(K|M|G)$/; +const AGE_RE = /^\d{1,4}(d|w|month|months|year|years)$/; + +export function vacuumScript(body: Record): string { + const size = str(body.size); + const time = str(body.time); + if (!size && !time) throw new HostScriptError("Give a size or an age"); + if (size && !SIZE_RE.test(size)) + throw new HostScriptError("Size looks like 500M or 2G"); + if (time && !AGE_RE.test(time)) + throw new HostScriptError("Age looks like 7d or 4w"); + const flags = [ + size ? `--vacuum-size=${size}` : "", + time ? `--vacuum-time=${time}` : "", + ] + .filter(Boolean) + .join(" "); + return `journalctl ${flags} 2>&1 || { echo "SCRIPT_ERR:journalctl vacuum failed"; exit 1; }\n`; +} diff --git a/packages/plugin-sdk/src/host-scripts/metrics.ts b/packages/plugin-sdk/src/host-scripts/metrics.ts new file mode 100644 index 000000000..a130670f5 --- /dev/null +++ b/packages/plugin-sdk/src/host-scripts/metrics.ts @@ -0,0 +1,433 @@ +/** + * Linux metrics: one awk sample of /proc for CPU, memory, load, network and + * processes, plus df parsing for disks. + */ + +// One awk reads every /proc file itself, so a sample is one exec of awk instead +// of a shell plus six tools. Output sections are split by @@; each process line +// is pid, utime+stime ticks, rss pages, starttime, state, ppid, comm. +const AWK = String.raw`BEGIN{f="/proc/stat";while((getline l0)if(l~/^cpu/)print l;close(f);print "@@";f="/proc/meminfo";while((getline l0)if(l~/^(MemTotal|MemFree|MemAvailable|Buffers|Cached|SwapTotal|SwapFree):/)print l;close(f);print "@@";f="/proc/loadavg";getline l0)print l;close(f);print "@@";print pg;print "@@";for(i=1;i0){a=index(s,"(");if(a&&match(s,/\)[^)]*$/)){split(substr(s,RSTART+2),x," ");print substr(s,1,a-2)" "(x[12]+x[13])" "x[22]"\t"x[20]"\t"x[1]" "x[2]" "substr(s,a+1,RSTART-a-1)}}close(f)}}`; + +/** The live sample command. The page size never changes on a host, so pass it back once known. */ +export const liveCmd = (pageBytes?: number) => + "awk -v pg=" + + (pageBytes ?? '"$(getconf PAGESIZE)"') + + " '" + + AWK + + "' /proc/[0-9]*/stat"; + +export interface LiveSample { + /** The admin minimum between samples; the client never polls faster. */ + minIntervalS?: number; + cpu: number | null; + cpuMix?: { user: number; system: number; iowait: number; steal: number }; + cores: Array; + coreIds: string[]; + memTotalKb: number; + memCachedKb: number; + memFreeKb: number; + memUsedKb: number; + swapTotalKb: number; + swapUsedKb: number; + load: [number, number, number]; + net: Array<{ name: string; rxBps: number | null; txBps: number | null }>; + count: number; + running: number; + procs: Array<{ + pid: number; + name: string; + cpu: number; + memKb: number; + state: string; + ppid: number; + }>; + /** Seconds since boot. */ + uptime?: number; + /** Total bytes received and sent on every interface but lo, since boot. */ + netTotals?: { rx: number; tx: number }; +} + +/** What the next call needs from this one. */ +export interface LivePrev { + at: number; + uptime: number; + pageBytes: number; + cpu: Map; + /** Aggregate user nice system idle iowait irq softirq steal ticks, for the breakdown. */ + cpuRaw?: number[]; + /** Keyed by pid:starttime so a reused pid never inherits another process's ticks. */ + ticks: Map; + net: Map; +} + +/** Total and idle ticks from a /proc/stat `cpu` line. */ +export function parseCpuLine( + cpuLine: string, +): { total: number; idle: number } | undefined { + const parts = cpuLine.trim().split(/\s+/); + if (parts[0] !== "cpu") return undefined; + const nums = parts + .slice(1) + .map((n) => Number(n)) + .filter((n) => Number.isFinite(n)); + if (nums.length < 4) return undefined; + const idle = (nums[3] ?? 0) + (nums[4] ?? 0); + const total = nums.reduce((a, b) => a + b, 0); + return { total, idle }; +} + +const TOP_N = 60; + +/** Parses `liveCmd()` output. Rates need the `next` of the previous call. */ +export function parseLive( + output: string, + prev: LivePrev | undefined, + at = Date.now(), +): { sample: LiveSample; next: LivePrev } { + const [cpuS = "", memS = "", loadS = "", netS = "", pageS = "", procS = ""] = + output.split("@@\n"); + + // Only the first 8 fields: guest and guest_nice are already inside user and nice. + const cpu = new Map(); + let cpuRaw: number[] | undefined; + for (const l of cpuS.split("\n")) { + const name = l.split(/\s+/)[0]; + if (!name.startsWith("cpu")) continue; + if (name === "cpu") cpuRaw = l.split(/\s+/).slice(1, 9).map(Number); + const c = parseCpuLine( + l + .replace(/^cpu\d*/, "cpu") + .split(/\s+/) + .slice(0, 9) + .join(" "), + ); + if (c) cpu.set(name, c); + } + // Matched by name, so an offline core does not shift the ones after it. + const pct = (name: string) => { + const p = prev?.cpu.get(name); + const c = cpu.get(name); + if (!p || !c || c.total <= p.total) return null; + const used = c.total - p.total - (c.idle - p.idle); + return Math.max(0, Math.min(100, (used / (c.total - p.total)) * 100)); + }; + + // Where the busy time went since the last sample, as percent of all ticks. + let cpuMix: LiveSample["cpuMix"]; + if (prev?.cpuRaw && cpuRaw && cpuRaw.length === 8) { + const d = cpuRaw.map((v, i) => v - (prev.cpuRaw?.[i] ?? 0)); + const total = d.reduce((a, b) => a + b, 0); + if (total > 0 && d.every((v) => v >= 0)) { + const p = (n: number) => (n / total) * 100; + cpuMix = { + user: p(d[0] + d[1]), + system: p(d[2] + d[5] + d[6]), + iowait: p(d[4]), + steal: p(d[7]), + }; + } + } + + const mem: Record = {}; + for (const l of memS.split("\n")) { + const m = l.match(/^(\w+):\s+(\d+)/); + if (m) mem[m[1]] = Number(m[2]); + } + + const [la = "", up = ""] = loadS.trim().split("\n"); + const laParts = la.split(/\s+/).map(Number); + const uptime = Number(up.split(/\s+/)[0]) || 0; + + const nets = new Map(); + for (const l of netS.split("\n")) { + const [name, rest] = l.split(":"); + if (!rest || name.trim() === "lo") continue; + const f = rest.trim().split(/\s+/); + nets.set(name.trim(), { rx: Number(f[0]) || 0, tx: Number(f[8]) || 0 }); + } + // A reboot makes uptime go backwards, so rates start again. + const dt = prev && uptime > prev.uptime ? uptime - prev.uptime : 0; + const rate = (now: number, before: number | undefined) => + dt > 0 && before !== undefined && now >= before + ? (now - before) / dt + : null; + + const pageBytes = Number(pageS.trim()) || prev?.pageBytes || 4096; + const pageKb = pageBytes / 1024; + const aggDelta = + prev && cpu.has("cpu") && prev.cpu.has("cpu") + ? cpu.get("cpu")!.total - prev.cpu.get("cpu")!.total + : 0; + const cores = [...cpu.keys()].filter((k) => k !== "cpu"); + const ncpu = Math.max(1, cores.length); + const ticks = new Map(); + const procs: LiveSample["procs"] = []; + for (const l of procS.split("\n")) { + const [pid, t, rss, start, state, ppid, ...rest] = l.split("\t"); + const name = rest.join("\t"); + if (!name) continue; + const key = pid + ":" + start; + const tk = Number(t) || 0; + ticks.set(key, tk); + const before = prev?.ticks.get(key); + procs.push({ + pid: Number(pid), + name, + cpu: + before !== undefined && aggDelta > 0 && tk >= before + ? ((tk - before) / aggDelta) * ncpu * 100 + : 0, + memKb: (Number(rss) || 0) * pageKb, + state: state || "?", + ppid: Number(ppid) || 0, + }); + } + procs.sort((a, b) => b.cpu - a.cpu || b.memKb - a.memKb); + + // Awk exits 0 when /proc is missing (macOS, restricted containers); that is not a sample. + if (!cpu.has("cpu") || !mem.MemTotal || !uptime) { + throw new Error("live sample unavailable: /proc data missing"); + } + const memTotalKb = mem.MemTotal; + const swapTotalKb = mem.SwapTotal ?? 0; + let rxTotal = 0; + let txTotal = 0; + for (const c of nets.values()) { + rxTotal += c.rx; + txTotal += c.tx; + } + return { + sample: { + cpu: pct("cpu"), + cores: cores.map(pct), + coreIds: cores.map((k) => k.slice(3)), + cpuMix, + memTotalKb, + memCachedKb: (mem.Buffers ?? 0) + (mem.Cached ?? 0), + memFreeKb: mem.MemFree ?? 0, + memUsedKb: memTotalKb - (mem.MemAvailable ?? 0), + swapTotalKb, + swapUsedKb: swapTotalKb - (mem.SwapFree ?? 0), + load: [laParts[0] || 0, laParts[1] || 0, laParts[2] || 0], + net: [...nets].map(([name, c]) => ({ + name, + rxBps: rate(c.rx, prev?.net.get(name)?.rx), + txBps: rate(c.tx, prev?.net.get(name)?.tx), + })), + count: procs.length, + running: procs.filter((p) => p.state === "R").length, + procs: procs.slice(0, TOP_N), + uptime, + netTotals: { rx: rxTotal, tx: txTotal }, + }, + next: { at, uptime, pageBytes, cpu, cpuRaw, ticks, net: nets }, + }; +} + +// Disks + +const DARWIN_DATA_MOUNT = "/System/Volumes/Data"; +const PSEUDO_FS_RE = + /^(tmpfs|devtmpfs|overlay|udev|none|shm|squashfs|efivarfs)$/; + +/** GNU df sizes in KiB, without the header. */ +export const DF_BYTES_COMMAND = "df -TkP | tail -n +2"; +/** GNU df sizes as short human text, without the header. */ +export const DF_HUMAN_COMMAND = "df -hT -P | tail -n +2"; + +export interface DfRow { + filesystem: string; + type: string; + mount: string; + parts: string[]; +} + +export interface DiskFilesystem { + filesystem: string; + type: string; + mount: string; + percent: number | null; + usedHuman: string | null; + totalHuman: string | null; + availableHuman: string | null; + usedBytes: number | null; + totalBytes: number | null; + availableBytes: number | null; + label?: string; +} + +export interface MonitoredMount { + path: string; + label?: string; +} + +function toFixedNum(n: number | null | undefined, digits = 2): number | null { + if (typeof n !== "number" || !Number.isFinite(n)) return null; + return Number(n.toFixed(digits)); +} + +/** + * Parses `df -T -P` style output: Filesystem, Type, then the size columns, + * with Mounted-on last. Pseudo filesystems are left out. + */ +export function parseDfLines(output: string): DfRow[] { + return output + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .map((line) => { + const parts = line.split(/\s+/); + return { + filesystem: parts[0] || "", + type: parts[1] || "", + mount: parts[6] || "", + parts, + }; + }) + .filter((row) => row.parts.length >= 7 && !PSEUDO_FS_RE.test(row.type)); +} + +/** The most used real filesystem in a `df -TkP` row set. */ +export function findWorstMountIndex(bytesRows: DfRow[]): { + index: number; + usedBytes: number; + totalBytes: number; +} { + let worstIndex = -1; + let worstUsedBytes = -1; + let worstTotalBytes = 0; + bytesRows.forEach((row, index) => { + const totalBytes = Number(row.parts[2]); + const usedBytes = Number(row.parts[3]); + if ( + !Number.isFinite(totalBytes) || + !Number.isFinite(usedBytes) || + totalBytes <= 0 + ) { + return; + } + const usedRatio = usedBytes / totalBytes; + const worstRatio = + worstTotalBytes > 0 ? worstUsedBytes / worstTotalBytes : -1; + if (usedRatio > worstRatio) { + worstIndex = index; + worstUsedBytes = usedBytes; + worstTotalBytes = totalBytes; + } + }); + return { + index: worstIndex, + usedBytes: worstUsedBytes, + totalBytes: worstTotalBytes, + }; +} + +/** + * Merges `df -TkP` size rows (parts[2..4] in `blockSizeBytes` units) and + * `df -hT -P` human rows into one list. Human rows only supply the display + * text, matched by mount point so a mismatched row count can't shift them. + */ +export function buildFilesystemList( + bytesRows: DfRow[], + humanRows: DfRow[], + blockSizeBytes = 1, +): DiskFilesystem[] { + const aligned = humanRows.length === bytesRows.length; + return bytesRows + .map((row, index) => { + const totalBytes = Number(row.parts[2]) * blockSizeBytes; + const usedBytes = Number(row.parts[3]) * blockSizeBytes; + const availableBytes = Number(row.parts[4]) * blockSizeBytes; + if (!Number.isFinite(totalBytes) || totalBytes <= 0) return null; + const humanRow = aligned + ? humanRows[index] + : humanRows.find((h) => h.mount === row.mount); + const percent = Number.isFinite(usedBytes) + ? Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100)) + : null; + return { + filesystem: row.filesystem, + type: row.type, + mount: row.mount, + percent: toFixedNum(percent, 0), + usedHuman: humanRow?.parts[3] || null, + totalHuman: humanRow?.parts[2] || null, + availableHuman: humanRow?.parts[4] || null, + usedBytes: Number.isFinite(usedBytes) ? usedBytes : null, + totalBytes, + availableBytes: Number.isFinite(availableBytes) ? availableBytes : null, + }; + }) + .filter((fs): fs is DiskFilesystem => fs !== null); +} + +/** + * The headline disk is the root filesystem. On macOS "/" is the small sealed + * system volume, so the data volume wins there. With no root mount the most + * used one is shown. + */ +export function selectPrimaryFilesystem( + filesystems: DiskFilesystem[], +): DiskFilesystem | null { + if (filesystems.length === 0) return null; + const root = + filesystems.find((fs) => fs.mount === DARWIN_DATA_MOUNT) ?? + filesystems.find((fs) => fs.mount === "/"); + if (root) return root; + let best = filesystems[0]; + for (const fs of filesystems) { + const ratio = (fs.usedBytes ?? 0) / (fs.totalBytes || 1); + const bestRatio = (best.usedBytes ?? 0) / (best.totalBytes || 1); + if (ratio > bestRatio) best = fs; + } + return best; +} + +/** + * Drops mounts by exact path ("/mnt/nas") or by filesystem type substring + * ("nfs" matches nfs and nfs4), so network shares can be hidden. + */ +export function filterExcludedFilesystems( + filesystems: DiskFilesystem[], + excludedMounts?: string[] | null, +): DiskFilesystem[] { + if (!excludedMounts || excludedMounts.length === 0) return filesystems; + const normalized = excludedMounts + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean); + if (normalized.length === 0) return filesystems; + return filesystems.filter((fs) => { + const mount = fs.mount.toLowerCase(); + const type = fs.type.toLowerCase(); + return !normalized.some( + (entry) => mount === entry || (type && type.includes(entry)), + ); + }); +} + +export function mergeMonitoredFilesystems( + filesystems: DiskFilesystem[], + monitored: MonitoredMount[], + customFilesystems: Array, +): DiskFilesystem[] { + const result = [...filesystems]; + monitored.forEach((entry, index) => { + const path = entry.path.trim(); + const custom = customFilesystems[index]; + if (!path || !custom) return; + const existing = result.find((fs) => fs.mount === path); + if (existing) { + existing.label = entry.label?.trim() || undefined; + return; + } + result.push({ + ...custom, + mount: path, + label: entry.label?.trim() || undefined, + }); + }); + return result; +} + +/** Host name, OS, kernel and arch in one exec, as key=value lines. */ +export const SYSTEM_INFO_COMMAND = `echo "hostname=$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null)"; echo "os=$( . /etc/os-release 2>/dev/null; echo "$PRETTY_NAME")"; echo "kernel=$(uname -r 2>/dev/null)"; echo "arch=$(uname -m 2>/dev/null)"`; diff --git a/packages/plugin-sdk/src/host-scripts/openrc.ts b/packages/plugin-sdk/src/host-scripts/openrc.ts new file mode 100644 index 000000000..287da98ff --- /dev/null +++ b/packages/plugin-sdk/src/host-scripts/openrc.ts @@ -0,0 +1,94 @@ +/** OpenRC scripts, with states mapped onto the words systemd uses. */ +import { isOpenRcName, type UnitAction, type UnitSummary } from "./systemd.js"; +import { HostScriptError, shellQuote as q } from "./text.js"; + +export const OPENRC_LIST_SCRIPT = ` +rc-status --servicelist 2>/dev/null +echo "@@TERMIX_RUNLEVELS@@" +rc-update show -v 2>/dev/null +exit 0 +`; + +function activeOf(state: string): { active: string; sub: string } { + switch (state) { + case "started": + return { active: "active", sub: "running" }; + case "crashed": + return { active: "failed", sub: "crashed" }; + case "starting": + return { active: "activating", sub: "starting" }; + case "stopping": + return { active: "deactivating", sub: "stopping" }; + case "inactive": + case "hotplugged": + return { active: "inactive", sub: state }; + default: + return { active: "inactive", sub: state || "stopped" }; + } +} + +export function parseOpenRcList(output: string): UnitSummary[] { + const [list = "", levels = ""] = output.split("@@TERMIX_RUNLEVELS@@"); + const units = new Map(); + for (const line of list.split("\n")) { + const match = /^\s*(\S+)\s+\[\s*([a-z ]+?)\s*\]/.exec(line); + if (!match) continue; + const { active, sub } = activeOf(match[2]); + units.set(match[1], { + name: match[1], + type: "service", + load: "loaded", + active, + sub, + description: "", + fileState: "disabled", + preset: "", + }); + } + for (const line of levels.split("\n")) { + const match = /^\s*(\S+)\s*\|\s*(.*)$/.exec(line); + if (!match) continue; + const runlevels = match[2].trim(); + const known = units.get(match[1]) ?? { + name: match[1], + type: "service", + load: "loaded", + active: "inactive", + sub: "stopped", + description: "", + fileState: "disabled", + preset: "", + }; + known.fileState = runlevels ? "enabled" : "disabled"; + known.description = runlevels ? `Runlevels: ${runlevels}` : ""; + units.set(match[1], known); + } + return [...units.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +function requireName(name: string): string { + if (!isOpenRcName(name)) + throw new HostScriptError("That is not a valid service name"); + return name; +} + +export function openRcActionScript(name: string, action: UnitAction): string { + const s = q(requireName(name)); + switch (action) { + case "start": + case "stop": + case "restart": + return `rc-service ${s} ${action} 2>&1 || { echo "SCRIPT_ERR:rc-service ${action} failed"; exit 1; }\n`; + case "enable": + return `rc-update add ${s} default 2>&1 || { echo "SCRIPT_ERR:rc-update add failed"; exit 1; }\n`; + case "disable": + return `rc-update del ${s} 2>&1 || { echo "SCRIPT_ERR:rc-update del failed"; exit 1; }\n`; + default: + throw new HostScriptError("OpenRC can't do that", 400); + } +} + +export function openRcStatusScript(name: string): string { + const s = q(requireName(name)); + return `rc-service ${s} status 2>&1; rc-update show -v 2>/dev/null | grep -E '^ *'${s}' *\\|'; exit 0\n`; +} diff --git a/packages/plugin-sdk/src/host-scripts/shell.ts b/packages/plugin-sdk/src/host-scripts/shell.ts new file mode 100644 index 000000000..5b4f27f20 --- /dev/null +++ b/packages/plugin-sdk/src/host-scripts/shell.ts @@ -0,0 +1,117 @@ +/** + * How a script reaches the host: the command line runs a shell that reads + * the script from stdin (`bash -s`), so names and values in a script are + * never parsed out of argv. + */ +import { shellQuote, toBase64 } from "./text.js"; + +// systemctl and friends often live in sbin, which a normal login skips. +// TZ=UTC so tools print every time in UTC, which parses the same anywhere. +const ENV = "SYSTEMD_COLORS=0 SYSTEMD_PAGER=cat LC_ALL=C TZ=UTC"; + +/** Puts sbin on PATH and sets a plain, UTC environment. */ +export const SCRIPT_PRELUDE = `export PATH="$PATH:/usr/sbin:/sbin:/usr/local/sbin"; export ${ENV}; `; +const SUDO = `sudo -n env PATH="$PATH" ${ENV}`; + +/** + * - `plain`: the login user. + * - `auto`: root when it can be had without a password, else the login user. + * - `root`: root or passwordless sudo, fails on anything else. + * - `root-only`: root logins only, for hosts where sudo is turned off. + */ +export type ScriptShellMode = "plain" | "auto" | "root" | "root-only"; + +const ERR = "SCRIPT_ERR:"; + +/** The command line that runs a script sent on stdin. */ +export function scriptShell(mode: ScriptShellMode): string { + switch (mode) { + case "plain": + return `${SCRIPT_PRELUDE}exec bash -s`; + case "auto": + return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; elif sudo -n true 2>/dev/null; then exec ${SUDO} bash -s; else exec bash -s; fi`; + case "root": + return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; else exec ${SUDO} bash -s; fi`; + case "root-only": + return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; else echo "${ERR}This needs root, or turn on Use sudo for this host"; exit 1; fi`; + } +} + +/** + * Runs a script as root with a sudo password. The command is `plain`; all of + * the returned stdin goes to the host. The password is read by the first + * line into a shell variable and piped to `sudo -S`, so it is never in argv, + * never in the environment and never run as a command. Root logins and + * passwordless sudo skip the password. The script itself travels as base64. + */ +export function passwordSudoScript( + script: string, + password: string, +): { command: string; stdin: string } { + if (/[\r\n\0]/.test(password)) { + throw new Error("A sudo password can't contain a line break"); + } + const run = `bash -c "$TERMIX_S"`; + const stdin = `IFS= read -r TERMIX_PW +${password} +TERMIX_S=$(printf '%s' ${shellQuote(toBase64(script))} | base64 -d) +if [ "$(id -u)" = "0" ]; then unset TERMIX_PW; exec ${run} +elif sudo -n true 2>/dev/null; then unset TERMIX_PW; exec ${SUDO} ${run} +else P=$TERMIX_PW; unset TERMIX_PW; printf '%s\\n' "$P" | sudo -k -S -p '' env PATH="$PATH" ${ENV} ${run}; exit $? +fi +`; + return { command: scriptShell("plain"), stdin }; +} + +/** sudo asked for a password or a terminal it did not get. */ +export const SUDO_REFUSED_RE = + /password is required|a terminal is required|no tty present/i; +/** sudo got a password and it was wrong. */ +export const SUDO_WRONG_RE = + /incorrect password|sorry, try again|no password was provided/i; +/** The user may not use sudo at all. */ +export const NOT_SUDOER_RE = + /is not in the sudoers file|not allowed to run sudo/i; + +/** + * Scripts print `SCRIPT_ERR:` lines (or the older `SERVICES_ERR:`) before a + * non zero exit to explain a failure. + */ +export function scriptError(stdout: string): string { + return stdout + .split("\n") + .map((line) => { + if (line.startsWith(ERR)) return line.slice(ERR.length); + if (line.startsWith("SERVICES_ERR:")) return line.slice(13); + return null; + }) + .filter((line): line is string => line !== null) + .join("\n") + .trim(); +} + +/** A line a script prints to explain why it stops. */ +export function errLine(message: string): string { + return `echo ${shellQuote(ERR + message)}`; +} + +/** + * Runs a long command in the background and stops it once stdin closes, + * which happens when the SSH channel ends. A pty can't be trusted for this: + * sudo keeps its own and the command would outlive the viewer. + */ +export function watchdog(command: string): string { + return `${command} & +P=$! +trap 'kill $P 2>/dev/null' EXIT +cat >/dev/null +`; +} + +/** The wrapper a streamed command runs in, as one shell command line. */ +export function streamCommand(script: string, sudo: boolean): string { + const quoted = shellQuote(`${SCRIPT_PRELUDE}${script}`); + return sudo + ? `if [ "$(id -u)" = "0" ]; then exec bash -c ${quoted}; elif sudo -n true 2>/dev/null; then exec ${SUDO} bash -c ${quoted}; else exec bash -c ${quoted}; fi` + : `exec bash -c ${quoted}`; +} diff --git a/packages/plugin-sdk/src/host-scripts/systemd.ts b/packages/plugin-sdk/src/host-scripts/systemd.ts new file mode 100644 index 000000000..9d3481b90 --- /dev/null +++ b/packages/plugin-sdk/src/host-scripts/systemd.ts @@ -0,0 +1,389 @@ +/** + * systemd unit scripts and parsers, plus init system detection. + * Every script is meant for `scriptShell()`, sent on stdin. + */ +import { HostScriptError, parseKeys, shellQuote as q } from "./text.js"; + +export const UNIT_TYPES = [ + "service", + "socket", + "timer", + "target", + "mount", + "automount", + "path", + "swap", + "slice", + "scope", + "device", +] as const; +export type UnitType = (typeof UNIT_TYPES)[number]; + +// Backslash is allowed for escaped names like dev-disk-by\x2duuid. +const UNIT_RE = new RegExp( + `^[A-Za-z0-9_][A-Za-z0-9:_.@\\\\-]{0,250}\\.(${UNIT_TYPES.join("|")})$`, +); +const OPENRC_RE = /^[A-Za-z0-9_][A-Za-z0-9_.@-]{0,127}$/; + +export function isUnit(name: unknown): name is string { + return typeof name === "string" && UNIT_RE.test(name); +} + +export function unitType(name: string): UnitType { + return name.slice(name.lastIndexOf(".") + 1) as UnitType; +} + +/** Units a file may be written for. Templates and device style names are out. */ +export function isWritableUnitName(name: string): boolean { + return ( + isUnit(name) && + !name.includes("\\") && + ["service", "socket", "timer", "target", "path", "mount"].includes( + unitType(name), + ) + ); +} + +export function isOpenRcName(name: unknown): name is string { + return typeof name === "string" && OPENRC_RE.test(name); +} + +export function requireUnit(name: unknown): string { + if (!isUnit(name)) throw new HostScriptError("That is not a valid unit name"); + return name; +} + +export type InitSystem = "systemd" | "openrc" | "none"; + +/** A unit name or, on OpenRC, a service name. */ +export function requireService(name: unknown, init: string): string { + if (init === "openrc") { + if (!isOpenRcName(name)) + throw new HostScriptError("That is not a valid service name"); + return name; + } + return requireUnit(name); +} + +export interface UnitSummary { + name: string; + type: string; + load: string; + active: string; + sub: string; + description: string; + /** enabled, disabled, static, masked and so on. Empty when unknown. */ + fileState: string; + preset: string; +} + +export interface UnitDetail { + name: string; + props: Record; + status: string; +} + +export interface TimerView { + name: string; + description: string; + active: string; + fileState: string; + /** Unix ms, or null when not set. */ + next: number | null; + last: number | null; + activates: string; + schedule: string[]; +} + +export const UNIT_ACTIONS = [ + "start", + "stop", + "restart", + "reload", + "enable", + "disable", + "enable-now", + "disable-now", + "mask", + "unmask", + "reset-failed", + "revert", +] as const; +export type UnitAction = (typeof UNIT_ACTIONS)[number]; + +/** Actions OpenRC can do. */ +export const OPENRC_ACTIONS: readonly UnitAction[] = [ + "start", + "stop", + "restart", + "enable", + "disable", +]; + +export const DETECT_SCRIPT = ` +INIT=none +if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then INIT=systemd +elif command -v rc-service >/dev/null 2>&1; then INIT=openrc; fi +echo "init=$INIT" +if [ "$INIT" = systemd ]; then echo "version=$(systemctl --version 2>/dev/null | head -n1 | awk '{print $2}')"; fi +if [ "$INIT" = openrc ]; then echo "version=$(openrc --version 2>/dev/null | head -n1 | awk '{print $NF}')"; fi +echo "os=$( . /etc/os-release 2>/dev/null; echo "$PRETTY_NAME")" +echo "hostname=$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null)" +echo "uid=$(id -u)" +if command -v journalctl >/dev/null 2>&1; then echo "journal=1"; fi +if [ "$INIT" = systemd ]; then echo "failed=$(systemctl list-units --state=failed --plain --no-legend --no-pager 2>/dev/null | wc -l)"; fi +if [ "$INIT" = openrc ]; then echo "failed=$(rc-status --crashed 2>/dev/null | wc -l)"; fi +exit 0 +`; + +export interface Detected { + init: InitSystem; + version: string; + os: string; + hostname: string; + root: boolean; + journal: boolean; + failed: number; +} + +export function parseDetect(output: string): Detected { + const keys = parseKeys(output); + const init = + keys.init === "systemd" || keys.init === "openrc" ? keys.init : "none"; + return { + init, + version: keys.version ?? "", + os: keys.os ?? "", + hostname: keys.hostname ?? "", + root: keys.uid === "0", + journal: keys.journal === "1", + failed: Number(keys.failed) || 0, + }; +} + +/** Types the list can show. */ +export const LIST_TYPES = [ + "service", + "socket", + "timer", + "target", + "path", + "mount", +] as const; +export type ListType = (typeof LIST_TYPES)[number] | "all"; + +export function listUnitsScript(type: ListType): string { + const types = type === "all" ? LIST_TYPES.join(",") : type; + if (type !== "all" && !LIST_TYPES.includes(type)) + throw new HostScriptError("Unknown unit type"); + return ` +systemctl list-units --all --plain --no-legend --no-pager --full --type=${types} 2>/dev/null +echo "@@TERMIX_FILES@@" +systemctl list-unit-files --plain --no-legend --no-pager --full --type=${types} 2>/dev/null +exit 0 +`; +} + +/** Merges list-units and list-unit-files. Template units are left out. */ +export function parseUnitList(output: string): UnitSummary[] { + const [loaded = "", files = ""] = output.split("@@TERMIX_FILES@@"); + const units = new Map(); + for (const raw of loaded.split("\n")) { + const line = raw.replace(/^[●*\s]+/, "").trimEnd(); + if (!line) continue; + const match = /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s*(.*)$/.exec(line); + if (!match) continue; + const [, name, load, active, sub, description] = match; + if (!name.includes(".") || name.includes("@.")) continue; + units.set(name, { + name, + type: unitType(name), + load, + active, + sub, + description: description.trim(), + fileState: "", + preset: "", + }); + } + for (const raw of files.split("\n")) { + const line = raw.trim(); + if (!line) continue; + const [name, state = "", preset = ""] = line.split(/\s+/); + if (!name || !name.includes(".") || name.includes("@.")) continue; + const known = units.get(name); + const cleanPreset = preset === "-" ? "" : preset; + if (known) { + known.fileState = state; + known.preset = cleanPreset; + } else { + units.set(name, { + name, + type: unitType(name), + load: state === "masked" ? "masked" : "unloaded", + active: "inactive", + sub: "dead", + description: "", + fileState: state, + preset: cleanPreset, + }); + } + } + return [...units.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +export const DETAIL_PROPS = [ + "Id", + "Description", + "LoadState", + "ActiveState", + "SubState", + "UnitFileState", + "UnitFilePreset", + "Result", + "MainPID", + "ExecMainStartTimestamp", + "ActiveEnterTimestamp", + "InactiveEnterTimestamp", + "MemoryCurrent", + "MemoryPeak", + "CPUUsageNSec", + "TasksCurrent", + "NRestarts", + "FragmentPath", + "DropInPaths", + "Requires", + "Wants", + "After", + "WantedBy", + "TriggeredBy", + "Triggers", + "User", + "Group", + "Restart", + "Type", + "ExecStart", + "CanStart", + "CanStop", + "CanReload", +] as const; + +export function detailScript(unit: string): string { + const props = DETAIL_PROPS.map((p) => `-p ${p}`).join(" "); + return ` +systemctl show --no-pager ${props} -- ${q(requireUnit(unit))} +echo "@@TERMIX_STATUS@@" +systemctl status --no-pager --full -n 0 -- ${q(unit)} 2>&1 +exit 0 +`; +} + +export function parseDetail(unit: string, output: string): UnitDetail { + const [props = "", status = ""] = output.split("@@TERMIX_STATUS@@"); + return { name: unit, props: parseKeys(props), status: status.trim() }; +} + +const VERBS: Record = { + start: "start", + stop: "stop", + restart: "restart", + reload: "reload", + enable: "enable", + disable: "disable", + "enable-now": "enable --now", + "disable-now": "disable --now", + mask: "mask", + unmask: "unmask", + "reset-failed": "reset-failed", + revert: "revert", +}; + +/** The systemctl call for an action. Prints `state=` after it. */ +export function actionScript(unit: string, action: UnitAction): string { + const verb = VERBS[action]; + if (!verb) throw new HostScriptError("Unknown action"); + const u = q(requireUnit(unit)); + return ` +systemctl ${verb} -- ${u} 2>&1 || { echo "SCRIPT_ERR:systemctl ${verb} failed"; exit 1; } +echo "state=$(systemctl is-active -- ${u} 2>/dev/null)" +`; +} + +/** Times systemctl prints under TZ=UTC, like "Thu 2026-10-22 02:03:51 UTC". */ +export function parseSystemdTime(value: string | undefined): number | null { + if (!value) return null; + const match = /(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/.exec(value); + if (!match) return null; + const [, y, mo, d, h, mi, s] = match.map(Number); + return Date.UTC(y, mo - 1, d, h, mi, s); +} + +export const TIMERS_SCRIPT = ` +UNITS=$( { systemctl list-units --all --type=timer --plain --no-legend --no-pager --full 2>/dev/null | awk '{print $1}'; systemctl list-unit-files --type=timer --plain --no-legend --no-pager --full 2>/dev/null | awk '{print $1}'; } | grep '\\.timer$' | grep -v '@\\.timer$' | sort -u) +[ -n "$UNITS" ] || exit 0 +systemctl show --no-pager -p Id -p Description -p ActiveState -p UnitFileState -p NextElapseUSecRealtime -p LastTriggerUSec -p Triggers -p TimersCalendar -p TimersMonotonic -- $UNITS +exit 0 +`; + +/** Reads systemctl show output for many units, split by blank lines. */ +export function parseShowBlocks(output: string): Array> { + const blocks: Array> = []; + for (const block of output.split(/\n\s*\n/)) { + const lines = block.split("\n").filter((line) => line.includes("=")); + if (!lines.length) continue; + // A unit can have several TimersCalendar lines; keep all of them. + const keys: Record = {}; + for (const line of lines) { + const eq = line.indexOf("="); + const key = line.slice(0, eq).trim(); + const value = line.slice(eq + 1).trim(); + keys[key] = key in keys ? `${keys[key]}\n${value}` : value; + } + blocks.push(keys); + } + return blocks; +} + +function schedules(value: string | undefined): string[] { + if (!value) return []; + const out: string[] = []; + for (const line of value.split("\n")) { + const match = /\{\s*(\w+)=(.+?)\s*;/.exec(line); + if (match) out.push(`${match[1]}=${match[2]}`); + } + return out; +} + +export function parseTimers(output: string): TimerView[] { + return parseShowBlocks(output) + .filter((keys) => keys.Id?.endsWith(".timer")) + .map((keys) => ({ + name: keys.Id, + description: keys.Description ?? "", + active: keys.ActiveState ?? "", + fileState: keys.UnitFileState ?? "", + next: parseSystemdTime(keys.NextElapseUSecRealtime), + last: parseSystemdTime(keys.LastTriggerUSec), + activates: (keys.Triggers ?? "").split(/\s+/)[0] ?? "", + schedule: [ + ...schedules(keys.TimersCalendar), + ...schedules(keys.TimersMonotonic), + ], + })) + .sort((a, b) => (a.next ?? Infinity) - (b.next ?? Infinity)); +} + +/** One line per unit from systemctl is-active, in the order asked. */ +export function isActiveScript(units: string[]): string { + return `systemctl is-active -- ${units.map((u) => q(requireUnit(u))).join(" ")} 2>/dev/null; exit 0\n`; +} + +export function parseIsActive( + units: string[], + output: string, +): Map { + const lines = output.split("\n").map((line) => line.trim()); + const out = new Map(); + units.forEach((unit, i) => out.set(unit, lines[i] || "unknown")); + return out; +} diff --git a/packages/plugin-sdk/src/host-scripts/text.ts b/packages/plugin-sdk/src/host-scripts/text.ts new file mode 100644 index 000000000..3f1217edd --- /dev/null +++ b/packages/plugin-sdk/src/host-scripts/text.ts @@ -0,0 +1,118 @@ +/** + * Quoting, encoding and the error type the host scripts share. + * + * No Node APIs here: this entry also runs in the mobile app. + */ + +/** A bad input to a host script builder. `status` is the HTTP status to answer with. */ +export class HostScriptError extends Error { + constructor( + message: string, + readonly status = 400, + readonly extra: Record = {}, + ) { + super(message); + this.name = "HostScriptError"; + } +} + +/** Wraps a value in single quotes for a POSIX shell. */ +export function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +// Written by hand because some JS engines (Hermes) have no TextDecoder. + +/** UTF-8 bytes of a string. */ +export function utf8Encode(text: string): Uint8Array { + const out: number[] = []; + for (const char of text) { + const cp = char.codePointAt(0) ?? 0xfffd; + if (cp < 0x80) out.push(cp); + else if (cp < 0x800) out.push(0xc0 | (cp >> 6), 0x80 | (cp & 63)); + else if (cp < 0x10000) { + out.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 63), 0x80 | (cp & 63)); + } else { + out.push( + 0xf0 | (cp >> 18), + 0x80 | ((cp >> 12) & 63), + 0x80 | ((cp >> 6) & 63), + 0x80 | (cp & 63), + ); + } + } + return Uint8Array.from(out); +} + +/** A string from UTF-8 bytes. Bad sequences become U+FFFD. */ +export function utf8Decode(bytes: ArrayLike): string { + let out = ""; + let i = 0; + while (i < bytes.length) { + const b = bytes[i]; + let need = 0; + let cp = 0; + if (b < 0x80) cp = b; + else if (b >= 0xc2 && b < 0xe0) [need, cp] = [1, b & 31]; + else if (b >= 0xe0 && b < 0xf0) [need, cp] = [2, b & 15]; + else if (b >= 0xf0 && b < 0xf5) [need, cp] = [3, b & 7]; + else { + out += "\ufffd"; + i++; + continue; + } + let j = 1; + for (; j <= need; j++) { + const next = bytes[i + j]; + if (next === undefined || (next & 0xc0) !== 0x80) break; + cp = (cp << 6) | (next & 63); + } + const tooSmall = [0, 0x80, 0x800, 0x10000][need]; + if ( + j <= need || + cp < tooSmall || + cp > 0x10ffff || + (cp >= 0xd800 && cp < 0xe000) + ) { + out += "\ufffd"; + i += Math.max(1, j); + continue; + } + out += String.fromCodePoint(cp); + i += need + 1; + } + return out; +} + +const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/** Base64 of the UTF-8 bytes of a string, for `base64 -d` on the host. */ +export function toBase64(text: string): string { + const bytes = utf8Encode(text); + let out = ""; + for (let i = 0; i < bytes.length; i += 3) { + const a = bytes[i]; + const b = i + 1 < bytes.length ? bytes[i + 1] : 0; + const c = i + 2 < bytes.length ? bytes[i + 2] : 0; + const n = (a << 16) | (b << 8) | c; + out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63]; + out += i + 1 < bytes.length ? B64[(n >> 6) & 63] : "="; + out += i + 2 < bytes.length ? B64[n & 63] : "="; + } + return out; +} + +/** Reads key=value lines. Only the first = splits a line. */ +export function parseKeys(output: string): Record { + const out: Record = {}; + for (const line of output.split("\n")) { + const eq = line.indexOf("="); + if (eq > 0) out[line.slice(0, eq).trim()] = line.slice(eq + 1).trim(); + } + return out; +} + +/** Trims output and keeps it short enough to show. */ +export function scrub(text: string, max = 600): string { + return text.trim().slice(0, max); +} diff --git a/src/backend/tests/plugins/host-scripts/docker.test.ts b/src/backend/tests/plugins/host-scripts/docker.test.ts new file mode 100644 index 000000000..2e6b1ba40 --- /dev/null +++ b/src/backend/tests/plugins/host-scripts/docker.test.ts @@ -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"]); + }); +}); diff --git a/src/backend/tests/plugins/host-scripts/metrics.test.ts b/src/backend/tests/plugins/host-scripts/metrics.test.ts new file mode 100644 index 000000000..d83d65d23 --- /dev/null +++ b/src/backend/tests/plugins/host-scripts/metrics.test.ts @@ -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(); + }); +}); diff --git a/src/backend/tests/plugins/host-scripts/services.test.ts b/src/backend/tests/plugins/host-scripts/services.test.ts new file mode 100644 index 000000000..75e1bf866 --- /dev/null +++ b/src/backend/tests/plugins/host-scripts/services.test.ts @@ -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"); + }); +}); diff --git a/src/backend/tests/plugins/host-scripts/shell.test.ts b/src/backend/tests/plugins/host-scripts/shell.test.ts new file mode 100644 index 000000000..3d41e7d19 --- /dev/null +++ b/src/backend/tests/plugins/host-scripts/shell.test.ts @@ -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"); + }); +});