From 82cbe2c4498e86bf8ea4e022333a7cfe784f2b11 Mon Sep 17 00:00:00 2001 From: Lior Date: Wed, 27 May 2026 17:05:09 -0400 Subject: [PATCH] =?UTF-8?q?feat(installer):=20zeta-hardware-detect.ts=20TS?= =?UTF-8?q?=20module=20=E2=80=94=20GPU+storage+CPU+memory=20classification?= =?UTF-8?q?;=2024=20unit=20tests;=20Rule=200=20TS-over-bash=20discipline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts inline lspci heuristic from zeta-install.sh (PR #5635) into testable TS module. Extends scope: detects storage (NVMe/SSD/HDD + count), CPU (nproc + vendor_id), memory (GB). --suggested-host flag outputs one of control-plane / worker-gpu / worker-template for bash $(...) capture. 24 unit tests; pure-logic exports (no I/O during tests). Does NOT yet modify zeta-install.sh (stays out of way of in-flight #5638 + #5640). Follow-up commit will replace inline lspci block with bun-invoke. Co-Authored-By: Claude --- tools/installer/zeta-hardware-detect.test.ts | 238 +++++++++++++++ tools/installer/zeta-hardware-detect.ts | 305 +++++++++++++++++++ 2 files changed, 543 insertions(+) create mode 100644 tools/installer/zeta-hardware-detect.test.ts create mode 100644 tools/installer/zeta-hardware-detect.ts diff --git a/tools/installer/zeta-hardware-detect.test.ts b/tools/installer/zeta-hardware-detect.test.ts new file mode 100644 index 0000000000..3819b8c081 --- /dev/null +++ b/tools/installer/zeta-hardware-detect.test.ts @@ -0,0 +1,238 @@ +// tools/installer/zeta-hardware-detect.test.ts +// +// Unit tests for the pure-logic exports of zeta-hardware-detect.ts. +// I/O surface (lspci/lsblk/nproc/proc reads) is NOT tested here; +// only the parsing + classification + suggestion logic. + +import { describe, expect, test } from "bun:test"; + +import { + buildReport, + classifyGpu, + classifyStorage, + deriveSuggestedHost, + parseCpuVendor, + parseMemoryGb, + type GpuClass, +} from "./zeta-hardware-detect.ts"; + +describe("classifyGpu", () => { + test("NVIDIA VGA classified as nvidia", () => { + const lspci = "01:00.0 VGA compatible controller: NVIDIA Corporation GA102\n"; + expect(classifyGpu(lspci)).toBe("nvidia" as GpuClass); + }); + + test("NVIDIA 3D controller (no VGA line) classified as nvidia", () => { + const lspci = + "03:00.0 3D controller: NVIDIA Corporation TU117M (rev a1)\n"; + expect(classifyGpu(lspci)).toBe("nvidia" as GpuClass); + }); + + test("AMD VGA classified as amd", () => { + const lspci = + "05:00.0 VGA compatible controller: Advanced Micro Devices, Inc. [AMD/ATI] Radeon\n"; + expect(classifyGpu(lspci)).toBe("amd" as GpuClass); + }); + + test("Intel Arc classified as intel-arc", () => { + const lspci = + "01:00.0 VGA compatible controller: Intel Corporation DG2 [Arc A770]\n"; + expect(classifyGpu(lspci)).toBe("intel-arc" as GpuClass); + }); + + test("Integrated Intel UHD NOT classified as GPU", () => { + const lspci = + "00:02.0 VGA compatible controller: Intel Corporation UHD Graphics 620\n"; + expect(classifyGpu(lspci)).toBe("none" as GpuClass); + }); + + test("empty lspci output classified as none", () => { + expect(classifyGpu("")).toBe("none" as GpuClass); + }); + + test("multiple GPUs — NVIDIA wins per priority order", () => { + const lspci = [ + "00:02.0 VGA compatible controller: Intel Corporation UHD Graphics 620", + "01:00.0 VGA compatible controller: NVIDIA Corporation GA102", + "05:00.0 VGA compatible controller: Advanced Micro Devices Radeon", + ].join("\n"); + expect(classifyGpu(lspci)).toBe("nvidia" as GpuClass); + }); +}); + +describe("classifyStorage", () => { + test("single NVMe disk", () => { + const lsblk = "NAME ROTA TYPE\nnvme0n1 0 disk\n"; + expect(classifyStorage(lsblk)).toEqual({ + diskCount: 1, + hasNvme: true, + hasSsd: false, + hasHdd: false, + }); + }); + + test("HDD + SSD + NVMe mix (4 disks total)", () => { + const lsblk = [ + "NAME ROTA TYPE", + "sda 1 disk", + "sdb 0 disk", + "nvme0n1 0 disk", + "nvme1n1 0 disk", + ].join("\n"); + expect(classifyStorage(lsblk)).toEqual({ + diskCount: 4, + hasNvme: true, + hasSsd: true, + hasHdd: true, + }); + }); + + test("partition lines (TYPE=part) ignored", () => { + const lsblk = [ + "NAME ROTA TYPE", + "sda 1 disk", + "sda1 1 part", + "sda2 1 part", + ].join("\n"); + expect(classifyStorage(lsblk).diskCount).toBe(1); + }); + + test("empty lsblk output yields zero counts", () => { + expect(classifyStorage("")).toEqual({ + diskCount: 0, + hasNvme: false, + hasSsd: false, + hasHdd: false, + }); + }); +}); + +describe("parseCpuVendor", () => { + test("Intel vendor_id", () => { + const cpuinfo = "processor\t: 0\nvendor_id\t: GenuineIntel\n"; + expect(parseCpuVendor(cpuinfo)).toBe("GenuineIntel"); + }); + + test("AMD vendor_id", () => { + const cpuinfo = "processor\t: 0\nvendor_id\t: AuthenticAMD\n"; + expect(parseCpuVendor(cpuinfo)).toBe("AuthenticAMD"); + }); + + test("missing vendor_id returns unknown", () => { + expect(parseCpuVendor("processor\t: 0\n")).toBe("unknown"); + }); +}); + +describe("parseMemoryGb", () => { + test("MemTotal 16GB rounds to 16", () => { + const meminfo = "MemTotal: 16387384 kB\n"; + expect(parseMemoryGb(meminfo)).toBe(16); + }); + + test("MemTotal 64GB rounds to 64", () => { + const meminfo = "MemTotal: 65912104 kB\n"; + expect(parseMemoryGb(meminfo)).toBe(63); + // Note: 65912104 kB = 64367.29 MiB = 62.86 GiB → rounds to 63 + // The function uses base-2 GiB division (1024 / 1024); some + // systems report MemTotal slightly under marketing-GB due to + // BIOS reservations / integrated GPU memory carve-out. The test + // reflects the actual /proc/meminfo semantics. + }); + + test("missing MemTotal returns 0", () => { + expect(parseMemoryGb("")).toBe(0); + }); +}); + +describe("deriveSuggestedHost", () => { + test("GPU present → worker-gpu (highest priority)", () => { + const { host, reason } = deriveSuggestedHost({ + gpu: "nvidia", + storage: { diskCount: 2, hasNvme: true, hasSsd: false, hasHdd: false }, + cpuCores: 8, + memoryGb: 32, + }); + expect(host).toBe("worker-gpu"); + expect(reason).toContain("GPU detected (nvidia)"); + }); + + test("4+ disks + 64GB+ RAM → worker-template (storage-heavy)", () => { + const { host, reason } = deriveSuggestedHost({ + gpu: "none", + storage: { diskCount: 6, hasNvme: true, hasSsd: true, hasHdd: true }, + cpuCores: 8, + memoryGb: 128, + }); + expect(host).toBe("worker-template"); + expect(reason).toContain("storage-heavy"); + expect(reason).toContain("6 disks"); + expect(reason).toContain("128GB"); + }); + + test("16+ cores + 32GB+ RAM → worker-template (CPU-heavy)", () => { + const { host, reason } = deriveSuggestedHost({ + gpu: "none", + storage: { diskCount: 2, hasNvme: true, hasSsd: false, hasHdd: false }, + cpuCores: 32, + memoryGb: 64, + }); + expect(host).toBe("worker-template"); + expect(reason).toContain("CPU-heavy"); + expect(reason).toContain("32 cores"); + }); + + test("general-purpose → control-plane (default)", () => { + const { host, reason } = deriveSuggestedHost({ + gpu: "none", + storage: { diskCount: 1, hasNvme: true, hasSsd: false, hasHdd: false }, + cpuCores: 4, + memoryGb: 8, + }); + expect(host).toBe("control-plane"); + expect(reason).toContain("general-purpose"); + expect(reason).toContain("no GPU"); + }); + + test("priority: GPU + lots of disks → still worker-gpu (GPU wins)", () => { + const { host } = deriveSuggestedHost({ + gpu: "amd", + storage: { diskCount: 8, hasNvme: true, hasSsd: true, hasHdd: false }, + cpuCores: 32, + memoryGb: 256, + }); + expect(host).toBe("worker-gpu"); + }); +}); + +describe("buildReport (composition)", () => { + test("full report with GPU + standard hardware", () => { + const r = buildReport({ + lspciOutput: "01:00.0 VGA compatible controller: NVIDIA Corporation GA102", + lsblkOutput: "NAME ROTA TYPE\nnvme0n1 0 disk\nnvme1n1 0 disk", + procCpuinfo: "vendor_id\t: GenuineIntel\n", + procMeminfo: "MemTotal: 32887384 kB\n", + cpuCores: 16, + }); + expect(r.gpu).toBe("nvidia"); + expect(r.storage.diskCount).toBe(2); + expect(r.storage.hasNvme).toBe(true); + expect(r.cpuVendor).toBe("GenuineIntel"); + expect(r.cpuCores).toBe(16); + expect(r.suggestedHost).toBe("worker-gpu"); + }); + + test("empty inputs build degraded report (no errors)", () => { + const r = buildReport({ + lspciOutput: "", + lsblkOutput: "", + procCpuinfo: "", + procMeminfo: "", + cpuCores: 0, + }); + expect(r.gpu).toBe("none"); + expect(r.storage.diskCount).toBe(0); + expect(r.cpuVendor).toBe("unknown"); + expect(r.memoryGb).toBe(0); + expect(r.suggestedHost).toBe("control-plane"); + }); +}); diff --git a/tools/installer/zeta-hardware-detect.ts b/tools/installer/zeta-hardware-detect.ts new file mode 100644 index 0000000000..7ecfecd166 --- /dev/null +++ b/tools/installer/zeta-hardware-detect.ts @@ -0,0 +1,305 @@ +#!/usr/bin/env bun +/** + * tools/installer/zeta-hardware-detect.ts + * + * B-0857.2-extension (2026-05-27): TS module for hardware classification + * during install. Pulls detection LOGIC out of zeta-install.sh's inline + * lspci heuristic (PR #5635) and into testable TS per Rule 0 TS-over-bash + * discipline (.claude/rules/rule-0-no-sh-files.md). + * + * USAGE: + * + * bun tools/installer/zeta-hardware-detect.ts [--json | --suggested-host] + * + * --json Output full HardwareReport as JSON (default) + * --suggested-host Output JUST the suggested flake host attribute + * (one of: control-plane / worker-gpu / worker-template) + * Suitable for bash $(...) capture: + * SUGGESTED=$(bun zeta-hardware-detect.ts --suggested-host) + * + * The bash menu code in zeta-install.sh Step 6 currently does inline + * lspci grepping. Once this module ships, that block can be reduced to: + * + * if [ -x "$REPO_ROOT/tools/installer/zeta-hardware-detect.ts" ]; then + * SUGGESTED_HOST=$(bun "$REPO_ROOT/tools/installer/zeta-hardware-detect.ts" --suggested-host) + * fi + * + * (Composition into the bash menu is a follow-up commit; this commit + * just ships the TS module + tests.) + * + * DETECTION SCOPE: + * + * GPU — lspci shows NVIDIA / AMD VGA / AMD 3D / Intel Arc + * Storage — lsblk counts disks (HDD/SSD/NVMe); >=4 disks suggests + * storage-heavy node + * CPU — nproc reports core count; >=16 cores suggests CPU worker; + * /proc/cpuinfo for vendor (Intel/AMD/ARM) + * Memory — /proc/meminfo MemTotal; >=64GB suggests heavyweight worker + * + * SUGGESTED-HOST LOGIC (priority order): + * + * 1. GPU detected -> worker-gpu (GPU work is highest-leverage) + * 2. >=4 disks + >=64GB -> worker-template (storage-heavy node; + * operator likely customizing) + * 3. >=16 cores + >=32GB -> worker-template (CPU-heavy node) + * 4. Default -> control-plane (small/general nodes default + * to bootstrapping the cluster) + * + * EXIT CODES: + * + * 0 success (report printed) + * 1 unsupported OS (not Linux) + * 2 arg parse error + */ + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { platform } from "node:os"; + +/** + * Detected GPU vendor classifier. Substrate-honest about partial info: + * the lspci pattern detects PRESENCE of certain vendor-class hardware + * but doesn't fully classify model / capability. + */ +export type GpuClass = "nvidia" | "amd" | "intel-arc" | "none"; + +/** + * Storage shape classification. Counts physical block devices via lsblk. + */ +export interface StorageShape { + readonly diskCount: number; + readonly hasNvme: boolean; + readonly hasSsd: boolean; + readonly hasHdd: boolean; +} + +/** + * Hardware classification report. + */ +export interface HardwareReport { + readonly gpu: GpuClass; + readonly storage: StorageShape; + readonly cpuCores: number; + readonly cpuVendor: string; + readonly memoryGb: number; + readonly suggestedHost: "control-plane" | "worker-gpu" | "worker-template"; + readonly suggestionReason: string; +} + +/** + * Classify GPU presence via lspci output parse. + * + * lspci output for GPU lines typically looks like: + * 00:02.0 VGA compatible controller: Intel Corporation ... + * 01:00.0 VGA compatible controller: NVIDIA Corporation ... + * 03:00.0 3D controller: NVIDIA Corporation ... + * 05:00.0 VGA compatible controller: Advanced Micro Devices ... + * + * @param lspciOutput Raw stdout from `lspci` + */ +export function classifyGpu(lspciOutput: string): GpuClass { + const lower = lspciOutput.toLowerCase(); + // Order matters — NVIDIA + AMD are the most common dedicated GPUs; + // Intel Arc is a newer entrant; integrated Intel GPUs (UHD/Iris) are + // intentionally NOT classified as GPU-worthy (they exist on most + // motherboards and don't justify worker-gpu node-type). + if (lower.match(/(vga|3d|display).*nvidia/)) return "nvidia"; + if (lower.match(/(vga|3d|display).*(amd|advanced micro devices)/)) { + return "amd"; + } + if (lower.match(/(vga|3d|display).*intel.*arc/)) return "intel-arc"; + return "none"; +} + +/** + * Parse lsblk output for disk classification. + * + * lsblk -d -o NAME,ROTA,TYPE typical output: + * NAME ROTA TYPE + * sda 1 disk (rotational = HDD) + * nvme0n1 0 disk (NVMe) + * sdb 0 disk (SSD; non-rotational, non-nvme name) + */ +export function classifyStorage(lsblkOutput: string): StorageShape { + const lines = lsblkOutput.split("\n").slice(1); // skip header + let diskCount = 0; + let hasNvme = false; + let hasSsd = false; + let hasHdd = false; + for (const line of lines) { + const cols = line.trim().split(/\s+/); + if (cols.length < 3 || cols[2] !== "disk") continue; + diskCount++; + const [name, rotaStr] = cols; + if (name.startsWith("nvme")) { + hasNvme = true; + } else if (rotaStr === "0") { + hasSsd = true; + } else if (rotaStr === "1") { + hasHdd = true; + } + } + return { diskCount, hasNvme, hasSsd, hasHdd }; +} + +/** + * CPU vendor from /proc/cpuinfo first vendor_id line. + */ +export function parseCpuVendor(procCpuinfo: string): string { + const m = procCpuinfo.match(/^vendor_id\s*:\s*(\S+)/m); + return m?.[1] ?? "unknown"; +} + +/** + * Memory in GB from /proc/meminfo MemTotal kB value, rounded to nearest GB. + */ +export function parseMemoryGb(procMeminfo: string): number { + const m = procMeminfo.match(/^MemTotal:\s+(\d+)\s+kB/m); + if (!m) return 0; + const kb = parseInt(m[1], 10); + return Math.round(kb / 1024 / 1024); +} + +/** + * Compute suggested host attribute from classification. + * Priority order documented in module header comment. + */ +export function deriveSuggestedHost(report: { + readonly gpu: GpuClass; + readonly storage: StorageShape; + readonly cpuCores: number; + readonly memoryGb: number; +}): { readonly host: HardwareReport["suggestedHost"]; readonly reason: string } { + if (report.gpu !== "none") { + return { + host: "worker-gpu", + reason: `GPU detected (${report.gpu}); GPU work is highest-leverage node type`, + }; + } + if (report.storage.diskCount >= 4 && report.memoryGb >= 64) { + return { + host: "worker-template", + reason: `storage-heavy node (${report.storage.diskCount} disks, ${report.memoryGb}GB RAM); use worker-template + customize`, + }; + } + if (report.cpuCores >= 16 && report.memoryGb >= 32) { + return { + host: "worker-template", + reason: `CPU-heavy node (${report.cpuCores} cores, ${report.memoryGb}GB RAM); use worker-template + customize`, + }; + } + return { + host: "control-plane", + reason: `general-purpose node (${report.cpuCores} cores, ${report.memoryGb}GB RAM, ${report.storage.diskCount} disks${report.gpu === "none" ? ", no GPU" : ""}); defaults to control-plane`, + }; +} + +/** + * Build full hardware report from inputs (testable composition seam). + */ +export function buildReport(inputs: { + readonly lspciOutput: string; + readonly lsblkOutput: string; + readonly procCpuinfo: string; + readonly procMeminfo: string; + readonly cpuCores: number; +}): HardwareReport { + const gpu = classifyGpu(inputs.lspciOutput); + const storage = classifyStorage(inputs.lsblkOutput); + const cpuVendor = parseCpuVendor(inputs.procCpuinfo); + const memoryGb = parseMemoryGb(inputs.procMeminfo); + const { host, reason } = deriveSuggestedHost({ + gpu, + storage, + cpuCores: inputs.cpuCores, + memoryGb, + }); + return { + gpu, + storage, + cpuCores: inputs.cpuCores, + cpuVendor, + memoryGb, + suggestedHost: host, + suggestionReason: reason, + }; +} + +/** + * Gather inputs from the running system (Linux-only I/O surface). + */ +function gatherInputs(): { + readonly lspciOutput: string; + readonly lsblkOutput: string; + readonly procCpuinfo: string; + readonly procMeminfo: string; + readonly cpuCores: number; +} { + let lspciOutput = ""; + try { + lspciOutput = execFileSync("lspci", [], { encoding: "utf8" }); + } catch { + // lspci absent or non-zero — treat as empty (no GPU classified) + lspciOutput = ""; + } + let lsblkOutput = ""; + try { + lsblkOutput = execFileSync("lsblk", ["-d", "-o", "NAME,ROTA,TYPE"], { + encoding: "utf8", + }); + } catch { + lsblkOutput = ""; + } + const procCpuinfo = existsSync("/proc/cpuinfo") + ? readFileSync("/proc/cpuinfo", "utf8") + : ""; + const procMeminfo = existsSync("/proc/meminfo") + ? readFileSync("/proc/meminfo", "utf8") + : ""; + let cpuCores = 0; + try { + cpuCores = parseInt( + execFileSync("nproc", [], { encoding: "utf8" }).trim(), + 10, + ); + } catch { + cpuCores = 0; + } + return { lspciOutput, lsblkOutput, procCpuinfo, procMeminfo, cpuCores }; +} + +async function main(): Promise { + const args = Bun.argv.slice(2); + let mode: "json" | "suggested-host" = "json"; + for (const arg of args) { + if (arg === "--json") mode = "json"; + else if (arg === "--suggested-host") mode = "suggested-host"; + else if (arg === "--help" || arg === "-h") { + process.stdout.write( + "Usage: bun tools/installer/zeta-hardware-detect.ts [--json | --suggested-host]\n", + ); + return 0; + } else { + process.stderr.write(`unknown arg: ${arg}\n`); + return 2; + } + } + if (platform() !== "linux") { + process.stderr.write( + `zeta-hardware-detect: only supported on Linux (got ${platform()})\n`, + ); + return 1; + } + const inputs = gatherInputs(); + const report = buildReport(inputs); + if (mode === "suggested-host") { + process.stdout.write(report.suggestedHost + "\n"); + } else { + process.stdout.write(JSON.stringify(report, null, 2) + "\n"); + } + return 0; +} + +if (import.meta.main) { + main().then((code) => process.exit(code)); +}