Skip to content

Commit d68223f

Browse files
authored
Add CPU and memory metrics to enclave ping response (#33)
## Summary - Enclave ping/pong now reports CPU and memory utilization by reading `/proc/meminfo` and `/proc/stat` - Metrics are included as an optional `"system"` key in the pong JSON; if procfs is unavailable the pong still returns healthy without it - No new dependencies -- uses only the standard library to parse procfs ## Example response ```json { "type": "pong", "message": "TEE server is healthy", "timestamp": 1709500000, "system": { "mem_total_bytes": 1073741824, "mem_used_bytes": 536870912, "mem_usage_percent": 50.0, "cpu_usage_percent": 23.5, "num_cpus": 2 } } ``` ## Test plan - [x] All existing tests pass (`go test ./...`) - [x] 13 new unit tests for procfs parsing using temp-file fixtures (run on macOS, no live procfs needed) Made with [Cursor](https://cursor.com)
1 parent 411b9b2 commit d68223f

File tree

3 files changed

+384
-0
lines changed

3 files changed

+384
-0
lines changed

enclave/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ func (s *EnclaveServer) handleConnection(conn net.Conn) {
130130
"type": "pong",
131131
"message": "TEE server is healthy",
132132
"timestamp": time.Now().Unix(),
133+
"system": getSystemInfoOrNil(),
133134
}
134135
log.Printf("INFO: Responding to ping with pong")
135136

enclave/sysinfo.go

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
package main
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"log"
7+
"math"
8+
"os"
9+
"runtime"
10+
"strconv"
11+
"strings"
12+
"time"
13+
)
14+
15+
type SystemInfo struct {
16+
MemTotalBytes uint64 `json:"mem_total_bytes"`
17+
MemUsedBytes uint64 `json:"mem_used_bytes"`
18+
MemUsagePercent float64 `json:"mem_usage_percent"`
19+
CPUUsagePercent float64 `json:"cpu_usage_percent"`
20+
NumCPUs int `json:"num_cpus"`
21+
}
22+
23+
const cpuSampleInterval = 100 * time.Millisecond
24+
25+
func getSystemInfoOrNil() *SystemInfo {
26+
info, err := GetSystemInfo()
27+
if err != nil {
28+
log.Printf("WARN: Failed to collect system info: %v", err)
29+
return nil
30+
}
31+
return info
32+
}
33+
34+
func GetSystemInfo() (*SystemInfo, error) {
35+
mem, err := readMemInfo("/proc/meminfo")
36+
if err != nil {
37+
return nil, fmt.Errorf("reading meminfo: %w", err)
38+
}
39+
40+
cpuPct, err := sampleCPUUsage("/proc/stat", cpuSampleInterval)
41+
if err != nil {
42+
return nil, fmt.Errorf("reading cpu stats: %w", err)
43+
}
44+
45+
return &SystemInfo{
46+
MemTotalBytes: mem.totalBytes,
47+
MemUsedBytes: mem.usedBytes,
48+
MemUsagePercent: mem.usagePercent,
49+
CPUUsagePercent: cpuPct,
50+
NumCPUs: runtime.NumCPU(),
51+
}, nil
52+
}
53+
54+
type memStats struct {
55+
totalBytes uint64
56+
usedBytes uint64
57+
usagePercent float64
58+
}
59+
60+
func readMemInfo(path string) (*memStats, error) {
61+
fields, err := parseKeyValueKB(path, "MemTotal", "MemAvailable", "MemFree", "Buffers", "Cached")
62+
if err != nil {
63+
return nil, err
64+
}
65+
66+
total, ok := fields["MemTotal"]
67+
if !ok || total == 0 {
68+
return nil, fmt.Errorf("MemTotal not found or zero in %s", path)
69+
}
70+
71+
available, hasAvailable := fields["MemAvailable"]
72+
if !hasAvailable {
73+
// Kernel <3.14 fallback
74+
free := fields["MemFree"]
75+
buffers := fields["Buffers"]
76+
cached := fields["Cached"]
77+
available = free + buffers + cached
78+
}
79+
80+
totalBytes := total * 1024
81+
var usedBytes uint64
82+
if total > available {
83+
usedBytes = (total - available) * 1024
84+
}
85+
pct := float64(usedBytes) / float64(totalBytes) * 100
86+
pct = math.Round(pct*10) / 10
87+
88+
return &memStats{
89+
totalBytes: totalBytes,
90+
usedBytes: usedBytes,
91+
usagePercent: pct,
92+
}, nil
93+
}
94+
95+
// parseKeyValueKB reads /proc/meminfo-style files and returns values in kB for
96+
// the requested keys.
97+
func parseKeyValueKB(path string, keys ...string) (map[string]uint64, error) {
98+
f, err := os.Open(path)
99+
if err != nil {
100+
return nil, err
101+
}
102+
defer func() { _ = f.Close() }()
103+
104+
want := make(map[string]bool, len(keys))
105+
for _, k := range keys {
106+
want[k] = true
107+
}
108+
109+
result := make(map[string]uint64, len(keys))
110+
scanner := bufio.NewScanner(f)
111+
for scanner.Scan() {
112+
line := scanner.Text()
113+
key, val, ok := parseMemInfoLine(line)
114+
if ok && want[key] {
115+
result[key] = val
116+
}
117+
}
118+
return result, scanner.Err()
119+
}
120+
121+
// parseMemInfoLine parses a single line like "MemTotal: 16384 kB" and
122+
// returns (key, valueInKB, ok).
123+
func parseMemInfoLine(line string) (string, uint64, bool) {
124+
colon := strings.IndexByte(line, ':')
125+
if colon < 0 {
126+
return "", 0, false
127+
}
128+
key := line[:colon]
129+
rest := strings.TrimSpace(line[colon+1:])
130+
131+
rest = strings.TrimSuffix(rest, " kB")
132+
val, err := strconv.ParseUint(rest, 10, 64)
133+
if err != nil {
134+
return "", 0, false
135+
}
136+
return key, val, true
137+
}
138+
139+
type cpuTicks struct {
140+
total uint64
141+
idle uint64
142+
}
143+
144+
func readCPUTicks(path string) (*cpuTicks, error) {
145+
f, err := os.Open(path)
146+
if err != nil {
147+
return nil, err
148+
}
149+
defer func() { _ = f.Close() }()
150+
151+
scanner := bufio.NewScanner(f)
152+
for scanner.Scan() {
153+
line := scanner.Text()
154+
if strings.HasPrefix(line, "cpu ") {
155+
return parseCPULine(line)
156+
}
157+
}
158+
if err := scanner.Err(); err != nil {
159+
return nil, err
160+
}
161+
return nil, fmt.Errorf("no aggregate cpu line found in %s", path)
162+
}
163+
164+
// parseCPULine parses the aggregate "cpu ..." line from /proc/stat.
165+
// Fields: user nice system idle iowait irq softirq steal [guest guest_nice]
166+
func parseCPULine(line string) (*cpuTicks, error) {
167+
fields := strings.Fields(line)
168+
if len(fields) < 5 {
169+
return nil, fmt.Errorf("unexpected cpu line format: %q", line)
170+
}
171+
172+
var total, idle uint64
173+
for i, f := range fields[1:] {
174+
v, err := strconv.ParseUint(f, 10, 64)
175+
if err != nil {
176+
return nil, fmt.Errorf("parsing field %d of cpu line: %w", i, err)
177+
}
178+
total += v
179+
if i == 3 { // idle is the 4th value (0-indexed field 3)
180+
idle = v
181+
}
182+
}
183+
return &cpuTicks{total: total, idle: idle}, nil
184+
}
185+
186+
func sampleCPUUsage(path string, interval time.Duration) (float64, error) {
187+
t1, err := readCPUTicks(path)
188+
if err != nil {
189+
return 0, err
190+
}
191+
192+
time.Sleep(interval)
193+
194+
t2, err := readCPUTicks(path)
195+
if err != nil {
196+
return 0, err
197+
}
198+
199+
totalDelta := t2.total - t1.total
200+
idleDelta := t2.idle - t1.idle
201+
202+
if totalDelta == 0 {
203+
return 0, nil
204+
}
205+
206+
pct := float64(totalDelta-idleDelta) / float64(totalDelta) * 100
207+
return math.Round(pct*10) / 10, nil
208+
}

enclave/sysinfo_test.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/peterldowns/testy/assert"
9+
)
10+
11+
func TestParseMemInfoLine(t *testing.T) {
12+
tests := []struct {
13+
line string
14+
wantKey string
15+
wantVal uint64
16+
wantOK bool
17+
}{
18+
{"MemTotal: 16384000 kB", "MemTotal", 16384000, true},
19+
{"MemAvailable: 8192000 kB", "MemAvailable", 8192000, true},
20+
{"MemFree: 4096000 kB", "MemFree", 4096000, true},
21+
{"Buffers: 512000 kB", "Buffers", 512000, true},
22+
{"Cached: 2048000 kB", "Cached", 2048000, true},
23+
{"bogus line", "", 0, false},
24+
{"NoValue:", "", 0, false},
25+
}
26+
27+
for _, tt := range tests {
28+
key, val, ok := parseMemInfoLine(tt.line)
29+
assert.Equal(t, tt.wantOK, ok)
30+
if ok {
31+
assert.Equal(t, tt.wantKey, key)
32+
assert.Equal(t, tt.wantVal, val)
33+
}
34+
}
35+
}
36+
37+
func writeFixture(t *testing.T, name, content string) string {
38+
t.Helper()
39+
path := filepath.Join(t.TempDir(), name)
40+
err := os.WriteFile(path, []byte(content), 0o644)
41+
assert.NoError(t, err)
42+
return path
43+
}
44+
45+
func TestReadMemInfo(t *testing.T) {
46+
content := `MemTotal: 16384000 kB
47+
MemFree: 4096000 kB
48+
MemAvailable: 8192000 kB
49+
Buffers: 512000 kB
50+
Cached: 2048000 kB
51+
SwapTotal: 2097152 kB
52+
SwapFree: 2097152 kB
53+
`
54+
path := writeFixture(t, "meminfo", content)
55+
56+
mem, err := readMemInfo(path)
57+
assert.NoError(t, err)
58+
59+
assert.Equal(t, uint64(16384000*1024), mem.totalBytes)
60+
assert.Equal(t, uint64((16384000-8192000)*1024), mem.usedBytes)
61+
assert.Equal(t, 50.0, mem.usagePercent)
62+
}
63+
64+
func TestReadMemInfo_NoMemAvailable(t *testing.T) {
65+
content := `MemTotal: 16384000 kB
66+
MemFree: 4096000 kB
67+
Buffers: 512000 kB
68+
Cached: 2048000 kB
69+
`
70+
path := writeFixture(t, "meminfo", content)
71+
72+
mem, err := readMemInfo(path)
73+
assert.NoError(t, err)
74+
75+
// Fallback: available = MemFree + Buffers + Cached = 6656000
76+
expectedUsed := uint64((16384000 - 6656000) * 1024)
77+
assert.Equal(t, uint64(16384000*1024), mem.totalBytes)
78+
assert.Equal(t, expectedUsed, mem.usedBytes)
79+
}
80+
81+
func TestReadMemInfo_MissingMemTotal(t *testing.T) {
82+
content := `MemFree: 4096000 kB
83+
MemAvailable: 8192000 kB
84+
`
85+
path := writeFixture(t, "meminfo", content)
86+
87+
_, err := readMemInfo(path)
88+
assert.Error(t, err)
89+
}
90+
91+
func TestReadMemInfo_FileNotFound(t *testing.T) {
92+
_, err := readMemInfo("/nonexistent/meminfo")
93+
assert.Error(t, err)
94+
}
95+
96+
func TestParseCPULine(t *testing.T) {
97+
// Fields: user nice system idle iowait irq softirq steal
98+
line := "cpu 100 20 30 500 10 5 3 2"
99+
ticks, err := parseCPULine(line)
100+
assert.NoError(t, err)
101+
102+
// total = 100+20+30+500+10+5+3+2 = 670
103+
assert.Equal(t, uint64(670), ticks.total)
104+
// idle is the 4th field (index 3) = 500
105+
assert.Equal(t, uint64(500), ticks.idle)
106+
}
107+
108+
func TestParseCPULine_WithGuestFields(t *testing.T) {
109+
line := "cpu 100 20 30 500 10 5 3 2 50 25"
110+
ticks, err := parseCPULine(line)
111+
assert.NoError(t, err)
112+
113+
// total = 100+20+30+500+10+5+3+2+50+25 = 745
114+
assert.Equal(t, uint64(745), ticks.total)
115+
assert.Equal(t, uint64(500), ticks.idle)
116+
}
117+
118+
func TestParseCPULine_TooFewFields(t *testing.T) {
119+
line := "cpu 100 20 30"
120+
_, err := parseCPULine(line)
121+
assert.Error(t, err)
122+
}
123+
124+
func TestReadCPUTicks(t *testing.T) {
125+
content := `cpu 100 20 30 500 10 5 3 2
126+
cpu0 50 10 15 250 5 3 1 1
127+
cpu1 50 10 15 250 5 2 2 1
128+
`
129+
path := writeFixture(t, "stat", content)
130+
131+
ticks, err := readCPUTicks(path)
132+
assert.NoError(t, err)
133+
assert.Equal(t, uint64(670), ticks.total)
134+
assert.Equal(t, uint64(500), ticks.idle)
135+
}
136+
137+
func TestReadCPUTicks_NoCPULine(t *testing.T) {
138+
content := `procs_running 2
139+
procs_blocked 0
140+
`
141+
path := writeFixture(t, "stat", content)
142+
143+
_, err := readCPUTicks(path)
144+
assert.Error(t, err)
145+
}
146+
147+
func TestReadCPUTicks_FileNotFound(t *testing.T) {
148+
_, err := readCPUTicks("/nonexistent/stat")
149+
assert.Error(t, err)
150+
}
151+
152+
func TestReadMemInfo_FullyUsed(t *testing.T) {
153+
content := `MemTotal: 1024 kB
154+
MemAvailable: 0 kB
155+
`
156+
path := writeFixture(t, "meminfo", content)
157+
158+
mem, err := readMemInfo(path)
159+
assert.NoError(t, err)
160+
assert.Equal(t, uint64(1024*1024), mem.totalBytes)
161+
assert.Equal(t, uint64(1024*1024), mem.usedBytes)
162+
assert.Equal(t, 100.0, mem.usagePercent)
163+
}
164+
165+
func TestReadMemInfo_NothingUsed(t *testing.T) {
166+
content := `MemTotal: 1024 kB
167+
MemAvailable: 1024 kB
168+
`
169+
path := writeFixture(t, "meminfo", content)
170+
171+
mem, err := readMemInfo(path)
172+
assert.NoError(t, err)
173+
assert.Equal(t, uint64(0), mem.usedBytes)
174+
assert.Equal(t, 0.0, mem.usagePercent)
175+
}

0 commit comments

Comments
 (0)