From 9fa47202c059e02d7bd7393aa2d28ba2d2046741 Mon Sep 17 00:00:00 2001 From: mistercodedor Date: Thu, 5 Mar 2026 15:04:09 -0500 Subject: [PATCH 1/7] feat(hardware): use NVML to grab the hardware profile during the register command --- go.mod | 3 +- go.sum | 2 + pkg/cmd/register/device_registration_store.go | 12 +- .../device_registration_store_test.go | 10 +- pkg/cmd/register/gpu_nvml.go | 158 ++++++++++ pkg/cmd/register/hardware.go | 276 ++++++------------ pkg/cmd/register/hardware_darwin.go | 151 ++++++++++ pkg/cmd/register/hardware_linux.go | 134 +++++++++ pkg/cmd/register/hardware_stub.go | 10 + pkg/cmd/register/hardware_test.go | 237 +++------------ pkg/cmd/register/register.go | 36 +-- pkg/cmd/register/register_test.go | 34 ++- pkg/cmd/register/rpcclient.go | 69 ++++- pkg/cmd/register/rpcclient_test.go | 20 +- 14 files changed, 687 insertions(+), 465 deletions(-) create mode 100644 pkg/cmd/register/gpu_nvml.go create mode 100644 pkg/cmd/register/hardware_darwin.go create mode 100644 pkg/cmd/register/hardware_linux.go create mode 100644 pkg/cmd/register/hardware_stub.go diff --git a/go.mod b/go.mod index ec29314f..a373a2e0 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( buf.build/gen/go/brevdev/devplane/connectrpc/go v1.19.1-20260228021043-887d38e1b474.2 buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260305005117-3cacb6388cd4.1 connectrpc.com/connect v1.19.1 + github.com/NVIDIA/go-nvml v0.13.0-1 github.com/alessio/shellescape v1.4.1 github.com/brevdev/parse v0.0.11 github.com/briandowns/spinner v1.16.0 @@ -150,7 +151,7 @@ require ( go.starlark.net v0.0.0-20230525235612-a134d8f9ddca // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sys v0.40.0 // indirect + golang.org/x/sys v0.40.0 golang.org/x/term v0.39.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/protobuf v1.36.11 diff --git a/go.sum b/go.sum index 468ebc6f..c5a9aed8 100644 --- a/go.sum +++ b/go.sum @@ -53,6 +53,8 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/NVIDIA/go-nvml v0.13.0-1 h1:OLX8Jq3dONuPOQPC7rndB6+iDmDakw0XTYgzMxObkEw= +github.com/NVIDIA/go-nvml v0.13.0-1/go.mod h1:+KNA7c7gIBH7SKSJ1ntlwkfN80zdx8ovl4hrK3LmPt4= github.com/ProtonMail/go-crypto v1.1.5 h1:eoAQfK2dwL+tFSFpr7TbOaPNUbPiJj4fLYwwGE1FQO4= github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= diff --git a/pkg/cmd/register/device_registration_store.go b/pkg/cmd/register/device_registration_store.go index 86dba36b..8459f7b0 100644 --- a/pkg/cmd/register/device_registration_store.go +++ b/pkg/cmd/register/device_registration_store.go @@ -23,12 +23,12 @@ const ( // DeviceRegistration is the persistent identity file for a registered device. // Fields align with the AddNodeResponse from dev-plane. type DeviceRegistration struct { - ExternalNodeID string `json:"external_node_id"` - DisplayName string `json:"display_name"` - OrgID string `json:"org_id"` - DeviceID string `json:"device_id"` - RegisteredAt string `json:"registered_at"` - NodeSpec NodeSpec `json:"node_spec"` + ExternalNodeID string `json:"external_node_id"` + DisplayName string `json:"display_name"` + OrgID string `json:"org_id"` + DeviceID string `json:"device_id"` + RegisteredAt string `json:"registered_at"` + HardwareProfile HardwareProfile `json:"hardware_profile"` } // RegistrationStore defines the contract for persisting device registration data. diff --git a/pkg/cmd/register/device_registration_store_test.go b/pkg/cmd/register/device_registration_store_test.go index 256acd4a..e73432f5 100644 --- a/pkg/cmd/register/device_registration_store_test.go +++ b/pkg/cmd/register/device_registration_store_test.go @@ -31,7 +31,7 @@ func Test_SaveAndLoadRegistration_RoundTrip(t *testing.T) { OrgID: "org_xyz", DeviceID: "device-uuid-123", RegisteredAt: "2026-02-25T00:00:00Z", - NodeSpec: NodeSpec{ + HardwareProfile: HardwareProfile{ CPUCount: &cpuCount, RAMBytes: &ramBytes, Architecture: "arm64", @@ -59,11 +59,11 @@ func Test_SaveAndLoadRegistration_RoundTrip(t *testing.T) { if loaded.DeviceID != reg.DeviceID { t.Errorf("DeviceID mismatch: got %s, want %s", loaded.DeviceID, reg.DeviceID) } - if loaded.NodeSpec.Architecture != "arm64" { - t.Errorf("Architecture mismatch: got %s", loaded.NodeSpec.Architecture) + if loaded.HardwareProfile.Architecture != "arm64" { + t.Errorf("Architecture mismatch: got %s", loaded.HardwareProfile.Architecture) } - if loaded.NodeSpec.CPUCount == nil || *loaded.NodeSpec.CPUCount != 12 { - t.Errorf("CPUCount mismatch: got %v", loaded.NodeSpec.CPUCount) + if loaded.HardwareProfile.CPUCount == nil || *loaded.HardwareProfile.CPUCount != 12 { + t.Errorf("CPUCount mismatch: got %v", loaded.HardwareProfile.CPUCount) } } diff --git a/pkg/cmd/register/gpu_nvml.go b/pkg/cmd/register/gpu_nvml.go new file mode 100644 index 00000000..0d34d840 --- /dev/null +++ b/pkg/cmd/register/gpu_nvml.go @@ -0,0 +1,158 @@ +//go:build linux || windows + +package register + +import ( + "fmt" + + "github.com/NVIDIA/go-nvml/pkg/nvml" +) + +// archNames maps NVML compute capability (major version) to GPU architecture name. +var archNames = map[int]string{ + 1: "Tesla", + 2: "Fermi", + 3: "Kepler", + 5: "Maxwell", + 6: "Pascal", + 7: "Volta/Turing", + 8: "Ampere", + 9: "Hopper/Ada Lovelace", + 10: "Blackwell", + 12: "Vera Rubin", +} + +// probeGPUsNVML uses NVML to detect GPUs and interconnects. +// Returns (nil, nil) if NVML is unavailable (e.g. no driver installed). +func probeGPUsNVML() ([]GPU, []Interconnect) { + ret := nvml.Init() + if ret != nvml.SUCCESS { + return nil, nil + } + defer func() { _ = nvml.Shutdown() }() + + count, ret := nvml.DeviceGetCount() + if ret != nvml.SUCCESS || count == 0 { + return nil, nil + } + + type gpuKey struct { + model string + arch string + mem int64 + } + counts := make(map[gpuKey]int32) + var order []gpuKey + var interconnects []Interconnect + + for i := 0; i < count; i++ { + device, ret := nvml.DeviceGetHandleByIndex(i) + if ret != nvml.SUCCESS { + continue + } + + name, ret := device.GetName() + if ret != nvml.SUCCESS { + name = "Unknown" + } + + var memBytes int64 + memInfo, ret := device.GetMemoryInfo() + if ret == nvml.SUCCESS { + memBytes = int64(memInfo.Total) + } + + arch := "" + major, minor, ret := device.GetCudaComputeCapability() + if ret == nvml.SUCCESS { + if archName, ok := archNames[major]; ok { + arch = archName + } else { + arch = fmt.Sprintf("sm_%d%d", major, minor) + } + } + + key := gpuKey{model: name, arch: arch, mem: memBytes} + if counts[key] == 0 { + order = append(order, key) + } + counts[key]++ + + // Probe NVLink interconnects for this device. + interconnects = append(interconnects, probeNVLink(device, i)...) + + // Probe PCIe interconnect for this device. + if ic := probePCIe(device, i); ic != nil { + interconnects = append(interconnects, *ic) + } + } + + gpus := make([]GPU, 0, len(order)) + for _, key := range order { + mem := key.mem + g := GPU{ + Model: key.model, + Architecture: key.arch, + Count: counts[key], + } + if mem > 0 { + g.MemoryBytes = &mem + } + gpus = append(gpus, g) + } + + return gpus, interconnects +} + +// probeNVLink checks NVLink connections for a device. +func probeNVLink(device nvml.Device, deviceIdx int) []Interconnect { + var ics []Interconnect + activeLinks := 0 + + // NVLink link count varies by architecture; try up to 18 links. + var nvlinkVersion uint32 + for link := 0; link < 18; link++ { + state, ret := device.GetNvLinkState(link) + if ret != nvml.SUCCESS { + break + } + if state == nvml.FEATURE_ENABLED { + activeLinks++ + if nvlinkVersion == 0 { + ver, ret := device.GetNvLinkVersion(link) + if ret == nvml.SUCCESS { + nvlinkVersion = uint32(ver) + } + } + } + } + + if activeLinks > 0 { + ics = append(ics, Interconnect{ + Type: "NVLink", + Device: fmt.Sprintf("GPU %d", deviceIdx), + ActiveLinks: activeLinks, + Version: nvlinkVersion, + }) + } + + return ics +} + +// probePCIe reads PCIe generation and width for a device. +func probePCIe(device nvml.Device, deviceIdx int) *Interconnect { + gen, ret := device.GetCurrPcieLinkGeneration() + if ret != nvml.SUCCESS { + return nil + } + width, ret := device.GetCurrPcieLinkWidth() + if ret != nvml.SUCCESS { + return nil + } + return &Interconnect{ + Type: "PCIe", + Device: fmt.Sprintf("GPU %d", deviceIdx), + Generation: gen, + Width: width, + } +} diff --git a/pkg/cmd/register/hardware.go b/pkg/cmd/register/hardware.go index d23ec170..dfbb3d3e 100644 --- a/pkg/cmd/register/hardware.go +++ b/pkg/cmd/register/hardware.go @@ -3,98 +3,107 @@ package register import ( "bufio" "fmt" - "os/exec" - "runtime" "strconv" "strings" - - breverrors "github.com/brevdev/brev-cli/pkg/errors" ) -// CommandRunner abstracts command execution for testability. -type CommandRunner interface { - Run(name string, args ...string) ([]byte, error) +// HardwareProfiler abstracts system hardware detection so it can be +// replaced in tests and implemented per-platform via build tags. +type HardwareProfiler interface { + Profile() (*HardwareProfile, error) } -// ExecCommandRunner is the real implementation that runs OS commands. -type ExecCommandRunner struct{} - -func (r ExecCommandRunner) Run(name string, args ...string) ([]byte, error) { - out, err := exec.Command(name, args...).Output() // #nosec G204 - if err != nil { - return nil, breverrors.WrapAndTrace(err) - } - return out, nil +// GPU describes a group of identical GPUs on the system. +type GPU struct { + Model string `json:"model"` + Architecture string `json:"architecture,omitempty"` + Count int32 `json:"count"` + MemoryBytes *int64 `json:"memory_bytes,omitempty"` } -// NodeSpec matches the proto NodeSpec message from dev-plane. -// All fields are best-effort. -type NodeSpec struct { - GPUs []NodeGPU `json:"gpus"` - RAMBytes *int64 `json:"ram_bytes,omitempty"` - CPUCount *int32 `json:"cpu_count,omitempty"` - Architecture string `json:"architecture,omitempty"` - Storage []NodeStorage `json:"storage,omitempty"` - OS string `json:"os,omitempty"` - OSVersion string `json:"os_version,omitempty"` +// Interconnect describes a GPU interconnect (NVLink, PCIe, etc.). +type Interconnect struct { + Type string `json:"type"` + Device string `json:"device"` + ActiveLinks int `json:"active_links,omitempty"` + Version uint32 `json:"version,omitempty"` + Generation int `json:"generation,omitempty"` + Width int `json:"width,omitempty"` } -// NodeStorage represents a single storage device with its size and type. -type NodeStorage struct { +// StorageDevice represents a single block storage device. +type StorageDevice struct { + Name string `json:"name,omitempty"` StorageBytes int64 `json:"storage_bytes"` StorageType string `json:"storage_type,omitempty"` // "SSD" or "HDD" } -// NodeGPU matches the proto NodeGPU message. -type NodeGPU struct { - Model string `json:"model"` - Count int32 `json:"count"` - MemoryBytes *int64 `json:"memory_bytes,omitempty"` +// HardwareProfile is the full hardware snapshot collected by a HardwareProfiler. +type HardwareProfile struct { + GPUs []GPU `json:"gpus"` + RAMBytes *int64 `json:"ram_bytes,omitempty"` + CPUCount *int32 `json:"cpu_count,omitempty"` + Architecture string `json:"architecture,omitempty"` + Storage []StorageDevice `json:"storage,omitempty"` + OS string `json:"os,omitempty"` + OSVersion string `json:"os_version,omitempty"` + ProductName string `json:"product_name,omitempty"` + Interconnects []Interconnect `json:"interconnects,omitempty"` } -// FileReader abstracts file reading for testability. -type FileReader interface { - ReadFile(path string) ([]byte, error) -} - -// CollectHardwareProfile gathers system hardware information. -// All fields are best-effort; failures are silently ignored. -func CollectHardwareProfile(runner CommandRunner, reader FileReader) (*NodeSpec, error) { - spec := &NodeSpec{ - Architecture: runtime.GOARCH, +// FormatHardwareProfile returns a human-readable summary of the hardware profile. +func FormatHardwareProfile(s *HardwareProfile) string { + var b strings.Builder + if s.ProductName != "" { + _, _ = fmt.Fprintf(&b, " Product: %s\n", s.ProductName) } - - if gpus, err := parseNvidiaSMI(runner); err == nil { - spec.GPUs = gpus + if s.CPUCount != nil { + _, _ = fmt.Fprintf(&b, " CPU: %d cores\n", *s.CPUCount) } - - if cpuCount, err := parseCPUCount(reader); err == nil { - count32 := int32(cpuCount) - spec.CPUCount = &count32 + if s.RAMBytes != nil { + _, _ = fmt.Fprintf(&b, " RAM: %.1f GB\n", float64(*s.RAMBytes)/(1024*1024*1024)) } - - if ramBytes, err := parseMemInfo(reader); err == nil { - spec.RAMBytes = &ramBytes + for _, gpu := range s.GPUs { + if gpu.MemoryBytes != nil { + memGB := float64(*gpu.MemoryBytes) / (1024 * 1024 * 1024) + _, _ = fmt.Fprintf(&b, " GPUs: %d x %s (%.1f GB)", gpu.Count, gpu.Model, memGB) + } else { + _, _ = fmt.Fprintf(&b, " GPUs: %d x %s", gpu.Count, gpu.Model) + } + if gpu.Architecture != "" { + _, _ = fmt.Fprintf(&b, " [%s]", gpu.Architecture) + } + b.WriteString("\n") } - - osName, osVersion := parseOSRelease(reader) - spec.OS = osName - spec.OSVersion = osVersion - - spec.Storage = collectStorage(runner) - - return spec, nil -} - -// parseCPUCount reads /proc/cpuinfo and returns the number of logical processors. -func parseCPUCount(reader FileReader) (int, error) { - data, err := reader.ReadFile("/proc/cpuinfo") - if err != nil { - return 0, breverrors.WrapAndTrace(err) + _, _ = fmt.Fprintf(&b, " Arch: %s\n", s.Architecture) + if s.OS != "" || s.OSVersion != "" { + _, _ = fmt.Fprintf(&b, " OS: %s %s\n", s.OS, s.OSVersion) + } + for _, ic := range s.Interconnects { + _, _ = fmt.Fprintf(&b, " Link: %s", ic.Type) + if ic.Device != "" { + _, _ = fmt.Fprintf(&b, " (%s)", ic.Device) + } + if ic.ActiveLinks > 0 { + _, _ = fmt.Fprintf(&b, " x%d", ic.ActiveLinks) + } + b.WriteString("\n") } - return parseCPUCountContent(string(data)) + for _, st := range s.Storage { + _, _ = fmt.Fprintf(&b, " Storage: %.1f GB", float64(st.StorageBytes)/(1024*1024*1024)) + if st.StorageType != "" { + _, _ = fmt.Fprintf(&b, " (%s)", st.StorageType) + } + if st.Name != "" { + _, _ = fmt.Fprintf(&b, " [%s]", st.Name) + } + b.WriteString("\n") + } + return b.String() } +// --- Content-parsing helpers (pure functions, used by Linux adapter and tests) --- + // parseCPUCountContent parses the content of /proc/cpuinfo for processor count. func parseCPUCountContent(content string) (int, error) { count := 0 @@ -110,15 +119,6 @@ func parseCPUCountContent(content string) (int, error) { return count, nil } -// parseMemInfo reads /proc/meminfo and returns total RAM in bytes. -func parseMemInfo(reader FileReader) (int64, error) { - data, err := reader.ReadFile("/proc/meminfo") - if err != nil { - return 0, breverrors.WrapAndTrace(err) - } - return parseMemInfoContent(string(data)) -} - // parseMemInfoContent parses the content of /proc/meminfo. func parseMemInfoContent(content string) (int64, error) { scanner := bufio.NewScanner(strings.NewReader(content)) @@ -139,15 +139,6 @@ func parseMemInfoContent(content string) (int64, error) { return 0, fmt.Errorf("MemTotal not found in /proc/meminfo") } -// parseOSRelease reads /etc/os-release and returns (name, version). -func parseOSRelease(reader FileReader) (string, string) { - data, err := reader.ReadFile("/etc/os-release") - if err != nil { - return "", "" - } - return parseOSReleaseContent(string(data)) -} - // parseOSReleaseContent parses the content of /etc/os-release. func parseOSReleaseContent(content string) (string, string) { name := "" @@ -170,82 +161,10 @@ func unquote(s string) string { return strings.Trim(strings.TrimSpace(s), "\"") } -// parseNvidiaSMI queries nvidia-smi for GPU information. -// Returns an error if nvidia-smi fails or no GPUs are found. -func parseNvidiaSMI(runner CommandRunner) ([]NodeGPU, error) { - out, err := runner.Run("nvidia-smi", - "--query-gpu=name,memory.total", - "--format=csv,noheader,nounits", - ) - if err != nil { - return nil, fmt.Errorf("nvidia-smi not available: %w", err) - } - gpus := parseNvidiaSMIOutput(string(out)) - if len(gpus) == 0 { - return nil, fmt.Errorf("nvidia-smi returned no GPUs") - } - return gpus, nil -} - -// parseNvidiaSMIOutput parses nvidia-smi CSV output, grouping identical GPU -// models into a single NodeGPU with a count. -func parseNvidiaSMIOutput(output string) []NodeGPU { - type gpuKey struct { - model string - memoryBytes int64 - } - - counts := make(map[gpuKey]int32) - var order []gpuKey - - scanner := bufio.NewScanner(strings.NewReader(output)) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - parts := strings.Split(line, ", ") - if len(parts) < 2 { - continue - } - model := strings.TrimSpace(parts[0]) - memMB, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64) - if err != nil { - continue - } - key := gpuKey{model: model, memoryBytes: memMB * 1024 * 1024} - if counts[key] == 0 { - order = append(order, key) - } - counts[key]++ - } - - gpus := make([]NodeGPU, 0, len(order)) - for _, key := range order { - mem := key.memoryBytes - gpus = append(gpus, NodeGPU{ - Model: key.model, - Count: counts[key], - MemoryBytes: &mem, - }) - } - return gpus -} - -// collectStorage returns per-device storage entries from lsblk, -// using the ROTA column to determine device type. -func collectStorage(runner CommandRunner) []NodeStorage { - out, err := runner.Run("lsblk", "-b", "-d", "-n", "-o", "NAME,SIZE,TYPE,ROTA") - if err != nil { - return nil - } - return parseStorageOutput(string(out)) -} - // parseStorageOutput parses lsblk output (NAME,SIZE,TYPE,ROTA columns), -// returning one NodeStorage entry per disk device. ROTA=0 → SSD, ROTA=1 → HDD. -func parseStorageOutput(output string) []NodeStorage { - var devices []NodeStorage +// returning one StorageDevice entry per disk device. ROTA=0 → SSD, ROTA=1 → HDD. +func parseStorageOutput(output string) []StorageDevice { + var devices []StorageDevice scanner := bufio.NewScanner(strings.NewReader(output)) for scanner.Scan() { fields := strings.Fields(scanner.Text()) @@ -256,7 +175,7 @@ func parseStorageOutput(output string) []NodeStorage { if err != nil { continue } - entry := NodeStorage{StorageBytes: size} + entry := StorageDevice{Name: fields[0], StorageBytes: size} rota, err := strconv.Atoi(fields[3]) if err == nil { if rota == 0 { @@ -269,34 +188,3 @@ func parseStorageOutput(output string) []NodeStorage { } return devices } - -// FormatNodeSpec returns a human-readable summary of the hardware profile. -func FormatNodeSpec(s *NodeSpec) string { - var b strings.Builder - if s.CPUCount != nil { - _, _ = fmt.Fprintf(&b, " CPU: %d cores\n", *s.CPUCount) - } - if s.RAMBytes != nil { - _, _ = fmt.Fprintf(&b, " RAM: %.1f GB\n", float64(*s.RAMBytes)/(1024*1024*1024)) - } - for _, gpu := range s.GPUs { - if gpu.MemoryBytes != nil { - memGB := float64(*gpu.MemoryBytes) / (1024 * 1024 * 1024) - _, _ = fmt.Fprintf(&b, " GPUs: %d x %s (%.1f GB)\n", gpu.Count, gpu.Model, memGB) - } else { - _, _ = fmt.Fprintf(&b, " GPUs: %d x %s\n", gpu.Count, gpu.Model) - } - } - _, _ = fmt.Fprintf(&b, " Arch: %s\n", s.Architecture) - if s.OS != "" || s.OSVersion != "" { - _, _ = fmt.Fprintf(&b, " OS: %s %s\n", s.OS, s.OSVersion) - } - for _, st := range s.Storage { - _, _ = fmt.Fprintf(&b, " Storage: %.1f GB", float64(st.StorageBytes)/(1024*1024*1024)) - if st.StorageType != "" { - _, _ = fmt.Fprintf(&b, " (%s)", st.StorageType) - } - b.WriteString("\n") - } - return b.String() -} diff --git a/pkg/cmd/register/hardware_darwin.go b/pkg/cmd/register/hardware_darwin.go new file mode 100644 index 00000000..d1e4e0e6 --- /dev/null +++ b/pkg/cmd/register/hardware_darwin.go @@ -0,0 +1,151 @@ +//go:build darwin + +package register + +import ( + "os/exec" + "runtime" + "strings" + + "golang.org/x/sys/unix" +) + +// SystemHardwareProfiler probes hardware on macOS using sysctl and system_profiler. +type SystemHardwareProfiler struct{} + +func (p *SystemHardwareProfiler) Profile() (*HardwareProfile, error) { + hw := &HardwareProfile{Architecture: runtime.GOARCH} + + hw.ProductName = readDarwinSysctl("hw.model") + + cpuCount := int32(runtime.NumCPU()) + hw.CPUCount = &cpuCount + + if memSize, err := unix.SysctlUint64("hw.memsize"); err == nil { + ramBytes := int64(memSize) + hw.RAMBytes = &ramBytes + } + + hw.OS = readDarwinCommand("sw_vers", "-productName") + hw.OSVersion = readDarwinCommand("sw_vers", "-productVersion") + + hw.Storage = probeDarwinStorage() + + // No GPU/interconnect probing on macOS (NVML unavailable). + + return hw, nil +} + +// readDarwinSysctl reads a string sysctl value. +func readDarwinSysctl(name string) string { + val, err := unix.Sysctl(name) + if err != nil { + return "" + } + return strings.TrimSpace(val) +} + +// readDarwinCommand runs a command and returns trimmed stdout. +func readDarwinCommand(name string, args ...string) string { + out, err := exec.Command(name, args...).Output() // #nosec G204 + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// probeDarwinStorage uses diskutil to discover storage devices. +func probeDarwinStorage() []StorageDevice { + out, err := exec.Command("diskutil", "info", "-all").Output() // #nosec G204 + if err != nil { + return nil + } + return parseDiskutilInfoOutput(string(out)) +} + +// parseDiskutilInfoOutput parses "diskutil info -all" output into StorageDevice entries. +func parseDiskutilInfoOutput(output string) []StorageDevice { + var devices []StorageDevice + var current *StorageDevice + + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + + current = parseDiskutilLine(line, current, &devices) + } + + if current != nil && current.StorageBytes > 0 { + devices = append(devices, *current) + } + + return devices +} + +// parseDiskutilLine processes a single line of diskutil output, updating the +// current device state. Returns the (possibly new) current device pointer. +func parseDiskutilLine(line string, current *StorageDevice, devices *[]StorageDevice) *StorageDevice { + if val, ok := strings.CutPrefix(line, "Device Identifier:"); ok { + return parseDiskutilDeviceID(strings.TrimSpace(val), current, devices) + } + + if current == nil { + return nil + } + + if val, ok := strings.CutPrefix(line, "Disk Size:"); ok { + parseDiskutilSize(strings.TrimSpace(val), current) + } else if val, ok := strings.CutPrefix(line, "Solid State:"); ok { + parseDiskutilSolidState(strings.TrimSpace(val), current) + } else if val, ok := strings.CutPrefix(line, "Protocol:"); ok { + if strings.Contains(strings.ToLower(strings.TrimSpace(val)), "nvme") { + current.StorageType = "NVMe" + } + } else if line == "" && current.StorageBytes > 0 { + *devices = append(*devices, *current) + return nil + } + + return current +} + +func parseDiskutilDeviceID(val string, current *StorageDevice, devices *[]StorageDevice) *StorageDevice { + // Flush previous device if valid. + if current != nil && current.StorageBytes > 0 { + *devices = append(*devices, *current) + } + // Only include whole disks (e.g. disk0, not disk0s1). + if isWholeDisk(val) { + return &StorageDevice{Name: val} + } + return nil +} + +func isWholeDisk(name string) bool { + if !strings.HasPrefix(name, "disk") { + return false + } + suffix := name[4:] + return !strings.ContainsAny(suffix, "s") +} + +func parseDiskutilSize(val string, dev *StorageDevice) { + parts := strings.Fields(val) + if len(parts) < 1 { + return + } + var size int64 + for _, c := range parts[0] { + if c >= '0' && c <= '9' { + size = size*10 + int64(c-'0') + } + } + dev.StorageBytes = size +} + +func parseDiskutilSolidState(val string, dev *StorageDevice) { + if strings.EqualFold(val, "yes") { + dev.StorageType = "SSD" + } else { + dev.StorageType = "HDD" + } +} diff --git a/pkg/cmd/register/hardware_linux.go b/pkg/cmd/register/hardware_linux.go new file mode 100644 index 00000000..a2f0d2c5 --- /dev/null +++ b/pkg/cmd/register/hardware_linux.go @@ -0,0 +1,134 @@ +//go:build linux + +package register + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" +) + +// SystemHardwareProfiler probes hardware on Linux using NVML, sysfs, and procfs. +type SystemHardwareProfiler struct{} + +func (p *SystemHardwareProfiler) Profile() (*HardwareProfile, error) { + hw := &HardwareProfile{Architecture: runtime.GOARCH} + + hw.GPUs, hw.Interconnects = probeGPUsNVML() + hw.ProductName = readProductName() + + if cpuCount, err := readCPUCount(); err == nil { + count32 := int32(cpuCount) + hw.CPUCount = &count32 + } + + if ramBytes, err := readRAMBytes(); err == nil { + hw.RAMBytes = &ramBytes + } + + hw.OS, hw.OSVersion = readOSRelease() + hw.Storage = probeStorageSysfs() + + return hw, nil +} + +// readProductName reads the product name from DMI sysfs. +func readProductName() string { + data, err := os.ReadFile("/sys/devices/virtual/dmi/id/product_name") + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +// readCPUCount reads /proc/cpuinfo and returns the number of logical processors. +func readCPUCount() (int, error) { + data, err := os.ReadFile("/proc/cpuinfo") + if err != nil { + return 0, err + } + return parseCPUCountContent(string(data)) +} + +// readRAMBytes returns total system RAM in bytes using sysinfo syscall. +func readRAMBytes() (int64, error) { + var info syscall.Sysinfo_t + if err := syscall.Sysinfo(&info); err != nil { + return 0, err + } + return int64(info.Totalram) * int64(info.Unit), nil +} + +// readOSRelease reads /etc/os-release and returns (name, version). +func readOSRelease() (string, string) { + data, err := os.ReadFile("/etc/os-release") + if err != nil { + return "", "" + } + return parseOSReleaseContent(string(data)) +} + +// probeStorageSysfs enumerates block devices via /sys/block and returns +// storage information. This replaces the lsblk-based approach. +func probeStorageSysfs() []StorageDevice { + entries, err := os.ReadDir("/sys/block") + if err != nil { + return nil + } + + var devices []StorageDevice + for _, entry := range entries { + name := entry.Name() + + // Skip virtual devices (loop, ram, dm-, etc.) + if strings.HasPrefix(name, "loop") || + strings.HasPrefix(name, "ram") || + strings.HasPrefix(name, "dm-") || + strings.HasPrefix(name, "sr") || + strings.HasPrefix(name, "zram") { + continue + } + + sizeBytes, err := readSysfsInt(filepath.Join("/sys/block", name, "size")) + if err != nil || sizeBytes == 0 { + continue + } + // /sys/block/*/size is in 512-byte sectors. + sizeBytes *= 512 + + dev := StorageDevice{ + Name: name, + StorageBytes: sizeBytes, + } + + rotational, err := readSysfsInt(filepath.Join("/sys/block", name, "queue", "rotational")) + if err == nil { + if rotational == 0 { + dev.StorageType = "SSD" + } else { + dev.StorageType = "HDD" + } + } + + // Detect NVMe specifically. + if strings.HasPrefix(name, "nvme") { + dev.StorageType = "NVMe" + } + + devices = append(devices, dev) + } + return devices +} + +// readSysfsInt reads a single integer from a sysfs file. +func readSysfsInt(path string) (int64, error) { + data, err := os.ReadFile(path) // #nosec G304 + if err != nil { + return 0, fmt.Errorf("read %s: %w", path, err) + } + return strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64) +} diff --git a/pkg/cmd/register/hardware_stub.go b/pkg/cmd/register/hardware_stub.go new file mode 100644 index 00000000..37bf8eb6 --- /dev/null +++ b/pkg/cmd/register/hardware_stub.go @@ -0,0 +1,10 @@ +//go:build !linux && !darwin && !windows + +package register + +// SystemHardwareProfiler is a no-op adapter for unsupported platforms. +type SystemHardwareProfiler struct{} + +func (p *SystemHardwareProfiler) Profile() (*HardwareProfile, error) { + return &HardwareProfile{}, nil +} diff --git a/pkg/cmd/register/hardware_test.go b/pkg/cmd/register/hardware_test.go index 3cb033c3..6e236851 100644 --- a/pkg/cmd/register/hardware_test.go +++ b/pkg/cmd/register/hardware_test.go @@ -94,50 +94,6 @@ func Test_parseOSReleaseContent(t *testing.T) { } } -func Test_parseNvidiaSMIOutput_GroupsByModel(t *testing.T) { - output := `NVIDIA GB10, 131072 -NVIDIA GB10, 131072 -` - gpus := parseNvidiaSMIOutput(output) - if len(gpus) != 1 { - t.Fatalf("expected 1 GPU group, got %d", len(gpus)) - } - if gpus[0].Model != "NVIDIA GB10" { - t.Errorf("unexpected GPU model: %s", gpus[0].Model) - } - if gpus[0].Count != 2 { - t.Errorf("expected count 2, got %d", gpus[0].Count) - } - expectedMem := int64(131072) * 1024 * 1024 - if gpus[0].MemoryBytes == nil || *gpus[0].MemoryBytes != expectedMem { - t.Errorf("expected %d bytes, got %v", expectedMem, gpus[0].MemoryBytes) - } -} - -func Test_parseNvidiaSMIOutput_MultipleModels(t *testing.T) { - output := `NVIDIA A100, 81920 -NVIDIA GB10, 131072 -NVIDIA A100, 81920 -` - gpus := parseNvidiaSMIOutput(output) - if len(gpus) != 2 { - t.Fatalf("expected 2 GPU groups, got %d", len(gpus)) - } - if gpus[0].Model != "NVIDIA A100" || gpus[0].Count != 2 { - t.Errorf("expected 2x NVIDIA A100, got %dx %s", gpus[0].Count, gpus[0].Model) - } - if gpus[1].Model != "NVIDIA GB10" || gpus[1].Count != 1 { - t.Errorf("expected 1x NVIDIA GB10, got %dx %s", gpus[1].Count, gpus[1].Model) - } -} - -func Test_parseNvidiaSMIOutput_Empty(t *testing.T) { - gpus := parseNvidiaSMIOutput("") - if len(gpus) != 0 { - t.Errorf("expected 0 GPUs, got %d", len(gpus)) - } -} - func Test_parseStorageOutput(t *testing.T) { output := `nvme0n1 500107862016 disk 0 nvme1n1 1000204886016 disk 0 @@ -153,6 +109,9 @@ sda 2048 rom 1 if devices[0].StorageType != "SSD" { t.Errorf("expected SSD, got %s", devices[0].StorageType) } + if devices[0].Name != "nvme0n1" { + t.Errorf("expected name nvme0n1, got %s", devices[0].Name) + } if devices[1].StorageBytes != 1000204886016 { t.Errorf("expected 1000204886016, got %d", devices[1].StorageBytes) } @@ -195,21 +154,25 @@ func Test_unquote(t *testing.T) { } } -func Test_FormatNodeSpec(t *testing.T) { +func Test_FormatHardwareProfile(t *testing.T) { cpuCount := int32(12) ramBytes := int64(137438953472) // 128 GB memBytes := int64(137438953472) // 128 GB - s := &NodeSpec{ + s := &HardwareProfile{ CPUCount: &cpuCount, RAMBytes: &ramBytes, Architecture: "arm64", OS: "Ubuntu", OSVersion: "24.04", - GPUs: []NodeGPU{ - {Model: "NVIDIA GB10", Count: 1, MemoryBytes: &memBytes}, + ProductName: "DGX Spark", + GPUs: []GPU{ + {Model: "NVIDIA GB10", Architecture: "Blackwell", Count: 1, MemoryBytes: &memBytes}, + }, + Interconnects: []Interconnect{ + {Type: "NVLink", Device: "GPU 0", ActiveLinks: 4, Version: 4}, }, } - output := FormatNodeSpec(s) + output := FormatHardwareProfile(s) if output == "" { t.Fatal("expected non-empty output") } @@ -222,16 +185,25 @@ func Test_FormatNodeSpec(t *testing.T) { if !strings.Contains(output, "NVIDIA GB10") { t.Errorf("expected GPU info in output: %s", output) } + if !strings.Contains(output, "Blackwell") { + t.Errorf("expected GPU arch in output: %s", output) + } + if !strings.Contains(output, "DGX Spark") { + t.Errorf("expected product name in output: %s", output) + } + if !strings.Contains(output, "NVLink") { + t.Errorf("expected interconnect in output: %s", output) + } } -func Test_FormatNodeSpec_MinimalFields(t *testing.T) { - s := &NodeSpec{ - GPUs: []NodeGPU{ +func Test_FormatHardwareProfile_MinimalFields(t *testing.T) { + s := &HardwareProfile{ + GPUs: []GPU{ {Model: "NVIDIA GB10", Count: 1}, }, Architecture: "arm64", } - output := FormatNodeSpec(s) + output := FormatHardwareProfile(s) if strings.Contains(output, "CPU:") { t.Errorf("should not contain CPU when nil: %s", output) } @@ -246,15 +218,15 @@ func Test_FormatNodeSpec_MinimalFields(t *testing.T) { } } -func Test_FormatNodeSpec_WithStorage(t *testing.T) { - s := &NodeSpec{ +func Test_FormatHardwareProfile_WithStorage(t *testing.T) { + s := &HardwareProfile{ Architecture: "amd64", - Storage: []NodeStorage{ - {StorageBytes: 500107862016, StorageType: "SSD"}, - {StorageBytes: 1000204886016, StorageType: "HDD"}, + Storage: []StorageDevice{ + {Name: "nvme0n1", StorageBytes: 500107862016, StorageType: "SSD"}, + {Name: "sda", StorageBytes: 1000204886016, StorageType: "HDD"}, }, } - output := FormatNodeSpec(s) + output := FormatHardwareProfile(s) if !strings.Contains(output, "Storage:") { t.Errorf("expected storage in output: %s", output) } @@ -264,22 +236,8 @@ func Test_FormatNodeSpec_WithStorage(t *testing.T) { if !strings.Contains(output, "HDD") { t.Errorf("expected HDD in output: %s", output) } -} - -func Test_parseNvidiaSMIOutput_MalformedLines(t *testing.T) { - output := ` -malformed line -NVIDIA GB10, 131072 -, , -just-a-name -NVIDIA A100, not-a-number -` - gpus := parseNvidiaSMIOutput(output) - if len(gpus) != 1 { - t.Fatalf("expected 1 valid GPU, got %d", len(gpus)) - } - if gpus[0].Model != "NVIDIA GB10" { - t.Errorf("unexpected model: %s", gpus[0].Model) + if !strings.Contains(output, "nvme0n1") { + t.Errorf("expected device name in output: %s", output) } } @@ -301,129 +259,12 @@ func Test_parseStorageOutput_NoValidDevices(t *testing.T) { } } -// mockCommandRunner for testing CollectHardwareProfile -type mockCommandRunner struct { - outputs map[string][]byte - errors map[string]error -} - -func (m *mockCommandRunner) Run(name string, args ...string) ([]byte, error) { - key := name - if err, ok := m.errors[key]; ok { - return nil, err - } - if out, ok := m.outputs[key]; ok { - return out, nil - } - return nil, nil -} - -type mockFileReader struct { - files map[string][]byte -} - -func (m *mockFileReader) ReadFile(path string) ([]byte, error) { - if data, ok := m.files[path]; ok { - return data, nil - } - return nil, &mockFileNotFoundError{path: path} -} - -type mockFileNotFoundError struct{ path string } - -func (e *mockFileNotFoundError) Error() string { return "file not found: " + e.path } - -func Test_CollectHardwareProfile_WithMocks(t *testing.T) { - runner := &mockCommandRunner{ - outputs: map[string][]byte{ - "nvidia-smi": []byte("NVIDIA GB10, 131072\nNVIDIA GB10, 131072\n"), - "lsblk": []byte("nvme0n1 500107862016 disk 0\n"), - }, - } - reader := &mockFileReader{ - files: map[string][]byte{ - "/proc/cpuinfo": []byte("processor\t: 0\nprocessor\t: 1\n"), - "/proc/meminfo": []byte("MemTotal: 131886028 kB\n"), - "/etc/os-release": []byte("NAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\n"), - }, - } - - spec, err := CollectHardwareProfile(runner, reader) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(spec.GPUs) != 1 || spec.GPUs[0].Count != 2 { - t.Errorf("expected 1 GPU group with count 2, got %v", spec.GPUs) - } - if spec.CPUCount == nil || *spec.CPUCount != 2 { - t.Errorf("expected 2 CPUs, got %v", spec.CPUCount) - } - if spec.RAMBytes == nil || *spec.RAMBytes != 131886028*1024 { - t.Errorf("unexpected RAM: %v", spec.RAMBytes) - } - if spec.OS != "Ubuntu" || spec.OSVersion != "24.04" { - t.Errorf("unexpected OS: %s %s", spec.OS, spec.OSVersion) - } - if len(spec.Storage) != 1 || spec.Storage[0].StorageBytes != 500107862016 { - t.Errorf("unexpected storage: %v", spec.Storage) - } - if spec.Storage[0].StorageType != "SSD" { - t.Errorf("expected SSD, got %s", spec.Storage[0].StorageType) - } -} - -func Test_CollectHardwareProfile_GPUBestEffort(t *testing.T) { - runner := &mockCommandRunner{ - errors: map[string]error{ - "nvidia-smi": &mockFileNotFoundError{path: "nvidia-smi"}, - }, - } - reader := &mockFileReader{ - files: map[string][]byte{ - "/proc/cpuinfo": []byte("processor\t: 0\n"), - "/proc/meminfo": []byte("MemTotal: 131886028 kB\n"), - }, - } - - spec, err := CollectHardwareProfile(runner, reader) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(spec.GPUs) != 0 { - t.Errorf("expected 0 GPUs when nvidia-smi fails, got %d", len(spec.GPUs)) - } - if spec.CPUCount == nil || *spec.CPUCount != 1 { - t.Errorf("expected 1 CPU, got %v", spec.CPUCount) - } +// mockHardwareProfiler implements HardwareProfiler for tests. +type mockHardwareProfiler struct { + profile *HardwareProfile + err error } -func Test_CollectHardwareProfile_OptionalFieldsMissing(t *testing.T) { - runner := &mockCommandRunner{ - outputs: map[string][]byte{ - "nvidia-smi": []byte("NVIDIA GB10, 131072\n"), - }, - errors: map[string]error{ - "lsblk": &mockFileNotFoundError{path: "lsblk"}, - }, - } - reader := &mockFileReader{ - files: map[string][]byte{}, - } - - spec, err := CollectHardwareProfile(runner, reader) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if spec.CPUCount != nil { - t.Errorf("expected nil CPUCount when /proc/cpuinfo missing") - } - if spec.RAMBytes != nil { - t.Errorf("expected nil RAMBytes when /proc/meminfo missing") - } - if len(spec.Storage) != 0 { - t.Errorf("expected empty Storage when lsblk fails, got %v", spec.Storage) - } - if len(spec.GPUs) != 1 { - t.Errorf("expected 1 GPU, got %d", len(spec.GPUs)) - } +func (m *mockHardwareProfiler) Profile() (*HardwareProfile, error) { + return m.profile, m.err } diff --git a/pkg/cmd/register/register.go b/pkg/cmd/register/register.go index 4e4cf0db..9a8043dd 100644 --- a/pkg/cmd/register/register.go +++ b/pkg/cmd/register/register.go @@ -4,7 +4,6 @@ package register import ( "context" "fmt" - "os" "os/exec" "os/user" "strings" @@ -30,17 +29,6 @@ type RegisterStore interface { GetAccessToken() (string, error) } -// OSFileReader reads files from the real OS filesystem. -type OSFileReader struct{} - -func (r OSFileReader) ReadFile(path string) ([]byte, error) { - data, err := os.ReadFile(path) // #nosec G304 - if err != nil { - return nil, breverrors.WrapAndTrace(err) - } - return data, nil -} - // NetBirdManager installs and uninstalls the NetBird network agent. type NetBirdManager interface { Install() error @@ -60,8 +48,7 @@ type registerDeps struct { netbird NetBirdManager setupRunner SetupRunner nodeClients externalnode.NodeClientFactory - commandRunner CommandRunner - fileReader FileReader + hardwareProfiler HardwareProfiler registrationStore RegistrationStore } @@ -72,8 +59,7 @@ func defaultRegisterDeps() registerDeps { netbird: Netbird{}, setupRunner: ShellSetupRunner{}, nodeClients: DefaultNodeClientFactory{}, - commandRunner: ExecCommandRunner{}, - fileReader: OSFileReader{}, + hardwareProfiler: &SystemHardwareProfiler{}, registrationStore: NewFileRegistrationStore(), } } @@ -166,13 +152,13 @@ func runRegister(ctx context.Context, t *terminal.Terminal, s RegisterStore, nam t.Vprint(t.Yellow("[Step 2/3] Collecting hardware profile...")) t.Vprint("") - nodeSpec, err := CollectHardwareProfile(deps.commandRunner, deps.fileReader) + hwProfile, err := deps.hardwareProfiler.Profile() if err != nil { return fmt.Errorf("failed to collect hardware profile: %w", err) } t.Vprint(" Hardware profile:") - t.Vprint(FormatNodeSpec(nodeSpec)) + t.Vprint(FormatHardwareProfile(hwProfile)) t.Vprint("") t.Vprint(t.Yellow("[Step 3/3] Registering with Brev...")) @@ -183,7 +169,7 @@ func runRegister(ctx context.Context, t *terminal.Terminal, s RegisterStore, nam OrganizationId: org.ID, Name: name, DeviceId: deviceID, - NodeSpec: toProtoNodeSpec(nodeSpec), + NodeSpec: toProtoNodeSpec(hwProfile), })) if err != nil { return fmt.Errorf("failed to register node: %w", err) @@ -191,12 +177,12 @@ func runRegister(ctx context.Context, t *terminal.Terminal, s RegisterStore, nam node := addResp.Msg.GetExternalNode() reg := &DeviceRegistration{ - ExternalNodeID: node.GetExternalNodeId(), - DisplayName: name, - OrgID: org.ID, - DeviceID: deviceID, - RegisteredAt: time.Now().UTC().Format(time.RFC3339), - NodeSpec: *nodeSpec, + ExternalNodeID: node.GetExternalNodeId(), + DisplayName: name, + OrgID: org.ID, + DeviceID: deviceID, + RegisteredAt: time.Now().UTC().Format(time.RFC3339), + HardwareProfile: *hwProfile, } if err := deps.registrationStore.Save(reg); err != nil { return fmt.Errorf("node registered but failed to save locally: %w", err) diff --git a/pkg/cmd/register/register_test.go b/pkg/cmd/register/register_test.go index b031fea4..35dd4e9d 100644 --- a/pkg/cmd/register/register_test.go +++ b/pkg/cmd/register/register_test.go @@ -98,6 +98,26 @@ func (m mockNodeClientFactory) NewNodeClient(provider externalnode.TokenProvider return NewNodeServiceClient(provider, m.serverURL) } +// testHardwareProfile returns a realistic HardwareProfile for use in tests. +func testHardwareProfile() *HardwareProfile { + cpuCount := int32(2) + ramBytes := int64(131886028) * 1024 + memBytes := int64(131072) * 1024 * 1024 + return &HardwareProfile{ + Architecture: "arm64", + OS: "Ubuntu", + OSVersion: "24.04", + GPUs: []GPU{ + {Model: "NVIDIA GB10", Count: 1, MemoryBytes: &memBytes}, + }, + CPUCount: &cpuCount, + RAMBytes: &ramBytes, + Storage: []StorageDevice{ + {Name: "nvme0n1", StorageBytes: 500107862016, StorageType: "SSD"}, + }, + } +} + // testRegisterDeps returns deps with all side effects stubbed out, and a fake // ConnectRPC server backed by the provided fakeNodeService. func testRegisterDeps(t *testing.T, svc *fakeNodeService, regStore RegistrationStore) (registerDeps, *httptest.Server) { @@ -112,18 +132,8 @@ func testRegisterDeps(t *testing.T, svc *fakeNodeService, regStore RegistrationS netbird: mockNetBirdManager{}, setupRunner: &mockSetupRunner{}, nodeClients: mockNodeClientFactory{serverURL: server.URL}, - commandRunner: &mockCommandRunner{ - outputs: map[string][]byte{ - "nvidia-smi": []byte("NVIDIA GB10, 131072\n"), - "lsblk": []byte("nvme0n1 500107862016 disk 0\n"), - }, - }, - fileReader: &mockFileReader{ - files: map[string][]byte{ - "/proc/cpuinfo": []byte("processor\t: 0\nprocessor\t: 1\n"), - "/proc/meminfo": []byte("MemTotal: 131886028 kB\n"), - "/etc/os-release": []byte("NAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\n"), - }, + hardwareProfiler: &mockHardwareProfiler{ + profile: testHardwareProfile(), }, registrationStore: regStore, }, server diff --git a/pkg/cmd/register/rpcclient.go b/pkg/cmd/register/rpcclient.go index 491ec9f2..41d4838d 100644 --- a/pkg/cmd/register/rpcclient.go +++ b/pkg/cmd/register/rpcclient.go @@ -53,43 +53,80 @@ func NewNodeServiceClient(provider externalnode.TokenProvider, baseURL string) n ) } -// toProtoNodeSpec converts the local NodeSpec (used for collection, display, persistence) -// to the generated proto NodeSpec for RPC calls. -func toProtoNodeSpec(s *NodeSpec) *nodev1.NodeSpec { - if s == nil { +// toProtoNodeSpec converts the local HardwareProfile (used for collection, display, +// persistence) to the generated proto NodeSpec for RPC calls. +func toProtoNodeSpec(hw *HardwareProfile) *nodev1.NodeSpec { + if hw == nil { return nil } proto := &nodev1.NodeSpec{ - RamBytes: s.RAMBytes, - CpuCount: s.CPUCount, + RamBytes: hw.RAMBytes, + CpuCount: hw.CPUCount, } - for _, st := range s.Storage { - proto.Storage = append(proto.Storage, &nodev1.StorageSpec{ + for _, st := range hw.Storage { + storageSpec := &nodev1.StorageSpec{ StorageBytes: st.StorageBytes, StorageType: st.StorageType, - }) + } + // TODO(BRE2-801): uncomment when proto dep is updated + // if st.Name != "" { + // storageSpec.Device = &st.Name + // } + proto.Storage = append(proto.Storage, storageSpec) } - if s.Architecture != "" { - proto.Architecture = &s.Architecture + if hw.Architecture != "" { + proto.Architecture = &hw.Architecture } - if s.OS != "" { - proto.Os = &s.OS + if hw.OS != "" { + proto.Os = &hw.OS } - if s.OSVersion != "" { - proto.OsVersion = &s.OSVersion + if hw.OSVersion != "" { + proto.OsVersion = &hw.OSVersion } - for _, g := range s.GPUs { + // TODO(BRE2-801): uncomment when proto dep is updated + // if hw.ProductName != "" { + // proto.ProductName = &hw.ProductName + // } + + for _, g := range hw.GPUs { pg := &nodev1.GPUSpec{ Model: g.Model, Count: g.Count, MemoryBytes: g.MemoryBytes, } + // TODO(BRE2-801): uncomment when proto dep is updated + // if g.Architecture != "" { + // pg.GpuArchitecture = &g.Architecture + // } proto.Gpus = append(proto.Gpus, pg) } + // TODO(BRE2-801): uncomment when proto dep is updated + // InterconnectSpec uses oneof: NVLinkDetails or PCIeDetails + // for _, ic := range hw.Interconnects { + // spec := &nodev1.InterconnectSpec{Device: ic.Device} + // switch ic.Type { + // case "NVLink": + // spec.Details = &nodev1.InterconnectSpec_Nvlink{ + // Nvlink: &nodev1.NVLinkDetails{ + // ActiveLinks: int32(ic.ActiveLinks), + // Version: ic.Version, + // }, + // } + // case "PCIe": + // spec.Details = &nodev1.InterconnectSpec_Pcie{ + // Pcie: &nodev1.PCIeDetails{ + // Generation: int32(ic.Generation), + // Width: int32(ic.Width), + // }, + // } + // } + // proto.Interconnects = append(proto.Interconnects, spec) + // } + return proto } diff --git a/pkg/cmd/register/rpcclient_test.go b/pkg/cmd/register/rpcclient_test.go index ce45c079..27bcf2af 100644 --- a/pkg/cmd/register/rpcclient_test.go +++ b/pkg/cmd/register/rpcclient_test.go @@ -60,18 +60,22 @@ func Test_toProtoNodeSpec(t *testing.T) { ramBytes := int64(137438953472) memBytes := int64(137438953472) - local := &NodeSpec{ - GPUs: []NodeGPU{ - {Model: "NVIDIA GB10", Count: 2, MemoryBytes: &memBytes}, + local := &HardwareProfile{ + GPUs: []GPU{ + {Model: "NVIDIA GB10", Architecture: "Blackwell", Count: 2, MemoryBytes: &memBytes}, }, RAMBytes: &ramBytes, CPUCount: &cpuCount, Architecture: "arm64", - Storage: []NodeStorage{ - {StorageBytes: 500107862016, StorageType: "SSD"}, + Storage: []StorageDevice{ + {Name: "nvme0n1", StorageBytes: 500107862016, StorageType: "SSD"}, + }, + OS: "Ubuntu", + OSVersion: "24.04", + ProductName: "DGX Spark", + Interconnects: []Interconnect{ + {Type: "NVLink", Device: "GPU 0", ActiveLinks: 4, Version: 4}, }, - OS: "Ubuntu", - OSVersion: "24.04", } proto := toProtoNodeSpec(local) @@ -122,7 +126,7 @@ func Test_toProtoNodeSpec_Nil(t *testing.T) { } func Test_toProtoNodeSpec_MinimalFields(t *testing.T) { - local := &NodeSpec{ + local := &HardwareProfile{ Architecture: "amd64", } proto := toProtoNodeSpec(local) From 6a6d007ed49884df3c6ed3db9b538af42362bb81 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Thu, 5 Mar 2026 13:08:06 -0800 Subject: [PATCH 2/7] cgo cross compile, bugfix in fetch devplane proto, uncomment todos --- Makefile | 30 ++++++++++++++--- go.mod | 2 +- go.sum | 4 +-- pkg/cmd/register/rpcclient.go | 63 ++++++++++++++++------------------- 4 files changed, 57 insertions(+), 42 deletions(-) diff --git a/Makefile b/Makefile index b072f053..e2256008 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,31 @@ .DEFAULT_GOAL := fast-build VERSION := dev-$(shell git rev-parse HEAD | cut -c 1-8) +# Cross-compilation via Docker (golang:1.24 native Linux container). +# When arch=/ is provided, spin up a container that matches +# the target platform so CGO uses the native Linux gcc/GNU ld toolchain +_GOMODCACHE := $(shell go env GOMODCACHE) +ifdef arch + _CROSS_GOOS := $(word 1,$(subst /, ,$(arch))) + _CROSS_GOARCH := $(word 2,$(subst /, ,$(arch))) + _BUILD_PREFIX := docker run --rm \ + --platform $(_CROSS_GOOS)/$(_CROSS_GOARCH) \ + -v $(CURDIR):/app \ + -v $(_GOMODCACHE):/go/pkg/mod \ + -e CGO_ENABLED=1 \ + -e GOPRIVATE=github.com/brevdev/* \ + -e GONOSUMDB=github.com/brevdev/* \ + -w /app \ + golang:1.24 +else + _BUILD_PREFIX := CGO_ENABLED=1 +endif + .PHONY: fast-build fast-build: ## go build -o brev $(call print-target) echo ${VERSION} - CGO_ENABLED=0 go build -o brev -ldflags "-X github.com/brevdev/brev-cli/pkg/cmd/version.Version=${VERSION}" + CGO_ENABLED=1 go build -o brev -ldflags "-X github.com/brevdev/brev-cli/pkg/cmd/version.Version=${VERSION}" .PHONY: local local: ## build with env wrapper (use: make local env=dev0|dev1|dev2|stg arch=linux/amd64, or make local for defaults) @@ -13,7 +33,7 @@ local: ## build with env wrapper (use: make local env=dev0|dev1|dev2|stg arch=li ifdef env @echo "Building with env=$(env) wrapper..." @echo ${VERSION} - $(if $(arch),GOOS=$(word 1,$(subst /, ,$(arch))) GOARCH=$(word 2,$(subst /, ,$(arch))),) CGO_ENABLED=0 go build -o brev-local -ldflags "-X github.com/brevdev/brev-cli/pkg/cmd/version.Version=${VERSION}" + $(_BUILD_PREFIX) go build -o brev-local -ldflags "-X github.com/brevdev/brev-cli/pkg/cmd/version.Version=${VERSION}" @echo '#!/bin/sh' > brev @echo '# Auto-generated wrapper with environment overrides' >> brev @echo 'export BREV_CONSOLE_URL="https://localhost.nvidia.com:3000"' >> brev @@ -26,7 +46,7 @@ ifdef env @chmod +x brev else @echo "Building without environment overrides (using config.go defaults)..." - $(if $(arch),GOOS=$(word 1,$(subst /, ,$(arch))) GOARCH=$(word 2,$(subst /, ,$(arch))),) CGO_ENABLED=0 go build -o brev -ldflags "-X github.com/brevdev/brev-cli/pkg/cmd/version.Version=${VERSION}" + $(_BUILD_PREFIX) go build -o brev -ldflags "-X github.com/brevdev/brev-cli/pkg/cmd/version.Version=${VERSION}" endif .PHONY: install-dev @@ -305,8 +325,8 @@ develop-with-nix: update-devplane-deps: ## update devplane dependencies (use: make update-devplane-deps commit=, defaults to latest) @COMMIT=$${commit:-latest}; \ echo "Updating devplane dependencies to: $$COMMIT"; \ - go get -u github.com/brevdev/dev-plane@$$COMMIT; \ + GOPRIVATE=github.com/brevdev/* go get -u github.com/brevdev/dev-plane@$$COMMIT; \ go get buf.build/gen/go/brevdev/devplane/grpc/go@$$COMMIT; \ go get buf.build/gen/go/brevdev/devplane/protocolbuffers/go@$$COMMIT; \ - go mod tidy; \ + GOPRIVATE=github.com/brevdev/* go mod tidy; \ echo "Successfully updated to $$COMMIT" \ No newline at end of file diff --git a/go.mod b/go.mod index a373a2e0..19f54a2c 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.24.0 require ( buf.build/gen/go/brevdev/devplane/connectrpc/go v1.19.1-20260228021043-887d38e1b474.2 - buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260305005117-3cacb6388cd4.1 + buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260305201249-af5106090c8a.1 connectrpc.com/connect v1.19.1 github.com/NVIDIA/go-nvml v0.13.0-1 github.com/alessio/shellescape v1.4.1 diff --git a/go.sum b/go.sum index c5a9aed8..3766f29c 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ buf.build/gen/go/brevdev/devplane/connectrpc/go v1.19.1-20260228021043-887d38e1b474.2 h1:Sq0kIa/xKzScbJcqB5EbPVhOL0QYHPr3araQaupL2lk= buf.build/gen/go/brevdev/devplane/connectrpc/go v1.19.1-20260228021043-887d38e1b474.2/go.mod h1:Yh34p9aADmWsKv2umYlMpnCZuBmNBE9N+HImgRriJXM= -buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260305005117-3cacb6388cd4.1 h1:3Y3FI5kbM4uacawy5dySjVTPSbu2BJMO42eQHf2wz+g= -buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260305005117-3cacb6388cd4.1/go.mod h1:V/y7Wxg0QvU4XPVwqErF5NHLobUT1QEyfgrGuQIxdPo= +buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260305201249-af5106090c8a.1 h1:d+kY4OSI/WV2eBuO5G7ezCQ8RiLtDOIrUbtmZOKi5Kw= +buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260305201249-af5106090c8a.1/go.mod h1:V/y7Wxg0QvU4XPVwqErF5NHLobUT1QEyfgrGuQIxdPo= buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.11-20220906235457-8b4922735da5.1 h1:6amhprQmCKJ4wgJ6ngkh32d9V+dQcOLUZ/SfHdOnYgo= buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.11-20220906235457-8b4922735da5.1/go.mod h1:O+pnSHMru/naTMrm4tmpBoH3wz6PHa+R75HR7Mv8X2g= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= diff --git a/pkg/cmd/register/rpcclient.go b/pkg/cmd/register/rpcclient.go index 41d4838d..273d9c21 100644 --- a/pkg/cmd/register/rpcclient.go +++ b/pkg/cmd/register/rpcclient.go @@ -70,10 +70,9 @@ func toProtoNodeSpec(hw *HardwareProfile) *nodev1.NodeSpec { StorageBytes: st.StorageBytes, StorageType: st.StorageType, } - // TODO(BRE2-801): uncomment when proto dep is updated - // if st.Name != "" { - // storageSpec.Device = &st.Name - // } + if st.Name != "" { + storageSpec.Device = &st.Name + } proto.Storage = append(proto.Storage, storageSpec) } @@ -87,10 +86,9 @@ func toProtoNodeSpec(hw *HardwareProfile) *nodev1.NodeSpec { proto.OsVersion = &hw.OSVersion } - // TODO(BRE2-801): uncomment when proto dep is updated - // if hw.ProductName != "" { - // proto.ProductName = &hw.ProductName - // } + if hw.ProductName != "" { + proto.ProductName = &hw.ProductName + } for _, g := range hw.GPUs { pg := &nodev1.GPUSpec{ @@ -98,35 +96,32 @@ func toProtoNodeSpec(hw *HardwareProfile) *nodev1.NodeSpec { Count: g.Count, MemoryBytes: g.MemoryBytes, } - // TODO(BRE2-801): uncomment when proto dep is updated - // if g.Architecture != "" { - // pg.GpuArchitecture = &g.Architecture - // } + if g.Architecture != "" { + pg.GpuArchitecture = &g.Architecture + } proto.Gpus = append(proto.Gpus, pg) } - // TODO(BRE2-801): uncomment when proto dep is updated - // InterconnectSpec uses oneof: NVLinkDetails or PCIeDetails - // for _, ic := range hw.Interconnects { - // spec := &nodev1.InterconnectSpec{Device: ic.Device} - // switch ic.Type { - // case "NVLink": - // spec.Details = &nodev1.InterconnectSpec_Nvlink{ - // Nvlink: &nodev1.NVLinkDetails{ - // ActiveLinks: int32(ic.ActiveLinks), - // Version: ic.Version, - // }, - // } - // case "PCIe": - // spec.Details = &nodev1.InterconnectSpec_Pcie{ - // Pcie: &nodev1.PCIeDetails{ - // Generation: int32(ic.Generation), - // Width: int32(ic.Width), - // }, - // } - // } - // proto.Interconnects = append(proto.Interconnects, spec) - // } + for _, ic := range hw.Interconnects { + spec := &nodev1.InterconnectSpec{Device: ic.Device} + switch ic.Type { + case "NVLink": + spec.Details = &nodev1.InterconnectSpec_Nvlink{ + Nvlink: &nodev1.NVLinkDetails{ + ActiveLinks: int32(ic.ActiveLinks), + Version: ic.Version, + }, + } + case "PCIe": + spec.Details = &nodev1.InterconnectSpec_Pcie{ + Pcie: &nodev1.PCIeDetails{ + Generation: int32(ic.Generation), + Width: int32(ic.Width), + }, + } + } + proto.Interconnects = append(proto.Interconnects, spec) + } return proto } From 42ec5a0310f48cd72890a554a4e2f157b2550357 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Thu, 5 Mar 2026 13:26:36 -0800 Subject: [PATCH 3/7] also use cgo_enabled=1 iin goreleaser --- .goreleaser.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index 61db3077..baeb2ce2 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -3,7 +3,7 @@ before: - go mod download builds: - env: - - CGO_ENABLED=0 + - CGO_ENABLED=1 goos: - darwin - linux From 1cc4b80c255b204425248888a0f424664e51b164 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Thu, 5 Mar 2026 13:38:01 -0800 Subject: [PATCH 4/7] lint --- pkg/cmd/register/gpu_nvml.go | 2 +- pkg/cmd/register/hardware_linux.go | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/register/gpu_nvml.go b/pkg/cmd/register/gpu_nvml.go index 0d34d840..3bf4277d 100644 --- a/pkg/cmd/register/gpu_nvml.go +++ b/pkg/cmd/register/gpu_nvml.go @@ -121,7 +121,7 @@ func probeNVLink(device nvml.Device, deviceIdx int) []Interconnect { if nvlinkVersion == 0 { ver, ret := device.GetNvLinkVersion(link) if ret == nvml.SUCCESS { - nvlinkVersion = uint32(ver) + nvlinkVersion = ver } } } diff --git a/pkg/cmd/register/hardware_linux.go b/pkg/cmd/register/hardware_linux.go index a2f0d2c5..d769b99d 100644 --- a/pkg/cmd/register/hardware_linux.go +++ b/pkg/cmd/register/hardware_linux.go @@ -10,6 +10,8 @@ import ( "strconv" "strings" "syscall" + + errors "github.com/brevdev/brev-cli/pkg/errors" ) // SystemHardwareProfiler probes hardware on Linux using NVML, sysfs, and procfs. @@ -49,7 +51,7 @@ func readProductName() string { func readCPUCount() (int, error) { data, err := os.ReadFile("/proc/cpuinfo") if err != nil { - return 0, err + return 0, errors.WrapAndTrace(err) } return parseCPUCountContent(string(data)) } @@ -58,7 +60,7 @@ func readCPUCount() (int, error) { func readRAMBytes() (int64, error) { var info syscall.Sysinfo_t if err := syscall.Sysinfo(&info); err != nil { - return 0, err + return 0, errors.WrapAndTrace(err) } return int64(info.Totalram) * int64(info.Unit), nil } @@ -128,7 +130,11 @@ func probeStorageSysfs() []StorageDevice { func readSysfsInt(path string) (int64, error) { data, err := os.ReadFile(path) // #nosec G304 if err != nil { - return 0, fmt.Errorf("read %s: %w", path, err) + return 0, errors.WrapAndTrace(fmt.Errorf("read %s: %w", path, err)) + } + n, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64) + if err != nil { + return 0, errors.WrapAndTrace(err) } - return strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64) + return n, nil } From bc416660db0a74f08cdb1c20c26c9d50515dc7e8 Mon Sep 17 00:00:00 2001 From: mistercodedor Date: Fri, 6 Mar 2026 08:57:27 -0500 Subject: [PATCH 5/7] fix: address hardware profile PR comments --- pkg/cmd/register/gpu_nvml.go | 48 +++++-- pkg/cmd/register/hardware.go | 2 +- pkg/cmd/register/hardware_darwin.go | 16 +++ pkg/cmd/register/hardware_darwin_test.go | 155 +++++++++++++++++++++++ pkg/cmd/register/hardware_stub.go | 6 +- 5 files changed, 210 insertions(+), 17 deletions(-) create mode 100644 pkg/cmd/register/hardware_darwin_test.go diff --git a/pkg/cmd/register/gpu_nvml.go b/pkg/cmd/register/gpu_nvml.go index 3bf4277d..b5137a7e 100644 --- a/pkg/cmd/register/gpu_nvml.go +++ b/pkg/cmd/register/gpu_nvml.go @@ -8,18 +8,38 @@ import ( "github.com/NVIDIA/go-nvml/pkg/nvml" ) -// archNames maps NVML compute capability (major version) to GPU architecture name. -var archNames = map[int]string{ - 1: "Tesla", - 2: "Fermi", - 3: "Kepler", - 5: "Maxwell", - 6: "Pascal", - 7: "Volta/Turing", - 8: "Ampere", - 9: "Hopper/Ada Lovelace", - 10: "Blackwell", - 12: "Vera Rubin", +// archName returns the GPU architecture name for the given CUDA compute capability. +func archName(major, minor int) string { + switch major { + case 1: + return "Tesla" + case 2: + return "Fermi" + case 3: + return "Kepler" + case 5: + return "Maxwell" + case 6: + return "Pascal" + case 7: + if minor >= 5 { + return "Turing" + } + return "Volta" + case 8: + if minor >= 9 { + return "Ada Lovelace" + } + return "Ampere" + case 9: + return "Hopper" + case 10: + return "Blackwell" + case 12: + return "Vera Rubin" + default: + return "" + } } // probeGPUsNVML uses NVML to detect GPUs and interconnects. @@ -65,8 +85,8 @@ func probeGPUsNVML() ([]GPU, []Interconnect) { arch := "" major, minor, ret := device.GetCudaComputeCapability() if ret == nvml.SUCCESS { - if archName, ok := archNames[major]; ok { - arch = archName + if name := archName(major, minor); name != "" { + arch = name } else { arch = fmt.Sprintf("sm_%d%d", major, minor) } diff --git a/pkg/cmd/register/hardware.go b/pkg/cmd/register/hardware.go index dfbb3d3e..733259ea 100644 --- a/pkg/cmd/register/hardware.go +++ b/pkg/cmd/register/hardware.go @@ -35,7 +35,7 @@ type Interconnect struct { type StorageDevice struct { Name string `json:"name,omitempty"` StorageBytes int64 `json:"storage_bytes"` - StorageType string `json:"storage_type,omitempty"` // "SSD" or "HDD" + StorageType string `json:"storage_type,omitempty"` // "SSD", "HDD", or "NVMe" } // HardwareProfile is the full hardware snapshot collected by a HardwareProfiler. diff --git a/pkg/cmd/register/hardware_darwin.go b/pkg/cmd/register/hardware_darwin.go index d1e4e0e6..040b9a46 100644 --- a/pkg/cmd/register/hardware_darwin.go +++ b/pkg/cmd/register/hardware_darwin.go @@ -4,12 +4,16 @@ package register import ( "os/exec" + "regexp" "runtime" + "strconv" "strings" "golang.org/x/sys/unix" ) +var diskutilBytesRe = regexp.MustCompile(`\((\d[\d,]*)\s+Bytes\)`) + // SystemHardwareProfiler probes hardware on macOS using sysctl and system_profiler. type SystemHardwareProfiler struct{} @@ -129,6 +133,14 @@ func isWholeDisk(name string) bool { } func parseDiskutilSize(val string, dev *StorageDevice) { + if m := diskutilBytesRe.FindStringSubmatch(val); len(m) == 2 { + cleaned := strings.ReplaceAll(m[1], ",", "") + if size, err := strconv.ParseInt(cleaned, 10, 64); err == nil { + dev.StorageBytes = size + return + } + } + // Fallback: extract digits from first token. parts := strings.Fields(val) if len(parts) < 1 { return @@ -143,6 +155,10 @@ func parseDiskutilSize(val string, dev *StorageDevice) { } func parseDiskutilSolidState(val string, dev *StorageDevice) { + // Don't overwrite a more specific type (e.g. NVMe from Protocol line). + if dev.StorageType != "" { + return + } if strings.EqualFold(val, "yes") { dev.StorageType = "SSD" } else { diff --git a/pkg/cmd/register/hardware_darwin_test.go b/pkg/cmd/register/hardware_darwin_test.go new file mode 100644 index 00000000..125a79b0 --- /dev/null +++ b/pkg/cmd/register/hardware_darwin_test.go @@ -0,0 +1,155 @@ +//go:build darwin + +package register + +import ( + "testing" +) + +func Test_parseDiskutilSize(t *testing.T) { + tests := []struct { + name string + val string + want int64 + }{ + { + "BytesFirst", + "500107862016 Bytes (exactly 976773168 512-Byte-Units)", + 500107862016, + }, + { + "HumanReadableFirst", + "1.0 TB (1,000,000,000,000 Bytes)", + 1000000000000, + }, + { + "GBWithParenBytes", + "500.1 GB (500,107,862,016 Bytes)", + 500107862016, + }, + { + "NoParensFallback", + "500107862016", + 500107862016, + }, + { + "Empty", + "", + 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dev := &StorageDevice{} + parseDiskutilSize(tt.val, dev) + if dev.StorageBytes != tt.want { + t.Errorf("parseDiskutilSize(%q) = %d, want %d", tt.val, dev.StorageBytes, tt.want) + } + }) + } +} + +func Test_isWholeDisk(t *testing.T) { + tests := []struct { + name string + want bool + }{ + {"disk0", true}, + {"disk1", true}, + {"disk0s1", false}, + {"disk2s3", false}, + {"notadisk", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isWholeDisk(tt.name); got != tt.want { + t.Errorf("isWholeDisk(%q) = %v, want %v", tt.name, got, tt.want) + } + }) + } +} + +func Test_parseDiskutilSolidState(t *testing.T) { + tests := []struct { + val string + want string + }{ + {"Yes", "SSD"}, + {"yes", "SSD"}, + {"No", "HDD"}, + {"no", "HDD"}, + } + for _, tt := range tests { + t.Run(tt.val, func(t *testing.T) { + dev := &StorageDevice{} + parseDiskutilSolidState(tt.val, dev) + if dev.StorageType != tt.want { + t.Errorf("parseDiskutilSolidState(%q) → %q, want %q", tt.val, dev.StorageType, tt.want) + } + }) + } +} + +func Test_parseDiskutilInfoOutput(t *testing.T) { + output := `********** + + Device Identifier: disk0 + Device Node: /dev/disk0 + Whole: Yes + Part of Whole: disk0 + Disk Size: 500.1 GB (500,107,862,016 Bytes) + Protocol: NVMe + Solid State: Yes + Device / Media Name: APPLE SSD AP0512Q + +********** + + Device Identifier: disk0s1 + Device Node: /dev/disk0s1 + Whole: No + Part of Whole: disk0 + Disk Size: 524.3 MB (524,288,000 Bytes) + +********** + + Device Identifier: disk1 + Device Node: /dev/disk1 + Whole: Yes + Part of Whole: disk1 + Disk Size: 1.0 TB (1,000,000,000,000 Bytes) + Solid State: No + +` + + devices := parseDiskutilInfoOutput(output) + if len(devices) != 2 { + t.Fatalf("expected 2 devices, got %d", len(devices)) + } + + if devices[0].Name != "disk0" { + t.Errorf("device 0 name = %q, want disk0", devices[0].Name) + } + if devices[0].StorageBytes != 500107862016 { + t.Errorf("device 0 bytes = %d, want 500107862016", devices[0].StorageBytes) + } + if devices[0].StorageType != "NVMe" { + t.Errorf("device 0 type = %q, want NVMe", devices[0].StorageType) + } + + if devices[1].Name != "disk1" { + t.Errorf("device 1 name = %q, want disk1", devices[1].Name) + } + if devices[1].StorageBytes != 1000000000000 { + t.Errorf("device 1 bytes = %d, want 1000000000000", devices[1].StorageBytes) + } + if devices[1].StorageType != "HDD" { + t.Errorf("device 1 type = %q, want HDD", devices[1].StorageType) + } +} + +func Test_parseDiskutilInfoOutput_Empty(t *testing.T) { + devices := parseDiskutilInfoOutput("") + if len(devices) != 0 { + t.Errorf("expected 0 devices, got %d", len(devices)) + } +} diff --git a/pkg/cmd/register/hardware_stub.go b/pkg/cmd/register/hardware_stub.go index 37bf8eb6..e97ba9d2 100644 --- a/pkg/cmd/register/hardware_stub.go +++ b/pkg/cmd/register/hardware_stub.go @@ -1,10 +1,12 @@ -//go:build !linux && !darwin && !windows +//go:build !linux && !darwin package register +import "runtime" + // SystemHardwareProfiler is a no-op adapter for unsupported platforms. type SystemHardwareProfiler struct{} func (p *SystemHardwareProfiler) Profile() (*HardwareProfile, error) { - return &HardwareProfile{}, nil + return &HardwareProfile{Architecture: runtime.GOARCH}, nil } From b3214b4c7963c66b8c86eb68c51de8f2caa0500e Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Fri, 6 Mar 2026 11:17:26 -0800 Subject: [PATCH 6/7] save --- .github/workflows/legacy.yml | 2 +- .github/workflows/release.yml | 11 +++++++++-- .gitignore | 1 + Makefile | 30 +++++++++++++++++++++++++++++- 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/.github/workflows/legacy.yml b/.github/workflows/legacy.yml index c274dd85..f7078c97 100644 --- a/.github/workflows/legacy.yml +++ b/.github/workflows/legacy.yml @@ -65,4 +65,4 @@ jobs: go-version: '1.24.0' cache: true - name: Release test - run: make build + run: make build-cross diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 79f3e277..b0834f6e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,12 @@ on: tags: - "v*" +env: + # Go module path for Docker mount (must match go.mod module). + BREV_MODULE: github.com/brevdev/brev-cli + # goreleaser-cross image with cross-compilers for CGO (see https://goreleaser.com/limitations/cgo/#using-docker). + GOLANG_CROSS_VERSION: v1.25.7 + jobs: goreleaser: runs-on: ubuntu-22.04 @@ -17,8 +23,9 @@ jobs: with: go-version: '1.24.0' cache: true - - name: Release - run: make ci smoke-test release + + - name: CI, smoke-test, and release + run: make ci smoke-test release-cross env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} # disable until this until figure out tags diff --git a/.gitignore b/.gitignore index 1276cd3d..933f48e4 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ dist/ # .env .env .envrc +.release-env # binary brev-cli diff --git a/Makefile b/Makefile index e2256008..77cc0b29 100644 --- a/Makefile +++ b/Makefile @@ -128,7 +128,7 @@ diff: ## git diff build: ## goreleaser --snapshot --skip-publish --rm-dist build: install-tools $(call print-target) - goreleaser --snapshot --skip-publish --rm-dist + goreleaser --snapshot --skip=publish --clean .PHONY: release release: ## goreleaser --rm-dist @@ -136,6 +136,34 @@ release: install-tools $(call print-target) goreleaser --rm-dist +# Docker-based build/release using goreleaser-cross (CGO cross-compile; see https://goreleaser.com/limitations/cgo/#using-docker). +GOLANG_CROSS_VERSION ?= v1.25.7 +BREV_MODULE ?= github.com/brevdev/brev-cli + +.PHONY: build-cross +build-cross: ## run goreleaser snapshot inside goreleaser-cross Docker (CGO builds for all targets) + $(call print-target) + docker run --rm \ + -e CGO_ENABLED=1 \ + -v "$$(pwd):/go/src/$(BREV_MODULE)" \ + -w "/go/src/$(BREV_MODULE)" \ + ghcr.io/goreleaser/goreleaser-cross:$(GOLANG_CROSS_VERSION) \ + --clean --skip=validate --skip=publish + +.PHONY: release-cross +release-cross: ## run goreleaser release inside goreleaser-cross Docker. Set GITHUB_TOKEN or create .release-env. + $(call print-target) + docker run --rm \ + -e CGO_ENABLED=1 \ + -e GOPRIVATE=github.com/brevdev/* \ + -e GONOSUMDB=github.com/brevdev/* \ + -e "GITHUB_TOKEN=$$GITHUB_TOKEN" \ + -v "$$HOME/.gitconfig:/root/.gitconfig:ro" \ + -v "$$(pwd):/go/src/$(BREV_MODULE)" \ + -w "/go/src/$(BREV_MODULE)" \ + ghcr.io/goreleaser/goreleaser-cross:$(GOLANG_CROSS_VERSION) \ + release --clean; \ + .PHONY: run run: ## go run @go run -race . From 7370f4a7b0fd2118f7a41b8f32979b1cb3dba81a Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Fri, 6 Mar 2026 11:22:51 -0800 Subject: [PATCH 7/7] version 2 --- .goreleaser.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.goreleaser.yml b/.goreleaser.yml index baeb2ce2..a201ae03 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,3 +1,4 @@ +version: 2 before: hooks: - go mod download