This commit is contained in:
illustris
2026-03-11 15:01:20 +05:30
commit d8f4a77657
30 changed files with 3164 additions and 0 deletions

View File

@@ -0,0 +1,394 @@
package procfs
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
const clkTck = 100 // sysconf(_SC_CLK_TCK) on Linux
// QEMUProcess holds info discovered from /proc for a QEMU VM.
type QEMUProcess struct {
PID int
VMID string
Name string
CPU string
Vcores int
MaxMem int64 // in kB (parsed from cmdline)
}
// CPUTimes holds parsed CPU times from /proc/{pid}/stat.
type CPUTimes struct {
User float64
System float64
IOWait float64
}
// IOCounters holds parsed I/O counters from /proc/{pid}/io.
type IOCounters struct {
ReadChars uint64
WriteChars uint64
ReadSyscalls uint64
WriteSyscalls uint64
ReadBytes uint64
WriteBytes uint64
}
// CtxSwitches holds context switch counts from /proc/{pid}/status.
type CtxSwitches struct {
Voluntary uint64
Involuntary uint64
}
// MemoryExtended holds memory info from /proc/{pid}/status (values in bytes).
type MemoryExtended map[string]int64
// ProcReader abstracts /proc access for testability.
type ProcReader interface {
DiscoverQEMUProcesses() ([]QEMUProcess, error)
GetCPUTimes(pid int) (CPUTimes, error)
GetIOCounters(pid int) (IOCounters, error)
GetNumThreads(pid int) (int, error)
GetMemoryPercent(pid int) (float64, error)
GetMemoryExtended(pid int) (MemoryExtended, error)
GetCtxSwitches(pid int) (CtxSwitches, error)
VMConfigExists(vmid string) bool
}
// RealProcReader reads from the actual /proc filesystem.
type RealProcReader struct {
ProcPath string // default "/proc"
PVECfgPath string // default "/etc/pve/qemu-server"
}
func NewRealProcReader() *RealProcReader {
return &RealProcReader{
ProcPath: "/proc",
PVECfgPath: "/etc/pve/qemu-server",
}
}
func (r *RealProcReader) DiscoverQEMUProcesses() ([]QEMUProcess, error) {
entries, err := os.ReadDir(r.ProcPath)
if err != nil {
return nil, err
}
var procs []QEMUProcess
for _, e := range entries {
if !e.IsDir() {
continue
}
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
exe, err := os.Readlink(filepath.Join(r.ProcPath, e.Name(), "exe"))
if err != nil {
continue
}
if exe != "/usr/bin/qemu-system-x86_64" {
continue
}
cmdlineBytes, err := os.ReadFile(filepath.Join(r.ProcPath, e.Name(), "cmdline"))
if err != nil {
continue
}
cmdline := ParseCmdline(cmdlineBytes)
vmid := FlagValue(cmdline, "-id")
if vmid == "" {
continue
}
if !r.VMConfigExists(vmid) {
continue
}
proc := QEMUProcess{
PID: pid,
VMID: vmid,
Name: FlagValue(cmdline, "-name"),
CPU: FlagValue(cmdline, "-cpu"),
}
proc.Vcores = ParseVcores(cmdline)
proc.MaxMem = ParseMem(cmdline)
procs = append(procs, proc)
}
return procs, nil
}
func (r *RealProcReader) VMConfigExists(vmid string) bool {
_, err := os.Stat(filepath.Join(r.PVECfgPath, vmid+".conf"))
return err == nil
}
func (r *RealProcReader) GetCPUTimes(pid int) (CPUTimes, error) {
data, err := os.ReadFile(filepath.Join(r.ProcPath, strconv.Itoa(pid), "stat"))
if err != nil {
return CPUTimes{}, err
}
return ParseStat(string(data))
}
func (r *RealProcReader) GetIOCounters(pid int) (IOCounters, error) {
data, err := os.ReadFile(filepath.Join(r.ProcPath, strconv.Itoa(pid), "io"))
if err != nil {
return IOCounters{}, err
}
return ParseIO(string(data))
}
func (r *RealProcReader) GetNumThreads(pid int) (int, error) {
data, err := os.ReadFile(filepath.Join(r.ProcPath, strconv.Itoa(pid), "status"))
if err != nil {
return 0, err
}
return ParseThreads(string(data))
}
func (r *RealProcReader) GetMemoryPercent(pid int) (float64, error) {
// Read process RSS and total memory to compute percentage
statusData, err := os.ReadFile(filepath.Join(r.ProcPath, strconv.Itoa(pid), "status"))
if err != nil {
return 0, err
}
rss := int64(0)
for _, line := range strings.Split(string(statusData), "\n") {
if strings.HasPrefix(line, "VmRSS:") {
parts := strings.Fields(line)
if len(parts) >= 2 {
rss, _ = strconv.ParseInt(parts[1], 10, 64)
rss *= 1024 // kB to bytes
}
break
}
}
meminfoData, err := os.ReadFile(filepath.Join(r.ProcPath, "meminfo"))
if err != nil {
return 0, err
}
totalMem := int64(0)
for _, line := range strings.Split(string(meminfoData), "\n") {
if strings.HasPrefix(line, "MemTotal:") {
parts := strings.Fields(line)
if len(parts) >= 2 {
totalMem, _ = strconv.ParseInt(parts[1], 10, 64)
totalMem *= 1024 // kB to bytes
}
break
}
}
if totalMem == 0 {
return 0, nil
}
return float64(rss) / float64(totalMem) * 100.0, nil
}
func (r *RealProcReader) GetMemoryExtended(pid int) (MemoryExtended, error) {
data, err := os.ReadFile(filepath.Join(r.ProcPath, strconv.Itoa(pid), "status"))
if err != nil {
return nil, err
}
return ParseMemoryExtended(string(data)), nil
}
func (r *RealProcReader) GetCtxSwitches(pid int) (CtxSwitches, error) {
data, err := os.ReadFile(filepath.Join(r.ProcPath, strconv.Itoa(pid), "status"))
if err != nil {
return CtxSwitches{}, err
}
return ParseCtxSwitches(string(data))
}
// ParseCmdline splits a null-byte separated /proc/{pid}/cmdline.
func ParseCmdline(data []byte) []string {
s := string(data)
if len(s) == 0 {
return nil
}
// Remove trailing null byte if present
s = strings.TrimRight(s, "\x00")
return strings.Split(s, "\x00")
}
// FlagValue returns the value after a flag in cmdline args.
func FlagValue(cmdline []string, flag string) string {
for i, arg := range cmdline {
if arg == flag && i+1 < len(cmdline) {
return cmdline[i+1]
}
}
return ""
}
// ParseVcores extracts vCPU count from -smp flag.
// -smp can be just a number or key=value pairs like "4,sockets=1,cores=4,maxcpus=4"
func ParseVcores(cmdline []string) int {
smp := FlagValue(cmdline, "-smp")
if smp == "" {
return 0
}
// Try simple numeric
parts := strings.Split(smp, ",")
n, err := strconv.Atoi(parts[0])
if err == nil {
return n
}
// Try key=value format
for _, p := range parts {
kv := strings.SplitN(p, "=", 2)
if len(kv) == 2 && kv[0] == "cpus" {
n, _ = strconv.Atoi(kv[1])
return n
}
}
return 0
}
// ParseMem extracts max memory in kB from cmdline.
// Simple: -m 1024 -> 1024*1024 kB
// NUMA: memory-backend-ram...size=NM -> sum * 1024 kB
func ParseMem(cmdline []string) int64 {
mVal := FlagValue(cmdline, "-m")
if mVal == "" {
return 0
}
// Simple numeric case
if n, err := strconv.ParseInt(mVal, 10, 64); err == nil {
return n * 1024 // MB to kB
}
// NUMA case: search for memory-backend-ram in all args
var total int64
for _, arg := range cmdline {
if strings.Contains(arg, "memory-backend-ram") {
// Format: ...size=XXXM
for _, part := range strings.Split(arg, ",") {
if strings.HasPrefix(part, "size=") {
sizeStr := strings.TrimPrefix(part, "size=")
if strings.HasSuffix(sizeStr, "M") {
sizeStr = strings.TrimSuffix(sizeStr, "M")
if n, err := strconv.ParseInt(sizeStr, 10, 64); err == nil {
total += n * 1024 // MB to kB
}
}
}
}
}
}
return total
}
// ParseStat extracts CPU times from /proc/{pid}/stat.
// Fields: (1-indexed) 14=utime, 15=stime, 42=delayacct_blkio_ticks
func ParseStat(data string) (CPUTimes, error) {
// Find the closing paren of comm field to handle spaces in process names
closeIdx := strings.LastIndex(data, ")")
if closeIdx < 0 {
return CPUTimes{}, fmt.Errorf("malformed stat: no closing paren")
}
// Fields after ") " are 1-indexed starting at field 3
rest := data[closeIdx+2:]
fields := strings.Fields(rest)
// field 14 (utime) is at index 14-3=11, field 15 (stime) at 12, field 42 at 39
if len(fields) < 40 {
return CPUTimes{}, fmt.Errorf("not enough fields in stat: %d", len(fields))
}
utime, _ := strconv.ParseUint(fields[11], 10, 64)
stime, _ := strconv.ParseUint(fields[12], 10, 64)
blkio, _ := strconv.ParseUint(fields[39], 10, 64)
return CPUTimes{
User: float64(utime) / clkTck,
System: float64(stime) / clkTck,
IOWait: float64(blkio) / clkTck,
}, nil
}
// ParseIO parses /proc/{pid}/io.
func ParseIO(data string) (IOCounters, error) {
var io IOCounters
for _, line := range strings.Split(data, "\n") {
parts := strings.SplitN(line, ": ", 2)
if len(parts) != 2 {
continue
}
val, err := strconv.ParseUint(strings.TrimSpace(parts[1]), 10, 64)
if err != nil {
continue
}
switch parts[0] {
case "rchar":
io.ReadChars = val
case "wchar":
io.WriteChars = val
case "syscr":
io.ReadSyscalls = val
case "syscw":
io.WriteSyscalls = val
case "read_bytes":
io.ReadBytes = val
case "write_bytes":
io.WriteBytes = val
}
}
return io, nil
}
// ParseThreads extracts the Threads count from /proc/{pid}/status.
func ParseThreads(data string) (int, error) {
for _, line := range strings.Split(data, "\n") {
if strings.HasPrefix(line, "Threads:") {
parts := strings.Fields(line)
if len(parts) >= 2 {
return strconv.Atoi(parts[1])
}
}
}
return 0, fmt.Errorf("Threads field not found")
}
// ParseMemoryExtended parses /proc/{pid}/status for Vm*/Rss*/Hugetlb* lines.
// Returns map with lowercase keys (trailing colon preserved) to values in bytes.
func ParseMemoryExtended(data string) MemoryExtended {
m := make(MemoryExtended)
for _, line := range strings.Split(data, "\n") {
if strings.HasPrefix(line, "Vm") || strings.HasPrefix(line, "Rss") || strings.HasPrefix(line, "Hugetlb") {
parts := strings.Fields(line)
if len(parts) >= 2 {
key := strings.ToLower(parts[0]) // keeps trailing colon
val, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
continue
}
if len(parts) >= 3 && parts[2] == "kB" {
val *= 1024
}
m[key] = val
}
}
}
return m
}
// ParseCtxSwitches parses voluntary/involuntary context switches from /proc/{pid}/status.
func ParseCtxSwitches(data string) (CtxSwitches, error) {
var cs CtxSwitches
for _, line := range strings.Split(data, "\n") {
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
switch parts[0] {
case "voluntary_ctxt_switches:":
cs.Voluntary, _ = strconv.ParseUint(parts[1], 10, 64)
case "nonvoluntary_ctxt_switches:":
cs.Involuntary, _ = strconv.ParseUint(parts[1], 10, 64)
}
}
return cs, nil
}