host: raw metric probes (mem/load/disk/release/tunnel) into the heartbeat Box
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+112
−0
internal/host/agent.go+8 −0internal/host/host_test.go+20 −0internal/host/probes.go+84 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 59d4a5e07..8316313ca 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -87,6 +87,7 @@ import ( |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/status" |
| + "github.com/carlosframework/platform/internal/version" |
| ) |
| // dataDir resolves the fleet data directory (CARLOS_DATA_DIR, default /data). |
| @@ -624,6 +625,7 @@ func statusLoop(reg *registry.Registry) { |
| continue |
| } |
| + memTotal, memAvail := readMemBytes() |
| p := status.Probes{ |
| MachineID: readMachineID(), |
| Region: boxRegion, |
| @@ -647,6 +649,12 @@ func statusLoop(reg *registry.Registry) { |
| // skew?) would be worse than the honest null CollectBox already |
| // knows how to carry — wiring this up is a seam for later, not |
| // a gap to paper over now. |
| + MemTotalBytes: memTotal, |
| + MemAvailBytes: memAvail, |
| + Load1: readLoad1(), |
| + DiskFreeBytes: readDiskFreeBytes(), |
| + Release: version.Build, |
| + Tunnel: tunnelState(), |
| } |
| // path.Join (an S3 key, not a filesystem path — matches |
diff --git a/internal/host/host_test.go b/internal/host/host_test.go| index a94cc4d65..4aa4995cc 100644 |
| --- a/internal/host/host_test.go |
| +++ b/internal/host/host_test.go |
| @@ -490,6 +490,26 @@ func TestAdoptTimerStateReading(t *testing.T) { |
| func boolPtr(v bool) *bool { return &v } |
| +func TestParseMeminfoBytes(t *testing.T) { |
| + total, avail := parseMeminfoBytes(strings.NewReader( |
| + "MemTotal: 3961472 kB\nMemFree: 100 kB\nMemAvailable: 1980736 kB\n")) |
| + if total == nil || *total != 3961472*1024 || avail == nil || *avail != 1980736*1024 { |
| + t.Fatalf("got %v %v", total, avail) |
| + } |
| + if tt, aa := parseMeminfoBytes(strings.NewReader("garbage")); tt != nil || aa != nil { |
| + t.Fatal("unparseable meminfo must yield nil, not zero") |
| + } |
| +} |
| + |
| +func TestParseLoad1(t *testing.T) { |
| + if l := parseLoad1("0.42 0.36 0.30 1/234 5678\n"); l == nil || *l != 0.42 { |
| + t.Fatalf("got %v", l) |
| + } |
| + if l := parseLoad1(""); l != nil { |
| + t.Fatal("empty loadavg must yield nil") |
| + } |
| +} |
| + |
| // TestStatusPublishNeverReadsMetricsPrefixes is the privacy tripwire the |
| // monitoring spec commits to: operator-grade needles live under metrics/ and |
| // alerts/, and nothing the public status publisher writes may ever be |
diff --git a/internal/host/probes.go b/internal/host/probes.go| index 4fe69a30c..d51858002 100644 |
| --- a/internal/host/probes.go |
| +++ b/internal/host/probes.go |
| @@ -12,6 +12,7 @@ package host |
| import ( |
| "bufio" |
| + "io" |
| "os" |
| "os/exec" |
| "strconv" |
| @@ -218,3 +219,86 @@ func wrapListeningProbe(unitBacked map[string]bool, probe func(string) *bool) fu |
| return probe(sock) |
| } |
| } |
| + |
| +// parseMeminfoBytes reads /proc/meminfo's MemTotal and MemAvailable (both in |
| +// kB) and returns them converted to bytes. Either field missing or |
| +// unparsable -> nil for both. |
| +func parseMeminfoBytes(r io.Reader) (*int64, *int64) { |
| + var totalKB, availKB int64 |
| + haveTotal, haveAvail := false, false |
| + scanner := bufio.NewScanner(r) |
| + for scanner.Scan() { |
| + fields := strings.Fields(scanner.Text()) |
| + if len(fields) < 2 { |
| + continue |
| + } |
| + v, err := strconv.ParseInt(fields[1], 10, 64) |
| + if err != nil { |
| + continue |
| + } |
| + switch fields[0] { |
| + case "MemTotal:": |
| + totalKB, haveTotal = v, true |
| + case "MemAvailable:": |
| + availKB, haveAvail = v, true |
| + } |
| + } |
| + if err := scanner.Err(); err != nil || !haveTotal || !haveAvail || totalKB <= 0 { |
| + return nil, nil |
| + } |
| + totalBytes := totalKB * 1024 |
| + availBytes := availKB * 1024 |
| + return &totalBytes, &availBytes |
| +} |
| + |
| +// readMemBytes reads /proc/meminfo and returns MemTotal and MemAvailable as |
| +// bytes. Unreadable or unparseable -> nil, nil. |
| +func readMemBytes() (*int64, *int64) { |
| + f, err := os.Open("/proc/meminfo") |
| + if err != nil { |
| + return nil, nil |
| + } |
| + defer f.Close() |
| + return parseMeminfoBytes(f) |
| +} |
| + |
| +// parseLoad1 parses /proc/loadavg's first field (1-minute load average) from |
| +// a string. The file format is: "load1 load5 load15 running/total lastpid". |
| +// Empty or unparseable -> nil. |
| +func parseLoad1(s string) *float64 { |
| + fields := strings.Fields(s) |
| + if len(fields) == 0 { |
| + return nil |
| + } |
| + l, err := strconv.ParseFloat(fields[0], 64) |
| + if err != nil { |
| + return nil |
| + } |
| + return &l |
| +} |
| + |
| +// readLoad1 reads /proc/loadavg and returns the 1-minute load average. |
| +// Unreadable or unparseable -> nil. |
| +func readLoad1() *float64 { |
| + b, err := os.ReadFile("/proc/loadavg") |
| + if err != nil { |
| + return nil |
| + } |
| + return parseLoad1(string(b)) |
| +} |
| + |
| +// readDiskFreeBytes reports how much free space is available on the data |
| +// volume, via syscall.Statfs on dataDir() — /data by default, CARLOS_DATA_DIR |
| +// when set. Statfs failure or a zero-block filesystem -> nil rather than a |
| +// crash. |
| +func readDiskFreeBytes() *int64 { |
| + var st syscall.Statfs_t |
| + if err := syscall.Statfs(dataDir(), &st); err != nil { |
| + return nil |
| + } |
| + if st.Blocks == 0 { |
| + return nil |
| + } |
| + freeBytes := int64(st.Bavail) * st.Bsize |
| + return &freeBytes |
| +} |