Files
felhom-controller/controller/internal/system/info_linux.go
T
admin c732fe1283
gates / gates (push) Successful in 18s
v0.210.0 — R-259 and R-258: two pictures that were not true
Both are one shape: something the box already knows, drawn as its opposite.

R-259 — A DISK WE FAILED TO READ WAS DRAWN AS A HEALTHY EMPTY DISK. readDiskUsage
(internal/system/info_linux.go) logged a statfs failure at DEBUG and returned, leaving the caller's
TotalGB/UsedGB/AvailGB/Percent at zero — and usageColor(0) is "nominal". The dashboard's
most-looked-at meter therefore rendered "0.0 GB / 0.0 GB (0%)" with a 0%-wide bar in the healthy
colour. "We could not look" and "there is plenty of room" were the same picture.

readDiskUsage now returns whether the measurement succeeded; SystemInfo gains DiskKnown and
HDDKnown (HDDConfigured is not a substitute: it says a path was configured, not that reading it
worked); and the template draws NO figure, NO percentage and NO meter fill when unknown, saying
"A tarhely merete most nem olvashato ki." instead. A healthy box is byte-identical, colour band
included.

This session rules the convention (felhom.eu CONTEXT.md S-39): an explicit `...Known bool` companion
beside the figures, checked in the template — the shape Offbox.StatsKnown already uses, whose own
comment says "a 0%-wide bar over an unread store is a picture of emptiness, and a picture is a
claim". Pointers and separate error fields are both legitimate Go, but a codebase with three
dialects cannot be gated (ROADMAP G-3 was blocked on exactly this). Existing call sites NOT
converted.

R-258 — THE PER-APP BACKUP TICK WAS GREEN ON PRESENCE, AND RED ONLY ON A GLOBAL CONDITION.
buildAppBackupRows set Tier1LastStatus from status.LastDBDump.Success, which is the box's single
most recent dump RUN, whichever app it belonged to. An app whose own dump failed showed a tick as
long as some other app dumped successfully afterwards; an app with no database took the nil branch
and went green on the mere existence of a restore point.

appDumpVerdict now reads THIS app's own entries in DBDumpStatus.Results (matched on
DumpResult.DB.StackName, failure = non-nil Error). Three states: any failing database -> error; all
clean -> ok; no result recorded -> NO verdict and no icon, titled "Errol a mentesrol nincs
eredmenyunk." The recovery unit carries no per-run outcome of its own, so green cannot honestly be
derived from presence. The global tier1DBStatus label is untouched — it is correct as a global.

RECENCY IS DELIBERATELY NOT ADDED. A tick over a three-week-old restore point is a real weakness,
but an age threshold means inventing a number and the time is already printed beside the icon.
Recorded as an observation, not changed.

AN EXISTING TEST WAS ASSERTING THE DEFECT AND WAS CORRECTED, NOT DELETED:
TestBuildAppBackupRows_Tier1FromRestorePoints expected "ok" for a status with no LastDBDump at all —
green from nothing but a file's existence. It now expects no verdict; its real subject, the
Tier1LastRun time, is unchanged.

The dashboard test EXTRACTS the meter block from the shipped template rather than copying it: a
copied block drifts, and a drifted copy passes while the page it claims to cover has changed — the
fixture-is-not-the-wire mistake this project has now hit twice.

Six red-proofs across both parts, each with the mutation asserted applied.

No new tag on any declared wire — report/builder.go maps into its own types and is untouched;
wire_contract_gate.py confirmed green.

go build / go vet / go test ./... green (28 packages), controller_gates --fast all OK, both run
separately from this commit.
2026-08-08 16:29:52 +02:00

410 lines
13 KiB
Go

//go:build linux
package system
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// GetInfo reads system memory, disk, CPU, load, and temperature info.
// hddPath is the mount path for external HDD; if empty, HDD info is skipped.
// cpuCollector provides the latest CPU usage sample; may be nil.
func GetInfo(hddPath string, cpuCollector *CPUCollector) SystemInfo {
start := time.Now()
debugf("[DEBUG] [system] GetInfo starting (hddPath=%q, hasCPUCollector=%v)", hddPath, cpuCollector != nil)
info := SystemInfo{}
// --- Memory from /proc/meminfo ---
readMemInfo(&info)
// --- Root filesystem disk usage ---
info.DiskKnown = readDiskUsage("/", &info.DiskTotalGB, &info.DiskUsedGB, &info.DiskAvailGB, &info.DiskPercent)
// --- HDD disk usage (if configured) ---
if hddPath != "" {
info.HDDConfigured = true
info.HDDKnown = readDiskUsage(hddPath, &info.HDDTotalGB, &info.HDDUsedGB, &info.HDDAvailGB, &info.HDDPercent)
}
// --- Load average ---
readLoadAvg(&info)
// --- Temperature ---
readTemperature(&info)
// --- CPU from collector ---
if cpuCollector != nil {
info.CPUPercent = cpuCollector.CPUPercent()
}
debugf("[DEBUG] [system] GetInfo done in %s — mem=%dMB/%dMB (%.1f%%), rootDisk=%.1fGB/%.1fGB (%.1f%%), load=%.2f/%.2f/%.2f, temp=%.1f°C (%s), cpu=%.1f%%",
time.Since(start).Round(time.Millisecond),
info.UsedMemMB, info.TotalMemMB, info.MemPercent,
info.DiskUsedGB, info.DiskTotalGB, info.DiskPercent,
info.LoadAvg1, info.LoadAvg5, info.LoadAvg15,
info.TemperatureCelsius, info.TemperatureSource,
info.CPUPercent,
)
return info
}
// GetTotalMemoryMB reads total system memory from /proc/meminfo.
func GetTotalMemoryMB() (int, error) {
info := SystemInfo{}
readMemInfo(&info)
if info.TotalMemMB == 0 {
return 0, fmt.Errorf("could not read MemTotal from /proc/meminfo")
}
return int(info.TotalMemMB), nil
}
// GetMemoryMB returns total and used system memory in MB from /proc/meminfo.
func GetMemoryMB() (totalMB, usedMB int, err error) {
info := SystemInfo{}
readMemInfo(&info)
if info.TotalMemMB == 0 {
return 0, 0, fmt.Errorf("could not read MemTotal from /proc/meminfo")
}
return int(info.TotalMemMB), int(info.UsedMemMB), nil
}
// cgroupRoot is the cgroup mount point. Overridable in tests.
var cgroupRoot = "/sys/fs/cgroup"
func readMemInfo(info *SystemInfo) {
f, err := os.Open("/proc/meminfo")
if err != nil {
debugf("[DEBUG] [system] readMemInfo: failed to open /proc/meminfo: %v", err)
return
}
defer f.Close()
var totalKB, availKB uint64
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
switch {
case strings.HasPrefix(line, "MemTotal:"):
totalKB = parseMemLine(line)
case strings.HasPrefix(line, "MemAvailable:"):
availKB = parseMemLine(line)
}
if totalKB > 0 && availKB > 0 {
break
}
}
if totalKB == 0 {
debugf("[DEBUG] [system] readMemInfo: could not parse MemTotal from /proc/meminfo")
return
}
info.TotalMemMB = totalKB / 1024
info.AvailMemMB = availKB / 1024
info.UsedMemMB = info.TotalMemMB - info.AvailMemMB
// F1: the controller runs as a Docker container inside an LXC. /proc/meminfo reports the HOST's RAM
// (no lxcfs in the container) and the container's OWN cgroup is unlimited (the 2GB cap lives on the
// LXC, an ancestor hidden from the container), so the reported total massively overstates the guest's
// real ceiling and defeats the deploy memory-headroom guard. Determine the true guest cap from, in
// order: the container's cgroup limit (correct when Docker sets -m, e.g. non-nested deploys), else
// `docker info` MemTotal (dockerd runs IN the LXC and reports the guest's lxcfs-backed RAM — the
// accurate cap in the nested-LXC case). The instantaneous guest-wide RSS is NOT observable from the
// container, so when we override the cap we scale the host's used-fraction onto it as an estimate for
// display; the CAP itself (what the headroom math depends on) is accurate. The deploy guard uses the
// controller's own committed-memory accounting for "used", so safety does not rely on this estimate.
capMB := uint64(0)
if v, ok := readCgroupMemLimitMB(cgroupRoot); ok && v > 0 && v < info.TotalMemMB {
capMB = v
}
if capMB == 0 {
if v, ok := guestMemTotalMB(); ok && v > 0 && v < info.TotalMemMB {
capMB = v
}
}
if capMB > 0 && capMB < info.TotalMemMB {
frac := 0.0
if info.TotalMemMB > 0 {
frac = float64(info.UsedMemMB) / float64(info.TotalMemMB)
}
info.TotalMemMB = capMB
// Scaled host-pressure estimate (the container can't read guest-wide RSS). The /api/system/info
// handler overrides this with the controller's committed-app memory for an accurate figure; this
// estimate covers the other GetInfo callers (monitoring) without alarming at ~100%.
info.UsedMemMB = uint64(float64(capMB) * frac)
info.AvailMemMB = info.TotalMemMB - info.UsedMemMB
debugf("[DEBUG] [system] readMemInfo: guest cap=%dMB (host total was %dKB) → used≈%dMB avail≈%dMB",
capMB, totalKB, info.UsedMemMB, info.AvailMemMB)
}
if info.TotalMemMB > 0 {
info.MemPercent = float64(info.UsedMemMB) / float64(info.TotalMemMB) * 100
}
debugf("[DEBUG] [system] readMemInfo: totalKB=%d availKB=%d → total=%dMB avail=%dMB used=%dMB (%.1f%%)",
totalKB, availKB, info.TotalMemMB, info.AvailMemMB, info.UsedMemMB, info.MemPercent)
}
// guestMemTotalMB returns the guest's total RAM (MB) as reported by the Docker daemon. The daemon runs
// inside the LXC, so `docker info` MemTotal reflects the guest's lxcfs-backed /proc/meminfo (the real
// cap) — unlike the container's own /proc/meminfo, which shows the Proxmox host's RAM. Overridable in
// tests via dockerMemTotalFn.
func guestMemTotalMB() (uint64, bool) {
if dockerMemTotalFn != nil {
return dockerMemTotalFn()
}
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "docker", "info", "--format", "{{.MemTotal}}").Output()
if err != nil {
return 0, false
}
bytesVal, err := strconv.ParseUint(strings.TrimSpace(string(out)), 10, 64)
if err != nil || bytesVal == 0 {
return 0, false
}
return bytesVal / (1024 * 1024), true
}
// dockerMemTotalFn lets tests stub the docker-info read.
var dockerMemTotalFn func() (uint64, bool)
// GuestMemTotalMB returns the guest's memory cap in MB (docker-info MemTotal), preferring the cgroup
// limit when finite. ok=false if neither is determinable. The deploy memory guard uses this as the
// accurate denominator (the controller container cannot read the guest cap from /proc — no lxcfs).
func GuestMemTotalMB() (int, bool) {
if v, ok := readCgroupMemLimitMB(cgroupRoot); ok && v > 0 {
return int(v), true
}
if v, ok := guestMemTotalMB(); ok && v > 0 {
return int(v), true
}
return 0, false
}
// readCgroupMemLimitMB returns the cgroup memory limit in MB. It tries cgroup v2 (memory.max) first,
// then v1 (memory/memory.limit_in_bytes). A sentinel ("max" on v2, or a near-uint64-max value on v1)
// means "unlimited" → ok=false so the caller keeps the /proc/meminfo value.
func readCgroupMemLimitMB(root string) (mb uint64, ok bool) {
// cgroup v2
if b, err := os.ReadFile(filepath.Join(root, "memory.max")); err == nil {
s := strings.TrimSpace(string(b))
if s == "max" {
return 0, false
}
if v, err := strconv.ParseUint(s, 10, 64); err == nil && v > 0 {
return v / (1024 * 1024), true
}
}
// cgroup v1
if b, err := os.ReadFile(filepath.Join(root, "memory", "memory.limit_in_bytes")); err == nil {
s := strings.TrimSpace(string(b))
if v, err := strconv.ParseUint(s, 10, 64); err == nil && v > 0 {
// v1 "unlimited" is a huge page-aligned value near uint64 max; treat >= 1 PiB as unlimited.
if v >= (1 << 50) {
return 0, false
}
return v / (1024 * 1024), true
}
}
return 0, false
}
// readCgroupMemCurrentMB returns the cgroup current memory usage in MB (v2 memory.current, v1
// memory/memory.usage_in_bytes). ok=false if unreadable.
func readCgroupMemCurrentMB(root string) (mb uint64, ok bool) {
for _, p := range []string{
filepath.Join(root, "memory.current"),
filepath.Join(root, "memory", "memory.usage_in_bytes"),
} {
if b, err := os.ReadFile(p); err == nil {
if v, err := strconv.ParseUint(strings.TrimSpace(string(b)), 10, 64); err == nil {
return v / (1024 * 1024), true
}
}
}
return 0, false
}
// parseMemLine extracts the kB value from a /proc/meminfo line like "MemTotal: 16384000 kB"
func parseMemLine(line string) uint64 {
parts := strings.SplitN(line, ":", 2)
if len(parts) < 2 {
return 0
}
valStr := strings.TrimSpace(parts[1])
valStr = strings.TrimSuffix(valStr, " kB")
valStr = strings.TrimSpace(valStr)
var val uint64
for _, c := range valStr {
if c >= '0' && c <= '9' {
val = val*10 + uint64(c-'0')
}
}
return val
}
// readDiskUsage fills the four figures and reports whether the measurement SUCCEEDED (R-259).
//
// It used to return nothing. On a statfs error it logged at DEBUG and returned, leaving the
// caller's floats at zero — and zero renders as a healthy empty disk. The boolean is the whole
// fix: the caller now knows the difference between "0 GB used" and "we could not look", and the
// template refuses to draw a picture it does not have.
func readDiskUsage(path string, totalGB, usedGB, availGB *float64, percent *float64) bool {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
debugf("[DEBUG] [system] readDiskUsage: statfs(%q) failed: %v", path, err)
return false
}
bsize := uint64(stat.Bsize)
total := stat.Blocks * bsize
avail := stat.Bavail * bsize
used := total - (stat.Bfree * bsize)
const gb = 1024 * 1024 * 1024
*totalGB = float64(total) / gb
*usedGB = float64(used) / gb
*availGB = float64(avail) / gb
if total > 0 {
*percent = float64(used) / float64(total) * 100
}
debugf("[DEBUG] [system] readDiskUsage: path=%q bsize=%d total=%.1fGB used=%.1fGB avail=%.1fGB (%.1f%%)",
path, bsize, *totalGB, *usedGB, *availGB, *percent)
return true
}
// readLoadAvg reads 1/5/15 minute load averages from /proc/loadavg.
func readLoadAvg(info *SystemInfo) {
data, err := os.ReadFile("/proc/loadavg")
if err != nil {
debugf("[DEBUG] [system] readLoadAvg: failed to read /proc/loadavg: %v", err)
return
}
fmt.Sscanf(string(data), "%f %f %f", &info.LoadAvg1, &info.LoadAvg5, &info.LoadAvg15)
debugf("[DEBUG] [system] readLoadAvg: raw=%q → 1m=%.2f 5m=%.2f 15m=%.2f",
strings.TrimSpace(string(data)), info.LoadAvg1, info.LoadAvg5, info.LoadAvg15)
}
// readTemperature reads CPU/SoC temperature from thermal zones.
// Tries /host/sys first (Docker mount), then /sys (native).
func readTemperature(info *SystemInfo) {
prefixes := []string{"/host/sys", "/sys"}
for _, prefix := range prefixes {
if readThermalZones(prefix, info) {
debugf("[DEBUG] [system] readTemperature: found via thermal_zone at %s — %.1f°C (%s)", prefix, info.TemperatureCelsius, info.TemperatureSource)
return
}
}
// Fallback: try hwmon
for _, prefix := range prefixes {
if readHwmon(prefix, info) {
debugf("[DEBUG] [system] readTemperature: found via hwmon at %s — %.1f°C (%s)", prefix, info.TemperatureCelsius, info.TemperatureSource)
return
}
}
debugf("[DEBUG] [system] readTemperature: no temperature source found")
}
func readThermalZones(sysPrefix string, info *SystemInfo) bool {
pattern := filepath.Join(sysPrefix, "class", "thermal", "thermal_zone*", "temp")
matches, err := filepath.Glob(pattern)
if err != nil || len(matches) == 0 {
return false
}
sort.Strings(matches)
debugf("[DEBUG] [system] readThermalZones: %s — found %d zones", sysPrefix, len(matches))
var maxTemp float64
var maxSource string
for _, tempPath := range matches {
data, err := os.ReadFile(tempPath)
if err != nil {
continue
}
var milliDeg int64
if _, err := fmt.Sscanf(strings.TrimSpace(string(data)), "%d", &milliDeg); err != nil {
continue
}
temp := float64(milliDeg) / 1000.0
// Read the type file for the label
zoneDir := filepath.Dir(tempPath)
typePath := filepath.Join(zoneDir, "type")
typeData, err := os.ReadFile(typePath)
source := strings.TrimSpace(string(typeData))
if err != nil || source == "" {
source = filepath.Base(zoneDir)
}
if temp > maxTemp {
maxTemp = temp
maxSource = source
}
}
if maxTemp > 0 {
info.TemperatureCelsius = maxTemp
info.TemperatureSource = maxSource
return true
}
return false
}
func readHwmon(sysPrefix string, info *SystemInfo) bool {
pattern := filepath.Join(sysPrefix, "class", "hwmon", "hwmon*", "temp1_input")
matches, err := filepath.Glob(pattern)
if err != nil || len(matches) == 0 {
return false
}
var maxTemp float64
var maxSource string
for _, tempPath := range matches {
data, err := os.ReadFile(tempPath)
if err != nil {
continue
}
var milliDeg int64
if _, err := fmt.Sscanf(strings.TrimSpace(string(data)), "%d", &milliDeg); err != nil {
continue
}
temp := float64(milliDeg) / 1000.0
source := filepath.Base(filepath.Dir(tempPath))
if temp > maxTemp {
maxTemp = temp
maxSource = source
}
}
if maxTemp > 0 {
info.TemperatureCelsius = maxTemp
info.TemperatureSource = maxSource
return true
}
return false
}