forgejo-runner-optimiser/internal/proc/status.go
Manuel Ganter 219d26959f
feat: add resource collector for Forgejo runners
Add Go application that collects CPU and RAM metrics from /proc filesystem:
- Parse /proc/[pid]/stat for CPU usage (user/system time)
- Parse /proc/[pid]/status for memory usage (RSS, VmSize, etc.)
- Aggregate metrics across all processes
- Output via structured logging (JSON/text)
- Continuous collection with configurable interval

Designed for monitoring pipeline runner resource utilization to enable
dynamic runner sizing.
2026-02-04 14:13:24 +01:00

147 lines
3.6 KiB
Go

package proc
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
// ProcStatus holds memory-related information from /proc/[pid]/status
type ProcStatus struct {
PID int
Name string // Process name
VmSize uint64 // Virtual memory size in bytes
VmRSS uint64 // Resident Set Size in bytes (actual RAM used)
VmPeak uint64 // Peak virtual memory size in bytes
VmData uint64 // Data segment size in bytes
VmStk uint64 // Stack size in bytes
VmExe uint64 // Text (code) size in bytes
VmLib uint64 // Shared library code size in bytes
RssAnon uint64 // Anonymous RSS in bytes
RssFile uint64 // File-backed RSS in bytes
RssShmem uint64 // Shared memory RSS in bytes
Threads int // Number of threads
UID int // Real user ID
GID int // Real group ID
}
// ReadStatus reads and parses /proc/[pid]/status for the given PID
func ReadStatus(procPath string, pid int) (*ProcStatus, error) {
path := fmt.Sprintf("%s/%d/status", procPath, pid)
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("opening status file: %w", err)
}
defer func() { _ = file.Close() }()
return parseStatus(pid, file)
}
// parseStatus parses the content of /proc/[pid]/status
func parseStatus(pid int, file *os.File) (*ProcStatus, error) {
status := &ProcStatus{PID: pid}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
switch key {
case "Name":
status.Name = value
case "VmSize":
status.VmSize = parseMemoryValue(value)
case "VmRSS":
status.VmRSS = parseMemoryValue(value)
case "VmPeak":
status.VmPeak = parseMemoryValue(value)
case "VmData":
status.VmData = parseMemoryValue(value)
case "VmStk":
status.VmStk = parseMemoryValue(value)
case "VmExe":
status.VmExe = parseMemoryValue(value)
case "VmLib":
status.VmLib = parseMemoryValue(value)
case "RssAnon":
status.RssAnon = parseMemoryValue(value)
case "RssFile":
status.RssFile = parseMemoryValue(value)
case "RssShmem":
status.RssShmem = parseMemoryValue(value)
case "Threads":
if n, err := strconv.Atoi(value); err == nil {
status.Threads = n
}
case "Uid":
// Format: "Uid: real effective saved filesystem"
fields := strings.Fields(value)
if len(fields) > 0 {
if uid, err := strconv.Atoi(fields[0]); err == nil {
status.UID = uid
}
}
case "Gid":
// Format: "Gid: real effective saved filesystem"
fields := strings.Fields(value)
if len(fields) > 0 {
if gid, err := strconv.Atoi(fields[0]); err == nil {
status.GID = gid
}
}
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("reading status file: %w", err)
}
return status, nil
}
// parseMemoryValue parses memory values from /proc/[pid]/status
// Format is typically "1234 kB"
func parseMemoryValue(value string) uint64 {
fields := strings.Fields(value)
if len(fields) == 0 {
return 0
}
num, err := strconv.ParseUint(fields[0], 10, 64)
if err != nil {
return 0
}
// Convert to bytes if unit is specified
if len(fields) > 1 {
unit := strings.ToLower(fields[1])
switch unit {
case "kb":
num *= 1024
case "mb":
num *= 1024 * 1024
case "gb":
num *= 1024 * 1024 * 1024
}
}
return num
}
// TotalRSS returns the total RSS (RssAnon + RssFile + RssShmem)
// Falls back to VmRSS if the detailed fields are not available
func (s *ProcStatus) TotalRSS() uint64 {
total := s.RssAnon + s.RssFile + s.RssShmem
if total == 0 {
return s.VmRSS
}
return total
}