//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 --- readDiskUsage("/", &info.DiskTotalGB, &info.DiskUsedGB, &info.DiskAvailGB, &info.DiskPercent) // --- HDD disk usage (if configured) --- if hddPath != "" { info.HDDConfigured = true 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 } func readDiskUsage(path string, totalGB, usedGB, availGB *float64, percent *float64) { var stat syscall.Statfs_t if err := syscall.Statfs(path, &stat); err != nil { debugf("[DEBUG] [system] readDiskUsage: statfs(%q) failed: %v", path, err) return } 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) } // 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 }