Public: anyone can read this; the crew can change it.
Boards, step 5a: readMachine(root) turns cgroup and /proc files into one CPU and memory point #2370
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2343-boards-step-5a-readmachine-root-turns-cg → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +111
−0
src/boards/machine.ts added +51
| @@ -0,0 +1,51 @@ | ||
| 1 | /** | |
| 2 | * The machine now, as one point: the pod's CPU and memory from its cgroup | |
| 3 | * v2 (`cpu.stat`, `memory.current`) and, when readable, the node's memory | |
| 4 | * (`meminfo`) and 1-min load (`loadavg`). `root` is the filesystem root | |
| 5 | * (`/` in production), so a test points it at a temp dir holding | |
| 6 | * `sys/fs/cgroup/…` and `proc/…`. Pure: no timer, no storage, never throws. | |
| 7 | */ | |
| 8 | import { readFileSync } from "node:fs"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { parseCpuStat, parseLoadavg, parseMeminfo } from "./sampler.ts"; | |
| 11 | ||
| 12 | /** Cumulative counters and gauges at one instant; each is null when its file is not readable. */ | |
| 13 | export type MachinePoint = { | |
| 14 | /** Pod CPU, µs since the cgroup began: a rate needs two points. */ | |
| 15 | cpuUsec: number | null; | |
| 16 | /** Pod memory, bytes. */ | |
| 17 | memoryBytes: number | null; | |
| 18 | /** Node memory in use, bytes. */ | |
| 19 | nodeMemoryBytes: number | null; | |
| 20 | /** Node 1-min load average. */ | |
| 21 | load: number | null; | |
| 22 | }; | |
| 23 | ||
| 24 | export type Machine = { ok: true; point: MachinePoint } | { ok: false; reason: string }; | |
| 25 | ||
| 26 | function read(path: string): string | null { | |
| 27 | try { | |
| 28 | return readFileSync(path, "utf8"); | |
| 29 | } catch { | |
| 30 | return null; | |
| 31 | } | |
| 32 | } | |
| 33 | ||
| 34 | /** A point from whatever is readable under `root`, or the reason nothing is. */ | |
| 35 | export function readMachine(root: string): Machine { | |
| 36 | try { | |
| 37 | const cgroup = join(root, "sys/fs/cgroup"); | |
| 38 | const proc = join(root, "proc"); | |
| 39 | const mem = read(join(cgroup, "memory.current")); | |
| 40 | const point: MachinePoint = { | |
| 41 | cpuUsec: parseCpuStat(read(join(cgroup, "cpu.stat"))), | |
| 42 | memoryBytes: mem !== null && /^\d+\s*$/.test(mem) ? Number(mem) : null, | |
| 43 | nodeMemoryBytes: parseMeminfo(read(join(proc, "meminfo"))), | |
| 44 | load: parseLoadavg(read(join(proc, "loadavg"))), | |
| 45 | }; | |
| 46 | if (Object.values(point).every((v) => v === null)) return { ok: false, reason: "no cgroup or /proc file is readable here" }; | |
| 47 | return { ok: true, point }; | |
| 48 | } catch { | |
| 49 | return { ok: false, reason: "the machine could not be read" }; | |
| 50 | } | |
| 51 | } |
test/boards-machine.test.ts added +60
| @@ -0,0 +1,60 @@ | ||
| 1 | /** | |
| 2 | * readMachine(root): fake cgroup and /proc files under a temp dir give the | |
| 3 | * expected point; what is missing is null; nothing readable gives a reason. | |
| 4 | */ | |
| 5 | import { afterEach, beforeEach, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { readMachine } from "../src/boards/machine.ts"; | |
| 10 | ||
| 11 | let root: string; | |
| 12 | beforeEach(() => { | |
| 13 | root = mkdtempSync(join(tmpdir(), "machine-")); | |
| 14 | }); | |
| 15 | afterEach(() => rmSync(root, { recursive: true, force: true })); | |
| 16 | ||
| 17 | const put = (rel: string, text: string) => { | |
| 18 | const p = join(root, rel); | |
| 19 | mkdirSync(join(p, ".."), { recursive: true }); | |
| 20 | writeFileSync(p, text); | |
| 21 | }; | |
| 22 | ||
| 23 | describe("readMachine", () => { | |
| 24 | test("turns cgroup and /proc files into one point", () => { | |
| 25 | put("sys/fs/cgroup/cpu.stat", "usage_usec 123456\nuser_usec 100\n"); | |
| 26 | put("sys/fs/cgroup/memory.current", "5242880\n"); | |
| 27 | put("proc/meminfo", "MemTotal: 1000 kB\nMemFree: 10 kB\nMemAvailable: 400 kB\n"); | |
| 28 | put("proc/loadavg", "0.52 0.40 0.30 1/200 999\n"); | |
| 29 | expect(readMachine(root)).toEqual({ | |
| 30 | ok: true, | |
| 31 | point: { cpuUsec: 123456, memoryBytes: 5242880, nodeMemoryBytes: 600 * 1024, load: 0.52 }, | |
| 32 | }); | |
| 33 | }); | |
| 34 | ||
| 35 | test("the cgroup alone is enough; the /proc fields are null", () => { | |
| 36 | put("sys/fs/cgroup/cpu.stat", "usage_usec 7\n"); | |
| 37 | put("sys/fs/cgroup/memory.current", "42\n"); | |
| 38 | expect(readMachine(root)).toEqual({ | |
| 39 | ok: true, | |
| 40 | point: { cpuUsec: 7, memoryBytes: 42, nodeMemoryBytes: null, load: null }, | |
| 41 | }); | |
| 42 | }); | |
| 43 | ||
| 44 | test("a garbled file is null, not a throw", () => { | |
| 45 | put("sys/fs/cgroup/memory.current", "max\n"); | |
| 46 | put("proc/loadavg", "1.5 1 1 1/1 1\n"); | |
| 47 | const m = readMachine(root); | |
| 48 | expect(m.ok && m.point).toEqual({ cpuUsec: null, memoryBytes: null, nodeMemoryBytes: null, load: 1.5 }); | |
| 49 | }); | |
| 50 | ||
| 51 | test("a missing directory gives the reason", () => { | |
| 52 | const m = readMachine(join(root, "nope")); | |
| 53 | expect(m.ok).toBe(false); | |
| 54 | expect(!m.ok && m.reason).toContain("readable"); | |
| 55 | }); | |
| 56 | ||
| 57 | test("a root with nothing in it gives the reason", () => { | |
| 58 | expect(readMachine(root).ok).toBe(false); | |
| 59 | }); | |
| 60 | }); |
new above
offline
This inline digits-only parse of memory.current belongs next to parseCpuStat in sampler.ts, so readCounters can share it.