1package env
2
3import (
4 "bufio"
5 "context"
6 "os"
7 "path/filepath"
8 "runtime"
9 "strconv"
10 "strings"
11 "sync"
12
13 "github.com/bnema/dumber/internal/application/port"
14 "github.com/bnema/dumber/internal/logging"
15)
16
17const (
18 // kibibyte is 1024 bytes (/proc/meminfo reports in kB which is actually KiB)
19 kibibyte = 1024
20)
21
22// HardwareSurveyor implements port.HardwareSurveyor for Linux systems.
23// It is safe for concurrent use after creation.
24type HardwareSurveyor struct {
25 once sync.Once
26 cached port.HardwareInfo
27}
28
29// NewHardwareSurveyor creates a new hardware surveyor.
30func NewHardwareSurveyor() *HardwareSurveyor {
31 return &HardwareSurveyor{}
32}
33
34// Survey detects and returns hardware information.
35// Results are cached after the first call. Safe for concurrent use.
36func (s *HardwareSurveyor) Survey(ctx context.Context) port.HardwareInfo {
37 s.once.Do(func() {
38 s.cached = s.doSurvey(ctx)
39 })
40 return s.cached
41}
42
43// doSurvey performs the actual hardware detection.
44func (s *HardwareSurveyor) doSurvey(ctx context.Context) port.HardwareInfo {
45 log := logging.FromContext(ctx)
46
47 info := port.HardwareInfo{
48 CPUThreads: runtime.NumCPU(), // Go's NumCPU returns logical threads
49 }
50
51 // Detect physical CPU cores
52 info.CPUCores = s.detectCPUCores(ctx)
53 if info.CPUCores == 0 {
54 info.CPUCores = info.CPUThreads // Fallback to threads if cores unknown
55 }
56
57 // Detect RAM
58 info.TotalRAM, info.AvailableRAM = s.detectMemory(ctx)
59
60 // Detect GPU
61 info.GPUVendor, info.GPUName, info.VRAM = s.detectGPU(ctx)
62
63 log.Debug().
64 Int("cpu_cores", info.CPUCores).
65 Int("cpu_threads", info.CPUThreads).
66 Uint64("total_ram_mb", info.TotalRAM/(1024*1024)).
67 Uint64("available_ram_mb", info.AvailableRAM/(1024*1024)).
68 Str("gpu_vendor", string(info.GPUVendor)).
69 Str("gpu_name", info.GPUName).
70 Uint64("vram_mb", info.VRAM/(1024*1024)).
71 Msg("hardware survey completed")
72
73 return info
74}
75
76// detectCPUCores returns the number of physical CPU cores.
77// Reads from /sys/devices/system/cpu/cpu*/topology/core_id to count unique cores.
78func (s *HardwareSurveyor) detectCPUCores(ctx context.Context) int {
79 log := logging.FromContext(ctx)
80
81 // Method 1: Count unique core_ids across all CPUs
82 coreIDs := make(map[string]struct{})
83 cpuPath := "/sys/devices/system/cpu"
84
85 entries, err := os.ReadDir(cpuPath)
86 if err != nil {
87 log.Debug().Err(err).Msg("cannot read /sys/devices/system/cpu")
88 return 0
89 }
90
91 for _, entry := range entries {
92 if !strings.HasPrefix(entry.Name(), "cpu") {
93 continue
94 }
95 // Skip non-numeric cpu entries (like cpufreq, cpuidle)
96 cpuNum := strings.TrimPrefix(entry.Name(), "cpu")
97 if _, err := strconv.Atoi(cpuNum); err != nil {
98 continue
99 }
100
101 coreIDPath := filepath.Join(cpuPath, entry.Name(), "topology", "core_id")
102 data, err := os.ReadFile(coreIDPath)
103 if err != nil {
104 continue
105 }
106 coreID := strings.TrimSpace(string(data))
107 coreIDs[coreID] = struct{}{}
108 }
109
110 if len(coreIDs) > 0 {
111 return len(coreIDs)
112 }
113
114 // Method 2: Parse /proc/cpuinfo for "cpu cores" field
115 return s.detectCPUCoresFromProcCPUInfo(ctx)
116}
117
118// detectCPUCoresFromProcCPUInfo parses /proc/cpuinfo to get core count.
119func (*HardwareSurveyor) detectCPUCoresFromProcCPUInfo(ctx context.Context) int {
120 log := logging.FromContext(ctx)
121
122 file, err := os.Open("/proc/cpuinfo")
123 if err != nil {
124 log.Debug().Err(err).Msg("cannot read /proc/cpuinfo")
125 return 0
126 }
127 defer func() { _ = file.Close() }()
128
129 scanner := bufio.NewScanner(file)
130 for scanner.Scan() {
131 line := scanner.Text()
132 if strings.HasPrefix(line, "cpu cores") {
133 parts := strings.Split(line, ":")
134 if len(parts) == 2 {
135 cores, err := strconv.Atoi(strings.TrimSpace(parts[1]))
136 if err == nil {
137 return cores
138 }
139 }
140 }
141 }
142 return 0
143}
144
145// detectMemory reads memory info from /proc/meminfo.
146func (s *HardwareSurveyor) detectMemory(ctx context.Context) (total, available uint64) {
147 log := logging.FromContext(ctx)
148
149 file, err := os.Open("/proc/meminfo")
150 if err != nil {
151 log.Debug().Err(err).Msg("cannot read /proc/meminfo")
152 return 0, 0
153 }
154 defer func() { _ = file.Close() }()
155
156 scanner := bufio.NewScanner(file)
157 for scanner.Scan() {
158 line := scanner.Text()
159 if strings.HasPrefix(line, "MemTotal:") {
160 total = s.parseMemInfoLine(line)
161 } else if strings.HasPrefix(line, "MemAvailable:") {
162 available = s.parseMemInfoLine(line)
163 }
164 if total > 0 && available > 0 {
165 break
166 }
167 }
168 return total, available
169}
170
171// parseMemInfoLine extracts bytes from a /proc/meminfo line like "MemTotal: 12345 kB".
172func (*HardwareSurveyor) parseMemInfoLine(line string) uint64 {
173 parts := strings.Fields(line)
174 if len(parts) < 2 {
175 return 0
176 }
177 value, err := strconv.ParseUint(parts[1], 10, 64)
178 if err != nil {
179 return 0
180 }
181 // /proc/meminfo reports in kB (actually KiB)
182 return value * kibibyte
183}
184
185// detectGPU detects GPU vendor, name, and VRAM from DRM subsystem.
186// When multiple GPUs are present (e.g., iGPU + discrete), prefers the one with more VRAM.
187func (s *HardwareSurveyor) detectGPU(ctx context.Context) (port.GPUVendor, string, uint64) {
188 log := logging.FromContext(ctx)
189
190 const drmBase = "/sys/class/drm"
191 cards := []string{"card0", "card1", "card2", "card3"}
192
193 var bestVendor port.GPUVendor
194 var bestName string
195 var bestVRAM uint64
196 var bestCard string
197
198 for _, card := range cards {
199 cardPath := filepath.Join(drmBase, card)
200 if _, err := os.Stat(cardPath); os.IsNotExist(err) {
201 continue
202 }
203
204 vendor := s.detectGPUVendor(cardPath)
205 if vendor == port.GPUVendorUnknown {
206 continue
207 }
208
209 name := s.detectGPUName(ctx, cardPath)
210 vram := s.detectVRAM(ctx, cardPath)
211
212 log.Debug().
213 Str("card", card).
214 Str("vendor", string(vendor)).
215 Str("name", name).
216 Uint64("vram_mb", vram/(1024*1024)).
217 Msg("found GPU")
218
219 // Prefer GPU with more VRAM (discrete > integrated)
220 if vram > bestVRAM {
221 bestVendor = vendor
222 bestName = name
223 bestVRAM = vram
224 bestCard = card
225 }
226 }
227
228 if bestVendor != port.GPUVendorUnknown {
229 log.Debug().
230 Str("card", bestCard).
231 Str("vendor", string(bestVendor)).
232 Uint64("vram_mb", bestVRAM/(1024*1024)).
233 Msg("selected GPU (highest VRAM)")
234 }
235
236 return bestVendor, bestName, bestVRAM
237}
238
239// detectGPUVendor reads the PCI vendor ID.
240func (*HardwareSurveyor) detectGPUVendor(cardPath string) port.GPUVendor {
241 vendorPath := filepath.Join(cardPath, "device", "vendor")
242 data, err := os.ReadFile(vendorPath)
243 if err != nil {
244 return port.GPUVendorUnknown
245 }
246
247 vendorID := strings.TrimSpace(string(data))
248 switch vendorID {
249 case "0x1002":
250 return port.GPUVendorAMD
251 case "0x8086":
252 return port.GPUVendorIntel
253 case "0x10de":
254 return port.GPUVendorNVIDIA
255 default:
256 return port.GPUVendorUnknown
257 }
258}
259
260// detectGPUName reads the GPU product name from various sources.
261func (*HardwareSurveyor) detectGPUName(ctx context.Context, cardPath string) string {
262 log := logging.FromContext(ctx)
263
264 // Try reading from device/label first (some drivers provide this)
265 labelPath := filepath.Join(cardPath, "device", "label")
266 if data, err := os.ReadFile(labelPath); err == nil {
267 name := strings.TrimSpace(string(data))
268 if name != "" {
269 return name
270 }
271 }
272
273 // Try reading product name from device subsystem (uevent)
274 ueventPath := filepath.Join(cardPath, "device", "uevent")
275 if file, err := os.Open(ueventPath); err == nil {
276 scanner := bufio.NewScanner(file)
277 for scanner.Scan() {
278 line := scanner.Text()
279 if strings.HasPrefix(line, "PCI_SLOT_NAME=") {
280 // This gives us the PCI address, useful for debugging
281 log.Debug().Str("pci_slot", strings.TrimPrefix(line, "PCI_SLOT_NAME=")).Msg("GPU PCI slot")
282 }
283 }
284 _ = file.Close()
285 }
286
287 // For AMD, try reading from /sys/kernel/debug/dri/*/name (requires root usually)
288 // For now, return empty and rely on vendor detection
289 return ""
290}
291
292// detectVRAM reads VRAM size from DRM memory info.
293func (s *HardwareSurveyor) detectVRAM(ctx context.Context, cardPath string) uint64 {
294 log := logging.FromContext(ctx)
295
296 // Method 1: AMD uses mem_info_vram_total in device directory
297 amdVRAMPath := filepath.Join(cardPath, "device", "mem_info_vram_total")
298 if data, err := os.ReadFile(amdVRAMPath); err == nil {
299 vram, err := strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64)
300 if err == nil && vram > 0 {
301 log.Debug().Uint64("vram_bytes", vram).Msg("detected VRAM from AMD mem_info_vram_total")
302 return vram
303 }
304 }
305
306 // Method 2: Try reading from resource file (PCI BAR sizes)
307 // The first non-zero memory region is usually VRAM
308 if vram := s.detectVRAMFromPCIResource(ctx, cardPath); vram > 0 {
309 return vram
310 }
311
312 // Method 3: For Intel integrated, check i915 specific paths
313 // Intel iGPU uses system RAM, so VRAM is usually 0 or shared
314 return 0
315}
316
317// detectVRAMFromPCIResource tries to detect VRAM from PCI BAR sizes.
318func (*HardwareSurveyor) detectVRAMFromPCIResource(ctx context.Context, cardPath string) uint64 {
319 log := logging.FromContext(ctx)
320
321 resourcePath := filepath.Join(cardPath, "device", "resource")
322 file, err := os.Open(resourcePath)
323 if err != nil {
324 return 0
325 }
326 defer func() { _ = file.Close() }()
327
328 const minVRAMSize = 256 * 1024 * 1024 // 256MB - skip smaller I/O regions
329 scanner := bufio.NewScanner(file)
330 for scanner.Scan() {
331 line := scanner.Text()
332 parts := strings.Fields(line)
333 if len(parts) >= 2 {
334 start, err1 := strconv.ParseUint(strings.TrimPrefix(parts[0], "0x"), 16, 64)
335 end, err2 := strconv.ParseUint(strings.TrimPrefix(parts[1], "0x"), 16, 64)
336 if err1 == nil && err2 == nil && start > 0 && end > start {
337 size := end - start + 1
338 // VRAM is typically > 256MB, skip smaller regions (I/O, config)
339 if size > minVRAMSize {
340 log.Debug().Uint64("vram_bytes", size).Msg("detected VRAM from PCI resource")
341 return size
342 }
343 }
344 }
345 }
346 return 0
347}
348
349var _ port.HardwareSurveyor = (*HardwareSurveyor)(nil)