diff --git a/.gitignore b/.gitignore index 457b57b320..b5a896bb7c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ go.work.sum tmp coverage.* +*.patch +/metrics diff --git a/Makefile b/Makefile index fe3d2a822a..9215f71cb1 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,8 @@ GOOS ?= $(shell go env GOOS) GOARCH ?= $(shell go env GOARCH) CGO_ENABLED ?= 1 +CC ?= cc +CXX ?= c++ # Cross-compilation sysroot auto-detection # On Fedora, the cross-compiler's default sysroot lacks headers. @@ -74,6 +76,24 @@ endif LDFLAGS=-ldflags "$(LD_STRIP_DEBUG_SYMBOLS) $(LD_VERSION_FLAGS)" +# Build tags (e.g., TAGS=goamdsmi) +TAGS ?= +ifneq ($(TAGS),) + BUILD_ARGS=-tags $(TAGS) +endif + +# When goamdsmi tag is used, ensure C++ stdlib is linked +# The goamdsmi library requires C++ linking +ifneq (,$(findstring goamdsmi,$(TAGS))) + BUILD_ENV_VARS=CC="$(CC)" CXX="$(CXX)" CGO_ENABLED=1 + # Preserve user CGO_CFLAGS and CGO_LDFLAGS if set, add C++ stdlib + export CGO_CFLAGS + export CGO_LDFLAGS += -lstdc++ + export CGO_CXXFLAGS=-std=c++11 +else + BUILD_ENV_VARS=CC="$(CC)" +endif + BUILD_DEBUG_ARGS ?= # Docker parameters @@ -130,7 +150,7 @@ help: ## Show this help message .PHONY: build build: ## Build binary mkdir -p $(BINARY_DIR) - GOOS=$(GOOS) GOARCH=$(GOARCH) CGO_ENABLED=$(CGO_ENABLED) CC=$(CC) \ + GOOS=$(GOOS) GOARCH=$(GOARCH) CGO_ENABLED=$(CGO_ENABLED) $(BUILD_ENV_VARS) \ $(GOBUILD) $(BUILD_ARGS) \ $(LDFLAGS) \ -o $(BINARY_DIR)/$(BINARY_NAME) \ diff --git a/cmd/kepler/main.go b/cmd/kepler/main.go index 095ebfe457..100635b5e0 100644 --- a/cmd/kepler/main.go +++ b/cmd/kepler/main.go @@ -13,7 +13,7 @@ import ( "github.com/alecthomas/kingpin/v2" "github.com/sustainable-computing-io/kepler/config" - "github.com/sustainable-computing-io/kepler/internal/device" + "github.com/sustainable-computing-io/kepler/internal/device/cpu" "github.com/sustainable-computing-io/kepler/internal/device/gpu" _ "github.com/sustainable-computing-io/kepler/internal/device/gpu/nvidia" // Register NVIDIA backend "github.com/sustainable-computing-io/kepler/internal/exporter/prometheus" @@ -130,7 +130,7 @@ Configuration func createServices(logger *slog.Logger, cfg *config.Config) ([]service.Service, error) { logger.Debug("Creating all services") - cpuPowerMeter, err := device.CreateCPUMeter(logger, cfg) + cpuPowerMeter, err := cpu.CreateCPUMeter(logger, cfg) if err != nil { return nil, fmt.Errorf("failed to create CPU power meter: %w", err) } diff --git a/compose/default/kepler/etc/kepler/config.yaml b/compose/default/kepler/etc/kepler/config.yaml index 9828d59748..d99d3d64a8 100644 --- a/compose/default/kepler/etc/kepler/config.yaml +++ b/compose/default/kepler/etc/kepler/config.yaml @@ -93,6 +93,8 @@ experimental: forceEnabled: false # DEPRECATED: set cpu.preferredMeters: ["hwmon"] instead. Emits a deprecation warning when true. zones: [] # List of zones to enable (default enable all) chipRules: [] # User-defined chip pairing rules (override/add to hardcoded defaults) + esmi: + enabled: false # Enable experimental AMD ESMI power monitoring (prepends "esmi" to cpu.preferredMeters) gpu: enabled: false # Enable experimental GPU power monitoring idlePower: 0 # GPU idle power in Watts (0 = auto-detect) diff --git a/compose/dev/kepler-dev/etc/kepler/config.yaml b/compose/dev/kepler-dev/etc/kepler/config.yaml index 07d8532d30..ff6ad15c25 100644 --- a/compose/dev/kepler-dev/etc/kepler/config.yaml +++ b/compose/dev/kepler-dev/etc/kepler/config.yaml @@ -93,6 +93,8 @@ experimental: forceEnabled: false # DEPRECATED: set cpu.preferredMeters: ["hwmon"] instead. Emits a deprecation warning when true. zones: [] # List of zones to enable (default enable all) chipRules: [] # User-defined chip pairing rules (override/add to hardcoded defaults) + esmi: + enabled: false # Enable experimental AMD ESMI power monitoring (prepends "esmi" to cpu.preferredMeters) gpu: enabled: false # Enable experimental GPU power monitoring idlePower: 0 # GPU idle power in Watts (0 = auto-detect) diff --git a/config/config.go b/config/config.go index b96517d52d..15b17a3cba 100644 --- a/config/config.go +++ b/config/config.go @@ -41,6 +41,9 @@ const ( // ExperimentalGPUFeature represents GPU power monitoring (experimental) ExperimentalGPUFeature Feature = "gpu" + + // ExperimentalEsmiFeature represents the CPU ESMI power monitoring feature + ExperimentalEsmiFeature Feature = "esmi" ) // Config represents the complete application configuration @@ -189,11 +192,17 @@ type ( DCGMEndpoint string `yaml:"dcgmEndpoint"` } + // ESMI configuration (Experimental) + Esmi struct { + Enabled *bool `yaml:"enabled"` //Development mode (capability auto detection in future) + } + // Experimental contains experimental features (no stability guarantees) Experimental struct { Platform Platform `yaml:"platform"` Hwmon Hwmon `yaml:"hwmon"` GPU ExperimentalGPU `yaml:"gpu"` + Esmi Esmi `yaml:"esmi"` } Config struct { @@ -311,6 +320,9 @@ const ( ExperimentalHwmonForceEnabledFlag = "experimental.hwmon.force-enabled" ExperimentalHwmonZonesFlag = "experimental.hwmon.zones" + // Experimental ESMI flags + ExperimentalEsmiEnabledFlag = "experimental.esmi.enabled" + // Experimental GPU flags ExperimentalGPUEnabledFlag = "experimental.gpu.enabled" ExperimentalGPUIdlePowerFlag = "experimental.gpu.idle-power" @@ -390,6 +402,10 @@ func DefaultConfig() *Config { // operators who set them today expect the legacy behavior. When both legacy // keys are set, fake takes precedence over hwmon. The legacy keys will stop // working in a future release. +// +// When experimental.esmi.enabled is set, "esmi" is prepended to the +// cpu.preferredMeters list (unless already present), making ESMI the +// first-tried CPU meter. func (c *Config) ApplyCpuMeterDeprecations(logger *slog.Logger) { switch { case ptr.Deref(c.Dev.FakeCpuMeter.Enabled, false): @@ -399,6 +415,23 @@ func (c *Config) ApplyCpuMeterDeprecations(logger *slog.Logger) { logger.Warn(`experimental.hwmon.forceEnabled is deprecated; set cpu.preferredMeters: ["hwmon"] instead`) c.Cpu.PreferredMeters = []string{"hwmon"} } + + // When ESMI is explicitly enabled, inject it as the first CPU meter to try + if c.Experimental != nil && ptr.Deref(c.Experimental.Esmi.Enabled, false) { + // Check if "esmi" is already in the list + hasEsmi := false + for _, m := range c.Cpu.PreferredMeters { + if m == "esmi" { + hasEsmi = true + break + } + } + if !hasEsmi { + // Prepend "esmi" to try it first + c.Cpu.PreferredMeters = append([]string{"esmi"}, c.Cpu.PreferredMeters...) + logger.Info("esmi CPU meter enabled", "preferredMeters", c.Cpu.PreferredMeters) + } + } } // Load loads configuration from an io.Reader @@ -501,6 +534,9 @@ func RegisterFlags(app *kingpin.Application) ConfigUpdaterFn { hwmonForceEnabled := app.Flag(ExperimentalHwmonForceEnabledFlag, "Force hwmon as the power meter, skipping RAPL auto-detection").Default("false").Bool() hwmonZones := app.Flag(ExperimentalHwmonZonesFlag, "Hwmon zone filter (power labels to monitor)").Strings() + // experimental ESMI + esmiEnabled := app.Flag(ExperimentalEsmiEnabledFlag, "Enable experimental ESMI power monitoring").Default("false").Bool() + // experimental GPU gpuEnabled := app.Flag(ExperimentalGPUEnabledFlag, "Enable experimental GPU power monitoring").Default("false").Bool() gpuIdlePower := app.Flag(ExperimentalGPUIdlePowerFlag, "GPU idle power in Watts (0 = auto-detect from idle observations)").Default("0").Float64() @@ -578,6 +614,15 @@ func RegisterFlags(app *kingpin.Application) ConfigUpdaterFn { return err } + // Apply experimental ESMI settings + if flagsSet[ExperimentalEsmiEnabledFlag] { + // Initialize experimental section if needed + if cfg.Experimental == nil { + cfg.Experimental = &Experimental{} + } + cfg.Experimental.Esmi.Enabled = esmiEnabled + } + // Apply experimental GPU settings applyGPUConfig(cfg, flagsSet, gpuEnabled, gpuIdlePower, gpuDCGMEndpoint) @@ -785,6 +830,11 @@ func (c *Config) IsFeatureEnabled(feature Feature) bool { return ptr.Deref(c.Exporter.Stdout.Enabled, false) case PprofFeature: return ptr.Deref(c.Debug.Pprof.Enabled, false) + case ExperimentalEsmiFeature: + if c.Experimental == nil { + return false + } + return ptr.Deref(c.Experimental.Esmi.Enabled, false) case ExperimentalGPUFeature: if c.Experimental == nil { return false @@ -811,6 +861,11 @@ func (c *Config) experimentalFeatureEnabled() bool { return true } + // Check if ESMI is enabled + if ptr.Deref(c.Experimental.Esmi.Enabled, false) { + return true + } + // Check if GPU is enabled if ptr.Deref(c.Experimental.GPU.Enabled, false) { return true @@ -924,15 +979,16 @@ func (c *Config) Validate(skips ...SkipValidation) error { } } { // cpu.preferredMeters - // Keep this list in sync with the switch in internal/device/cpu_power_meter.go. + // Keep this list in sync with the switch in internal/device/cpu/cpu_power_meter.go. validCpuMeters := map[string]bool{ "rapl": true, "hwmon": true, + "esmi": true, "fake": true, } for _, name := range c.Cpu.PreferredMeters { if !validCpuMeters[name] { - errs = append(errs, fmt.Sprintf("invalid cpu.preferredMeters entry %q, must be one of %q, %q, %q", name, "rapl", "hwmon", "fake")) + errs = append(errs, fmt.Sprintf("invalid cpu.preferredMeters entry %q, must be one of %q, %q, %q, %q", name, "rapl", "hwmon", "esmi", "fake")) } } } diff --git a/config/config_cpu_test.go b/config/config_cpu_test.go index e8c32c48b5..8a1a379763 100644 --- a/config/config_cpu_test.go +++ b/config/config_cpu_test.go @@ -59,6 +59,23 @@ func TestCpuPreferredMeters(t *testing.T) { }, want: []string{"rapl"}, }, + { + name: "esmi enabled prepends to preferredMeters", + setup: func(c *Config) { + c.Experimental = &Experimental{} + c.Experimental.Esmi.Enabled = ptr.To(true) + }, + want: []string{"esmi", "rapl", "hwmon"}, + }, + { + name: "esmi already in list: no duplication", + setup: func(c *Config) { + c.Cpu.PreferredMeters = []string{"esmi", "rapl"} + c.Experimental = &Experimental{} + c.Experimental.Esmi.Enabled = ptr.To(true) + }, + want: []string{"esmi", "rapl"}, + }, } logger := slog.New(slog.DiscardHandler) @@ -81,7 +98,7 @@ func TestCpuPreferredMetersValidation(t *testing.T) { }{ { name: "all known backends", - meters: []string{"rapl", "hwmon", "fake"}, + meters: []string{"rapl", "hwmon", "esmi", "fake"}, }, { name: "unknown backend", diff --git a/docs/user/configuration.md b/docs/user/configuration.md index ea78145de7..3ea46bc67e 100644 --- a/docs/user/configuration.md +++ b/docs/user/configuration.md @@ -38,6 +38,7 @@ You can configure Kepler by passing flags when starting the service. The followi | `--experimental.platform.redfish.config-file` | Path to experimental Redfish BMC configuration file | `""` | Any valid file path | | `--experimental.hwmon.force-enabled` | Force hwmon as power meter, skipping RAPL auto-detection | `false` | `true`, `false` | | `--experimental.hwmon.zones` | hwmon zones to be enabled (can be specified multiple times) | All available zones | Any valid hwmon zone name | +| `--experimental.esmi.enabled` | Enable experimental AMD ESMI power monitoring for AMD EPYC CPUs | `false` | `true`, `false` | | `--experimental.gpu.enabled` | Enable experimental GPU power monitoring | `false` | `true`, `false` | | `--experimental.gpu.idle-power` | GPU idle power in Watts (0 = auto-detect) | `0` | Any non-negative float | | `--experimental.gpu.dcgm-endpoint` | dcgm-exporter metrics endpoint for MIG power attribution | `""` (auto-discover) | URL (e.g., `http://10.0.0.1:9400/metrics`) | @@ -249,6 +250,7 @@ Built-in backends: - `rapl`: Intel RAPL via sysfs (default first choice) - `hwmon`: hwmon power sensors (default second choice) +- `esmi`: AMD ESMI (E-SMI) for AMD EPYC processors (experimental, opt-in) - `fake`: synthetic readings for development and testing Examples: @@ -444,6 +446,8 @@ experimental: forceEnabled: false zones: [] chipRules: [] + esmi: + enabled: false gpu: enabled: false ``` @@ -546,6 +550,26 @@ experimental: skipVoltages: [0] # Skip shunt voltage at in0 ``` +#### ESMI CPU Power Monitoring (AMD) + +- **enabled**: Enable experimental AMD ESMI power monitoring (default: false) + - When enabled, Kepler will prepend `esmi` to `cpu.preferredMeters`, making it the first-tried CPU power source + - Collects power metrics from AMD EPYC processors via E-SMI library or sysfs + - Provides per-socket energy (package-N), per-thread energy (core-N), and per-socket DIMM power (dimm-N) + - Supports two backends: + - **goamdsmi**: Uses E-SMI C library (`libe_smi64.so`) - requires build with `-tags goamdsmi` + - **sysfs**: Pure Go fallback using `amd_energy` kernel module (default, no dependencies) + +**Example:** + +```yaml +experimental: + esmi: + enabled: true +``` + +When enabled, this configuration automatically prepends `esmi` to the CPU power meter priority list, so on AMD EPYC systems with ESMI support, it becomes the first choice before RAPL or hwmon. + #### GPU Power Monitoring - **enabled**: Enable experimental GPU power monitoring (default: false) diff --git a/hack/config.yaml b/hack/config.yaml index 636c8a9e61..154b10496c 100644 --- a/hack/config.yaml +++ b/hack/config.yaml @@ -36,6 +36,7 @@ cpu: # Ordered preference list of CPU power-meter backends. The first backend # that initializes successfully and reports zones is used. # Built-in backends: rapl, hwmon, fake. + # To enable ESMI (AMD CPU power monitoring), set experimental.esmi.enabled: true preferredMeters: [rapl, hwmon] rapl: @@ -111,3 +112,5 @@ experimental: enabled: false # Enable experimental GPU power monitoring idlePower: 0 # GPU idle power in Watts (0 = auto-detect) dcgmEndpoint: "" # dcgm-exporter metrics URL for MIG (auto-discovered if empty) + esmi: + enabled: false # Enable experimental AMD ESMI CPU power monitoring (AMD EPYC platforms) diff --git a/internal/device/cpu/cpu_power_meter.go b/internal/device/cpu/cpu_power_meter.go new file mode 100644 index 0000000000..0f53f55c95 --- /dev/null +++ b/internal/device/cpu/cpu_power_meter.go @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2025 The Kepler Authors +// SPDX-License-Identifier: Apache-2.0 + +package cpu + +import ( + "github.com/sustainable-computing-io/kepler/internal/device" +) + +// CPUPowerMeter is the interface for CPU power measurement. +// It embeds PowerMeter and adds CPU-specific methods. +type CPUPowerMeter interface { + device.PowerMeter + + // Zones() returns a slice of the energy measurement zones + Zones() ([]device.EnergyZone, error) + + // PrimaryEnergyZone() returns the zone with the highest energy coverage/priority + // This zone represents the most comprehensive energy measurement available + // E.g. Psys > Package > Core > DRAM > Uncore + PrimaryEnergyZone() (device.EnergyZone, error) +} diff --git a/internal/device/cpu_power_meter.go b/internal/device/cpu/create_meter.go similarity index 67% rename from internal/device/cpu_power_meter.go rename to internal/device/cpu/create_meter.go index 1726584620..cd107d82e1 100644 --- a/internal/device/cpu_power_meter.go +++ b/internal/device/cpu/create_meter.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 The Kepler Authors // SPDX-License-Identifier: Apache-2.0 -package device +package cpu import ( "errors" @@ -9,49 +9,9 @@ import ( "log/slog" "github.com/sustainable-computing-io/kepler/config" + "github.com/sustainable-computing-io/kepler/internal/device/cpu/esmi" ) -// EnergyZone represents a measurable energy or power zone/domain exposed by a power meter. -// An EnergyZone typically represents a logical zone of the hardware unit, e.g. cpu core, cpu package -// dram, uncore etc. -// Reference: https://firefox-source-docs.mozilla.org/performance/power_profiling_overview.html -type EnergyZone interface { - // Name() returns the zone name - Name() string - - // Index() returns the index of the zone - Index() int - - // Path() returns the path from which the energy usage value ie being read - Path() string - - // Energy() returns energy consumed by the zone. - Energy() (Energy, error) - - // MaxEnergy returns the maximum value of energy usage that can be read. - // When energy usage reaches this value, the energy value returned by Energy() - // will wrap around and start again from zero. - MaxEnergy() Energy - - // Power() returns the current power consumption by the zone. - // This method is used for zones that provide instantaneous power readings. - Power() (Power, error) -} - -// CPUPowerMeter is the interface for CPU power measurement. -// It embeds PowerMeter and adds CPU-specific methods. -type CPUPowerMeter interface { - PowerMeter - - // Zones() returns a slice of the energy measurement zones - Zones() ([]EnergyZone, error) - - // PrimaryEnergyZone() returns the zone with the highest energy coverage/priority - // This zone represents the most comprehensive energy measurement available - // E.g. Psys > Package > Core > DRAM > Uncore - PrimaryEnergyZone() (EnergyZone, error) -} - // CreateCPUMeter walks cfg.Cpu.PreferredMeters in preference order, builds // each backend, runs Init(), and returns the first meter that reports zones. // @@ -134,6 +94,12 @@ func buildCPUMeter(name string, logger *slog.Logger, cfg *config.Config) (CPUPow WithHwmonChipRules(rules), ) + case "esmi": + return esmi.NewCPUPowerMeter( + cfg.Host.SysFS, + esmi.WithLogger(logger), + ) + case "fake": return NewFakeCPUMeter(cfg.Dev.FakeCpuMeter.Zones, WithFakeLogger(logger)) diff --git a/internal/device/cpu/esmi/backends_default.go b/internal/device/cpu/esmi/backends_default.go new file mode 100644 index 0000000000..b7dc915519 --- /dev/null +++ b/internal/device/cpu/esmi/backends_default.go @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2025 The Kepler Authors +// SPDX-License-Identifier: Apache-2.0 + +//go:build !goamdsmi + +package esmi + +// availableReaders returns the ordered list of backend candidates when the +// goamdsmi build tag is NOT active (default / CI build). +// +// Only the sysfs backend is available in this configuration. +// To also enable the goamdsmi backend, build with: -tags goamdsmi +func availableReaders(sysfsPath string) []backendCandidate { + return []backendCandidate{ + sysfsBackend(sysfsPath), + } +} diff --git a/internal/device/cpu/esmi/backends_goamdsmi.go b/internal/device/cpu/esmi/backends_goamdsmi.go new file mode 100644 index 0000000000..e4a8ded565 --- /dev/null +++ b/internal/device/cpu/esmi/backends_goamdsmi.go @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2025 The Kepler Authors +// SPDX-License-Identifier: Apache-2.0 + +//go:build goamdsmi + +package esmi + +// availableReaders returns all backend candidates when the goamdsmi build tag +// is active. +// +// Primary backends (mutually exclusive — first working one wins): +// 1. goamdsmi — socket + core energy counters, instantaneous socket power +// 2. sysfs — pure-Go socket + core energy counters via amd_energy driver +// +// Additive backends (always attempted, zones merged alongside primary): +// - dimm — per-DIMM instantaneous power via e-smi C library +func availableReaders(sysfsPath string) []backendCandidate { + return []backendCandidate{ + goamdsmiBackend(), + {name: "dimm", reader: NewDimmReader(), additive: true}, + sysfsBackend(sysfsPath), + } +} diff --git a/internal/device/cpu/esmi/dimm_reader.go b/internal/device/cpu/esmi/dimm_reader.go new file mode 100644 index 0000000000..de4c53f221 --- /dev/null +++ b/internal/device/cpu/esmi/dimm_reader.go @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: 2025 The Kepler Authors +// SPDX-License-Identifier: Apache-2.0 + +//go:build goamdsmi + +package esmi + +// DimmReader provides per-DIMM power readings via the AMD E-SMI C library +// (libe_smi64.so) through CGo. It is compiled only with -tags goamdsmi. +// +// The E-SMI library exposes esmi_dimm_power_consumption_get(socket, dimmAddr, *power) +// which returns instantaneous DIMM power in milliwatts. DIMM addresses are +// enumerated per-socket; the library uses the SPD address (0x80–0x8b). +// +// Unlike the energy-counter backends, DimmReader zones only support Power() +// (instantaneous mW → W). Energy() returns an error directing callers to +// integrate Power() readings over time. +// +// Build requirements: +// The E-SMI library (libe_smi64.so) and headers must be installed. +// If installed in a non-standard location (e.g., /opt/e-sms/e_smi/), +// update the CGO directives below with the correct paths. + +/* +#cgo CFLAGS: -I/opt/e-sms/e_smi/include +#cgo LDFLAGS: -L/opt/e-sms/e_smi/lib -le_smi64 + +#include +#include + +// esmi_dimm_power_read is a thin C helper that calls +// esmi_dimm_power_consumption_get and returns the power in milliwatts, +// or UINT32_MAX on any error. +static uint32_t esmi_dimm_power_read(uint32_t socket, uint8_t dimm_addr) { + struct dimm_power dp = {0}; + esmi_status_t ret = esmi_dimm_power_consumption_get(socket, dimm_addr, &dp); + if (ret != ESMI_SUCCESS) { + return UINT32_MAX; + } + return dp.power; // milliwatts +} + +// esmi_num_sockets returns the number of sockets or 0 on error. +static uint32_t esmi_num_sockets(void) { + uint32_t n = 0; + esmi_number_of_sockets_get(&n); + return n; +} +*/ +import "C" + +import ( + "fmt" + + device "github.com/sustainable-computing-io/kepler/internal/device" +) + +// Standard SPD DIMM addresses on the SMBus: 0x80 through 0x8b +// (up to 8 DIMMs per socket channel). +var dimmAddrs = []uint8{0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x8b} + +const dimmPowerFailure = uint32(0xFFFFFFFF) + +// ── DimmReader ──────────────────────────────────────────────────────────────── + +// DimmReader enumerates all responding DIMM addresses across all sockets and +// returns one EsmiDimmZone per DIMM that responds to a power query. +type DimmReader struct{} + +// NewDimmReader returns a DimmReader. +func NewDimmReader() *DimmReader { return &DimmReader{} } + +// Zones initialises the e-smi library and probes all known DIMM SPD addresses +// on every socket. Returns one aggregated zone per socket that sums power from +// all responding DIMMs on that socket. +func (r *DimmReader) Zones() ([]device.EnergyZone, error) { + if !esmiInit() { + // Library or driver not available — return nil so the caller can + // skip this reader without treating it as a hard error. + return nil, nil + } + + numSockets := int(C.esmi_num_sockets()) + if numSockets == 0 { + return nil, nil + } + + var zones []device.EnergyZone + for socket := 0; socket < numSockets; socket++ { + // Collect all responding DIMM addresses for this socket + var dimmAddresses []uint8 + for _, addr := range dimmAddrs { + pw := uint32(C.esmi_dimm_power_read(C.uint32_t(socket), C.uint8_t(addr))) + if pw != dimmPowerFailure { + dimmAddresses = append(dimmAddresses, addr) + } + } + + // Only create a zone if at least one DIMM responded + if len(dimmAddresses) > 0 { + zones = append(zones, &EsmiDimmZone{ + socketIdx: socket, + dimmAddresses: dimmAddresses, + }) + } + } + return zones, nil +} + +// ── EsmiDimmZone ────────────────────────────────────────────────────────────── + +// EsmiDimmZone implements device.EnergyZone for aggregated DIMM power per socket. +// It sums power from all responding DIMMs on the socket. +// Only Power() is supported; Energy() returns an error. +type EsmiDimmZone struct { + socketIdx int + dimmAddresses []uint8 +} + +func (z *EsmiDimmZone) Name() string { + return fmt.Sprintf("dimm-%d", z.socketIdx) +} +func (z *EsmiDimmZone) Index() int { return z.socketIdx } +func (z *EsmiDimmZone) Path() string { + return fmt.Sprintf("esmi://cpu/socket/%d/dimm", z.socketIdx) +} + +// Energy is not supported for DIMM zones. The E-SMI library exposes only +// instantaneous power; integrate Power() readings over time to get energy. +func (z *EsmiDimmZone) Energy() (device.Energy, error) { + return 0, fmt.Errorf("esmi/dimm: DIMM zones do not provide energy counters; integrate Power() readings over time") +} + +// MaxEnergy returns 0 (not applicable). +func (z *EsmiDimmZone) MaxEnergy() device.Energy { return 0 } + +// Power returns the aggregated instantaneous DIMM power for all DIMMs on this +// socket in watts. The E-SMI library returns milliwatts per DIMM. +func (z *EsmiDimmZone) Power() (device.Power, error) { + var totalPowerMw uint64 + var readErrors int + + for _, addr := range z.dimmAddresses { + pw := uint32(C.esmi_dimm_power_read(C.uint32_t(z.socketIdx), C.uint8_t(addr))) + if pw == dimmPowerFailure { + readErrors++ + continue + } + totalPowerMw += uint64(pw) + } + + // Return error only if ALL DIMMs failed to read + if readErrors == len(z.dimmAddresses) { + return 0, fmt.Errorf("esmi/dimm: all DIMM reads failed for socket %d", z.socketIdx) + } + + return device.Power(float64(totalPowerMw) / 1000.0), nil +} + +// ── backend registration ────────────────────────────────────────────────────── + +// dimmBackend returns a backend candidate for DIMM power readings. +// It is included in availableReaders() alongside the goamdsmi backend. +func dimmBackend() backendCandidate { + return backendCandidate{name: "dimm", reader: NewDimmReader()} +} diff --git a/internal/device/cpu/esmi/esmi_init.go b/internal/device/cpu/esmi/esmi_init.go new file mode 100644 index 0000000000..b7aa914999 --- /dev/null +++ b/internal/device/cpu/esmi/esmi_init.go @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2025 The Kepler Authors +// SPDX-License-Identifier: Apache-2.0 + +//go:build goamdsmi + +package esmi + +/* +#cgo CFLAGS: -I/opt/e-sms/e_smi/include +#cgo LDFLAGS: -L/opt/e-sms/e_smi/lib -le_smi64 + +#include +#include + +static int esmi_init_wrapper(void) { + return (int)esmi_init(); +} +*/ +import "C" +import "sync" + +// Shared E-SMI library initialization state. +// The E-SMI library must be initialized only once per process. +var ( + esmiInitOnce sync.Once + esmiInitOk bool +) + +// esmiInit ensures the E-SMI library is initialized exactly once. +// Returns true if initialization succeeded (or was already successful). +func esmiInit() bool { + esmiInitOnce.Do(func() { + esmiInitOk = (C.esmi_init_wrapper() == 0) + }) + return esmiInitOk +} diff --git a/internal/device/cpu/esmi/goamdsmi_reader.go b/internal/device/cpu/esmi/goamdsmi_reader.go new file mode 100644 index 0000000000..f24d9c1af0 --- /dev/null +++ b/internal/device/cpu/esmi/goamdsmi_reader.go @@ -0,0 +1,195 @@ +// SPDX-FileCopyrightText: 2025 The Kepler Authors +// SPDX-License-Identifier: Apache-2.0 + +//go:build goamdsmi + +package esmi + +// This file is only compiled when built with -tags goamdsmi. +// It requires: +// - E-SMI C library (libe_smi64.so) installed (typically /opt/e-sms/e_smi/) +// - amd_hsmp kernel driver loaded +// +// Zone types provided by this backend: +// - socket : accumulated energy (µJ) + instantaneous power (W) per socket +// - core : accumulated energy (µJ) per logical thread/core + +/* +#cgo CFLAGS: -I/opt/e-sms/e_smi/include +#cgo LDFLAGS: -L/opt/e-sms/e_smi/lib -le_smi64 + +#include +#include + +// Wrapper functions to safely call E-SMI library functions +static uint32_t esmi_num_sockets(void) { + uint32_t n = 0; + esmi_number_of_sockets_get(&n); + return n; +} + +static uint32_t esmi_num_cpus(void) { + uint32_t n = 0; + esmi_number_of_cpus_get(&n); + return n; +} + +static uint32_t esmi_threads_per_core_get_safe(void) { + uint32_t n = 0; + esmi_threads_per_core_get(&n); + return n; +} + +// Returns 0 on success, non-zero on failure +static int esmi_socket_energy_read(uint32_t socket_idx, uint64_t *energy) { + return (int)esmi_socket_energy_get(socket_idx, energy); +} + +// Returns 0 on success, non-zero on failure +static int esmi_socket_power_read(uint32_t socket_idx, uint32_t *power) { + return (int)esmi_socket_power_get(socket_idx, power); +} + +// Returns 0 on success, non-zero on failure +static int esmi_core_energy_read(uint32_t core_ind, uint64_t *energy) { + return (int)esmi_core_energy_get(core_ind, energy); +} +*/ +import "C" + +import ( + "fmt" + + device "github.com/sustainable-computing-io/kepler/internal/device" +) + +// ── GoamdsmiReader ──────────────────────────────────────────────────────────── + +// GoamdsmiReader implements Reader using the AMD E-SMI C library. +// It returns one "socket" zone per physical CPU socket and one "core" zone +// per logical thread exposed by the library. +type GoamdsmiReader struct{} + +// NewGoamdsmiReader returns a GoamdsmiReader. +func NewGoamdsmiReader() *GoamdsmiReader { return &GoamdsmiReader{} } + +// Zones initialises the E-SMI library and returns all available +// energy zones. Returns nil (no error) when the library cannot initialise so +// NewCPUPowerMeter falls through to the next backend. +func (r *GoamdsmiReader) Zones() ([]device.EnergyZone, error) { + if !esmiInit() { + return nil, nil + } + + var zones []device.EnergyZone + + // ── socket zones ───────────────────────────────────────────────────────── + numSockets := int(C.esmi_num_sockets()) + for i := 0; i < numSockets; i++ { + var energy C.uint64_t + if C.esmi_socket_energy_read(C.uint32_t(i), &energy) != 0 { + continue + } + zones = append(zones, &GoamdsmiPackageZone{socketIdx: i}) + } + + // ── core zones ──────────────────────────────────────────────────────────── + // The library indexes cores by CPU (logical thread index). + numCPUs := int(C.esmi_num_cpus()) + threadsPerCore := int(C.esmi_threads_per_core_get_safe()) + if threadsPerCore < 1 { + threadsPerCore = 1 + } + for i := 0; i < numCPUs; i++ { + var energy C.uint64_t + if C.esmi_core_energy_read(C.uint32_t(i), &energy) != 0 { + continue + } + zones = append(zones, &GoamdsmiCoreZone{ + threadIdx: i, + coreIdx: i / threadsPerCore, + }) + } + + return zones, nil +} + +// ── GoamdsmiPackageZone ──────────────────────────────────────────────────────── + +// GoamdsmiPackageZone implements device.EnergyZone for one AMD CPU socket. +// Uniquely among ESMI zones, it also supports Power() for instantaneous watts. +type GoamdsmiPackageZone struct { + socketIdx int +} + +func (z *GoamdsmiPackageZone) Name() string { + return fmt.Sprintf("package-%d", z.socketIdx) +} +func (z *GoamdsmiPackageZone) Index() int { return z.socketIdx } +func (z *GoamdsmiPackageZone) Path() string { + return fmt.Sprintf("esmi://cpu/socket/%d", z.socketIdx) +} + +// Energy returns accumulated socket energy in microjoules. +func (z *GoamdsmiPackageZone) Energy() (device.Energy, error) { + var energy C.uint64_t + if C.esmi_socket_energy_read(C.uint32_t(z.socketIdx), &energy) != 0 { + return 0, fmt.Errorf("esmi: esmi_socket_energy_get failed for socket %d", z.socketIdx) + } + return device.Energy(energy), nil +} + +// MaxEnergy returns 0 — the library does not expose a counter maximum. +func (z *GoamdsmiPackageZone) MaxEnergy() device.Energy { return 0 } + +// Power returns instantaneous socket power in watts. +// esmi_socket_power_get returns milliwatts. +func (z *GoamdsmiPackageZone) Power() (device.Power, error) { + var power C.uint32_t + if C.esmi_socket_power_read(C.uint32_t(z.socketIdx), &power) != 0 { + return 0, fmt.Errorf("esmi: esmi_socket_power_get failed for socket %d", z.socketIdx) + } + return device.Power(float64(power) / 1000.0), nil +} + +// ── GoamdsmiCoreZone ────────────────────────────────────────────────────────── + +// GoamdsmiCoreZone implements device.EnergyZone for a single logical core +// (SMT thread). Energy is cumulative µJ; instantaneous power is not available +// at per-core granularity via this library. +type GoamdsmiCoreZone struct { + threadIdx int // library index (SMT thread) + coreIdx int // physical core index (threadIdx / threadsPerCore) +} + +func (z *GoamdsmiCoreZone) Name() string { + return fmt.Sprintf("core-%d", z.threadIdx) +} +func (z *GoamdsmiCoreZone) Index() int { return z.threadIdx } +func (z *GoamdsmiCoreZone) Path() string { + return fmt.Sprintf("esmi://cpu/core/%d", z.threadIdx) +} + +// Energy returns accumulated core energy in microjoules. +func (z *GoamdsmiCoreZone) Energy() (device.Energy, error) { + var energy C.uint64_t + if C.esmi_core_energy_read(C.uint32_t(z.threadIdx), &energy) != 0 { + return 0, fmt.Errorf("esmi: esmi_core_energy_get failed for thread %d", z.threadIdx) + } + return device.Energy(energy), nil +} + +// MaxEnergy returns 0 — no counter maximum is exposed. +func (z *GoamdsmiCoreZone) MaxEnergy() device.Energy { return 0 } + +// Power is not available at per-core granularity via E-SMI. +// Use socket-level Power() or derive from successive Energy() readings. +func (z *GoamdsmiCoreZone) Power() (device.Power, error) { + return 0, fmt.Errorf("esmi: per-core instantaneous power is not available; derive from successive Energy() calls") +} + +// ── backend registration ────────────────────────────────────────────────────── + +func goamdsmiBackend() backendCandidate { + return backendCandidate{name: "goamdsmi", reader: NewGoamdsmiReader()} +} diff --git a/internal/device/cpu/esmi/power_meter.go b/internal/device/cpu/esmi/power_meter.go new file mode 100644 index 0000000000..255a053946 --- /dev/null +++ b/internal/device/cpu/esmi/power_meter.go @@ -0,0 +1,370 @@ +// SPDX-FileCopyrightText: 2025 The Kepler Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package esmi implements a CPUPowerMeter for AMD processors. +// +// Two primary backends are supported and selected automatically at runtime: +// +// 1. goamdsmi (priority 1) — Go bindings over the AMD e-smi / HSMP C library +// (github.com/ROCm/amdsmi). Provides socket and core energy counters plus +// instantaneous socket power. Only compiled with the "goamdsmi" build tag. +// +// 2. sysfs amd_energy (priority 2) — pure-Go, zero CGo. Reads hwmon counters +// from the amd_energy kernel module (CONFIG_AMD_ENERGY, Linux ≥ 5.8). +// Provides socket and core energy counters only. +// +// One additive backend is always attempted independently: +// +// - dimm — per-DIMM instantaneous power via the e-smi C library +// (esmi_get_dimm_power). Compiled only with the "goamdsmi" build tag. +// DIMM zones expose Power() only; Energy() returns an error. +// +// NewCPUPowerMeter selects the first primary backend that works, then merges +// in any DIMM zones from the dimm backend (if available). Zone types: +// +// package – highest priority for PrimaryEnergyZone(); normalized from AMD +// "socket" to match RAPL naming; supports Power() via goamdsmi backend +// core – per-logical-thread energy +// dimm – per-DIMM power (Power() only, no Energy() counter) +package esmi + +import ( + "fmt" + "log/slog" + "strings" + + device "github.com/sustainable-computing-io/kepler/internal/device" +) + +// PowerMeter implements device.CPUPowerMeter for AMD CPUs. +type PowerMeter struct { + reader Reader + cachedZones []device.EnergyZone + logger *slog.Logger + zoneFilter []string + topZone device.EnergyZone + backendName string +} + +// OptionFn is a functional option for PowerMeter. +type OptionFn func(*PowerMeter) + +// WithReader forces a specific Reader backend, bypassing auto-detection. +// Primarily useful in tests. +func WithReader(r Reader) OptionFn { + return func(pm *PowerMeter) { pm.reader = r } +} + +// WithLogger sets a structured logger on the meter. +func WithLogger(logger *slog.Logger) OptionFn { + return func(pm *PowerMeter) { pm.logger = logger.With("service", "esmi") } +} + +// WithZoneFilter restricts monitoring to the named zone types. +// Valid names: "package", "core", "dimm", "l3cache". Empty means all zones are included. +// Note: "package" is the normalized name for AMD "socket" zones to align with RAPL. +func WithZoneFilter(zones []string) OptionFn { + return func(pm *PowerMeter) { pm.zoneFilter = zones } +} + +// NewCPUPowerMeter constructs a PowerMeter, auto-detecting the best available +// backends. sysfsPath is the sysfs root (normally "/sys"). +func NewCPUPowerMeter(sysfsPath string, opts ...OptionFn) (*PowerMeter, error) { + pm := &PowerMeter{ + logger: slog.Default().With("service", "esmi"), + zoneFilter: []string{}, + } + for _, opt := range opts { + opt(pm) + } + + // WithReader injected a backend (e.g. in tests) — use it directly. + if pm.reader != nil { + pm.backendName = "injected" + return pm, nil + } + + candidates := availableReaders(sysfsPath) + + // Split candidates into primary (energy-capable) and additive (power-only). + var primaryCandidates, additiveCandidates []backendCandidate + for _, c := range candidates { + if c.additive { + additiveCandidates = append(additiveCandidates, c) + } else { + primaryCandidates = append(primaryCandidates, c) + } + } + + // Select the first working primary backend. + var primaryReader Reader + for _, candidate := range primaryCandidates { + zones, err := candidate.reader.Zones() + if err != nil { + pm.logger.Debug("esmi primary backend failed", + "backend", candidate.name, "err", err) + continue + } + if len(zones) == 0 { + pm.logger.Debug("esmi primary backend found no zones", + "backend", candidate.name) + continue + } + // Probe the first zone for either Energy() or Power() support + _, errEnergy := zones[0].Energy() + _, errPower := zones[0].Power() + if errEnergy != nil && errPower != nil { + pm.logger.Debug("esmi primary backend probe failed", + "backend", candidate.name, + "energy_err", errEnergy, + "power_err", errPower) + continue + } + primaryReader = candidate.reader + pm.backendName = candidate.name + pm.logger.Info("esmi primary backend selected", "backend", candidate.name) + break + } + + if primaryReader == nil { + return nil, fmt.Errorf( + "esmi: no working primary backend found; " + + "load the amd_energy kernel module or install libamd_smi.so " + + "and rebuild with -tags goamdsmi", + ) + } + + // Probe additive backends (e.g. DIMM) and merge their zones. + // These are probed via Power() rather than Energy(). + var additiveReaders []Reader + for _, candidate := range additiveCandidates { + zones, err := candidate.reader.Zones() + if err != nil || len(zones) == 0 { + pm.logger.Debug("esmi additive backend unavailable", + "backend", candidate.name, "err", err) + continue + } + if _, err := zones[0].Power(); err != nil { + pm.logger.Debug("esmi additive backend probe failed", + "backend", candidate.name, "err", err) + continue + } + additiveReaders = append(additiveReaders, candidate.reader) + pm.logger.Info("esmi additive backend available", "backend", candidate.name) + } + + if len(additiveReaders) == 0 { + pm.reader = primaryReader + } else { + pm.reader = &mergedReader{ + primary: primaryReader, + additive: additiveReaders, + } + } + + return pm, nil +} + +// backendCandidate pairs a display name with a Reader for the detection loop. +type backendCandidate struct { + name string + reader Reader + // additive marks backends whose zones supplement (rather than replace) the + // primary backend. Additive zones are probed via Power(), not Energy(). + additive bool +} + +// mergedReader combines a primary Reader with one or more additive Readers. +type mergedReader struct { + primary Reader + additive []Reader +} + +func (m *mergedReader) Zones() ([]device.EnergyZone, error) { + zones, err := m.primary.Zones() + if err != nil { + return nil, err + } + for _, r := range m.additive { + extra, err := r.Zones() + if err != nil { + continue // additive failures are non-fatal + } + zones = append(zones, extra...) + } + return zones, nil +} + +// Reader enumerates ESMI energy zones. +type Reader interface { + Zones() ([]device.EnergyZone, error) +} + +// ── device.CPUPowerMeter interface ─────────────────────────────────────────── + +// Name identifies the meter and the active backend. +func (pm *PowerMeter) Name() string { return "esmi/" + pm.backendName } + +// Init validates that the active backend can enumerate and read at least one zone. +func (pm *PowerMeter) Init() error { + zones, err := pm.reader.Zones() + if err != nil { + return fmt.Errorf("esmi: failed to enumerate zones: %w", err) + } + if len(zones) == 0 { + return fmt.Errorf("esmi: no energy zones found (backend: %s)", pm.backendName) + } + // Find the first energy-capable or power-capable zone to probe. + for _, z := range zones { + // Try Energy() first (preferred) + if _, err = z.Energy(); err == nil { + return nil + } + // Fall back to Power() for power-only zones (e.g., HSMP) + if _, err = z.Power(); err == nil { + return nil + } + } + return fmt.Errorf("esmi: no readable energy or power zone found (backend: %s)", pm.backendName) +} + +// Zones returns the filtered and aggregated list of energy zones. +// Results are cached after the first successful call. +func (pm *PowerMeter) Zones() ([]device.EnergyZone, error) { + if len(pm.cachedZones) != 0 { + return pm.cachedZones, nil + } + + zones, err := pm.reader.Zones() + if err != nil { + return nil, err + } + if len(zones) == 0 { + return nil, fmt.Errorf("esmi: no energy zones found") + } + + zones = pm.filterZones(zones) + if len(zones) == 0 { + return nil, fmt.Errorf("esmi: no energy zones remaining after filtering") + } + + pm.cachedZones = pm.groupZonesByName(zones) + return pm.cachedZones, nil +} + +// PrimaryEnergyZone returns the highest-priority energy-capable zone. +// Priority: package > core > l3cache > first available. +// Zone names are normalized to match RAPL conventions (socket → package). +// DIMM zones are excluded since they have no energy counter. +func (pm *PowerMeter) PrimaryEnergyZone() (device.EnergyZone, error) { + if pm.topZone != nil { + return pm.topZone, nil + } + + zones, err := pm.Zones() + if err != nil { + return nil, err + } + if len(zones) == 0 { + return nil, fmt.Errorf("esmi: no energy zones available") + } + + zoneMap := make(map[string]device.EnergyZone, len(zones)) + for _, z := range zones { + // Only index energy-capable zones for primary selection. + if _, err := z.Energy(); err == nil { + zoneMap[strings.ToLower(z.Name())] = z + } + } + + // Priority order aligned with RAPL: package > core > l3cache + for _, name := range []string{"package", "core", "l3cache"} { + if z, ok := zoneMap[name]; ok { + pm.topZone = z + return z, nil + } + } + + // Fallback: first energy-capable zone. + for _, z := range zones { + if _, err := z.Energy(); err == nil { + pm.topZone = z + return z, nil + } + } + + // Last resort: accept power-only zones (e.g., HSMP) + // Power can be integrated over time to derive energy + for _, z := range zones { + if _, err := z.Power(); err == nil { + pm.logger.Info("using power-only zone as primary", + "zone", z.Name(), + "note", "energy will be derived from power readings") + pm.topZone = z + return z, nil + } + } + + return nil, fmt.Errorf("esmi: no energy-capable or power-capable zone found") +} + +// ── internal helpers ────────────────────────────────────────────────────────── + +func (pm *PowerMeter) needsFiltering() bool { return len(pm.zoneFilter) != 0 } + +func (pm *PowerMeter) filterZones(zones []device.EnergyZone) []device.EnergyZone { + if !pm.needsFiltering() { + return zones + } + wanted := make(map[string]bool, len(pm.zoneFilter)) + for _, name := range pm.zoneFilter { + wanted[strings.ToLower(name)] = true + } + var included, excluded []string + filtered := make([]device.EnergyZone, 0, len(zones)) + for _, z := range zones { + if wanted[strings.ToLower(z.Name())] { + filtered = append(filtered, z) + included = append(included, z.Name()) + } else { + excluded = append(excluded, z.Name()) + } + } + pm.logger.Debug("filtered ESMI zones", "included", included, "excluded", excluded) + return filtered +} + +func (pm *PowerMeter) groupZonesByName(zones []device.EnergyZone) []device.EnergyZone { + groups := make(map[string][]device.EnergyZone) + for _, z := range zones { + groups[z.Name()] = append(groups[z.Name()], z) + } + result := make([]device.EnergyZone, 0, len(zones)) + + for name, grp := range groups { + if len(grp) == 1 { + // Single zone: add as-is (matches RAPL behavior for unique names like package-0, package-1) + result = append(result, grp[0]) + continue + } + + // Multiple zones with same name: create aggregated zone + // This handles cases where multiple sensors share the same name + agg, err := device.NewAggregatedZone(grp) + if err != nil { + pm.logger.Warn("failed to create aggregated zone", + "name", name, "err", err) + continue + } + result = append(result, agg) + } + return result +} + +func zoneNames(zones []device.EnergyZone) []string { + names := make([]string, len(zones)) + for i, z := range zones { + names[i] = fmt.Sprintf("%s-%d", z.Name(), z.Index()) + } + return names +} diff --git a/internal/device/cpu/esmi/power_meter_test.go b/internal/device/cpu/esmi/power_meter_test.go new file mode 100644 index 0000000000..8a4ec01d7e --- /dev/null +++ b/internal/device/cpu/esmi/power_meter_test.go @@ -0,0 +1,222 @@ +// SPDX-FileCopyrightText: 2025 The Kepler Authors +// SPDX-License-Identifier: Apache-2.0 + +package esmi + +import ( + "errors" + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/sustainable-computing-io/kepler/internal/device" +) + +// MockReader is a mock implementation of Reader for testing +type MockReader struct { + mock.Mock +} + +func (m *MockReader) Zones() ([]device.EnergyZone, error) { + args := m.Called() + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]device.EnergyZone), args.Error(1) +} + +// MockZone is a mock implementation of EnergyZone for testing +type MockZone struct { + mock.Mock + name string + index int +} + +func (m *MockZone) Index() int { + return m.index +} + +func (m *MockZone) Path() string { + args := m.Called() + return args.String(0) +} + +func (m *MockZone) Name() string { + return m.name +} + +func (m *MockZone) MaxEnergy() device.Energy { + args := m.Called() + return args.Get(0).(device.Energy) +} + +func (m *MockZone) Energy() (device.Energy, error) { + args := m.Called() + return args.Get(0).(device.Energy), args.Error(1) +} + +func (m *MockZone) Power() (device.Power, error) { + args := m.Called() + return args.Get(0).(device.Power), args.Error(1) +} + +func TestPowerMeter_Name(t *testing.T) { + mockReader := new(MockReader) + + meter := &PowerMeter{ + reader: mockReader, + logger: slog.Default(), + backendName: "test-backend", + } + + name := meter.Name() + assert.Equal(t, "esmi/test-backend", name) +} + +func TestPowerMeter_Init_Success(t *testing.T) { + mockReader := new(MockReader) + mockZone := &MockZone{name: "package-0", index: 0} + + mockZone.On("Energy").Return(device.Energy(1000*device.Joule), nil) + mockReader.On("Zones").Return([]device.EnergyZone{mockZone}, nil) + + meter := &PowerMeter{ + reader: mockReader, + logger: slog.Default(), + backendName: "test", + } + + err := meter.Init() + assert.NoError(t, err) + mockReader.AssertExpectations(t) + mockZone.AssertExpectations(t) +} + +func TestPowerMeter_Init_NoZones(t *testing.T) { + mockReader := new(MockReader) + mockReader.On("Zones").Return([]device.EnergyZone{}, nil) + + meter := &PowerMeter{ + reader: mockReader, + logger: slog.Default(), + backendName: "test", + } + + err := meter.Init() + assert.Error(t, err) + assert.Contains(t, err.Error(), "no energy zones found") + mockReader.AssertExpectations(t) +} + +func TestPowerMeter_Init_ZonesError(t *testing.T) { + mockReader := new(MockReader) + mockReader.On("Zones").Return(nil, errors.New("zones failed")) + + meter := &PowerMeter{ + reader: mockReader, + logger: slog.Default(), + backendName: "test", + } + + err := meter.Init() + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to enumerate zones") + mockReader.AssertExpectations(t) +} + +func TestPowerMeter_Zones(t *testing.T) { + mockReader := new(MockReader) + mockZone1 := &MockZone{name: "package-0", index: 0} + mockZone2 := &MockZone{name: "core-0", index: 0} + + expectedZones := []device.EnergyZone{mockZone1, mockZone2} + mockReader.On("Zones").Return(expectedZones, nil) + + meter := &PowerMeter{ + reader: mockReader, + logger: slog.Default(), + backendName: "test", + } + + zones, err := meter.Zones() + assert.NoError(t, err) + assert.Len(t, zones, 2) + mockReader.AssertExpectations(t) +} + +func TestPowerMeter_Zones_Cached(t *testing.T) { + mockReader := new(MockReader) + mockZone := &MockZone{name: "package-0", index: 0} + + expectedZones := []device.EnergyZone{mockZone} + mockReader.On("Zones").Return(expectedZones, nil).Once() + + meter := &PowerMeter{ + reader: mockReader, + logger: slog.Default(), + backendName: "test", + } + + // First call + zones1, err1 := meter.Zones() + assert.NoError(t, err1) + assert.Equal(t, expectedZones, zones1) + + // Second call should return cached zones + zones2, err2 := meter.Zones() + assert.NoError(t, err2) + assert.Equal(t, expectedZones, zones2) + + // Zones() should only be called once (caching works) + mockReader.AssertExpectations(t) +} + +func TestPowerMeter_PrimaryEnergyZone(t *testing.T) { + mockReader := new(MockReader) + mockZone1 := &MockZone{name: "core-0", index: 0} + mockZone2 := &MockZone{name: "package-0", index: 0} + + // package should be selected as primary over core + zones := []device.EnergyZone{mockZone1, mockZone2} + mockReader.On("Zones").Return(zones, nil) + + meter := &PowerMeter{ + reader: mockReader, + logger: slog.Default(), + backendName: "test", + cachedZones: zones, + topZone: mockZone2, // Pre-set the topZone to avoid probing + } + + primaryZone, err := meter.PrimaryEnergyZone() + assert.NoError(t, err) + assert.Equal(t, "package-0", primaryZone.Name()) +} + +func TestPowerMeter_WithZoneFilter(t *testing.T) { + mockReader := new(MockReader) + + meter, err := NewCPUPowerMeter("/sys", + WithReader(mockReader), + WithZoneFilter([]string{"package", "dimm"}), + ) + + assert.NoError(t, err) + assert.NotNil(t, meter) + assert.Equal(t, []string{"package", "dimm"}, meter.zoneFilter) +} + +func TestPowerMeter_WithLogger(t *testing.T) { + mockReader := new(MockReader) + customLogger := slog.Default().With("test", "value") + + meter, err := NewCPUPowerMeter("/sys", + WithReader(mockReader), + WithLogger(customLogger), + ) + + assert.NoError(t, err) + assert.NotNil(t, meter) + assert.NotNil(t, meter.logger) +} diff --git a/internal/device/cpu/esmi/sysfs_reader.go b/internal/device/cpu/esmi/sysfs_reader.go new file mode 100644 index 0000000000..a93d19b563 --- /dev/null +++ b/internal/device/cpu/esmi/sysfs_reader.go @@ -0,0 +1,290 @@ +// SPDX-FileCopyrightText: 2025 The Kepler Authors +// SPDX-License-Identifier: Apache-2.0 + +package esmi + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strconv" + "strings" + + device "github.com/sustainable-computing-io/kepler/internal/device" +) + +// ── SysfsReader ─────────────────────────────────────────────────────────────── + +// SysfsReader reads AMD energy zones from the amd_energy platform driver. +// +// The amd_energy driver (CONFIG_AMD_ENERGY, Linux ≥ 5.8) exposes hwmon-style +// attributes under: +// +// /sys/bus/platform/drivers/amd_energy/amd_energy./hwmon/hwmon/ +// energy_input – accumulated energy in µJ (uint64, wraps at max) +// energy_label – human-readable label, e.g. "socket0", "core3" +// +// A fallback via /sys/class/hwmon is tried when the platform driver path +// contains no devices (some distributions symlink the nodes differently). +type SysfsReader struct { + sysfsPath string +} + +// NewSysfsReader returns a SysfsReader rooted at sysfsPath (normally "/sys"). +func NewSysfsReader(sysfsPath string) *SysfsReader { + return &SysfsReader{sysfsPath: sysfsPath} +} + +func (r *SysfsReader) driverRoot() string { + return filepath.Join(r.sysfsPath, "bus", "platform", "drivers", "amd_energy") +} + +// Zones discovers all energy zones exposed by the amd_energy driver. +func (r *SysfsReader) Zones() ([]device.EnergyZone, error) { + driverRoot := r.driverRoot() + + deviceDirs, err := filepath.Glob(filepath.Join(driverRoot, "amd_energy.*")) + if err != nil { + return nil, fmt.Errorf("esmi/sysfs: glob driver root: %w", err) + } + + if len(deviceDirs) == 0 { + return r.zonesFromHwmonClass() + } + + var zones []device.EnergyZone + for _, dev := range deviceDirs { + zs, err := r.zonesFromDevice(dev) + if err != nil { + slog.Default().Warn("esmi/sysfs: skipping device", "path", dev, "err", err) + continue + } + zones = append(zones, zs...) + } + return zones, nil +} + +func (r *SysfsReader) zonesFromDevice(deviceDir string) ([]device.EnergyZone, error) { + hwmonDirs, err := filepath.Glob(filepath.Join(deviceDir, "hwmon", "hwmon*")) + if err != nil || len(hwmonDirs) == 0 { + return nil, fmt.Errorf("esmi/sysfs: no hwmon dir under %s", deviceDir) + } + var zones []device.EnergyZone + socketIdx := 0 + for _, hwmon := range hwmonDirs { + // For driver root path, assume amd_energy (not HSMP) + zs, err := r.zonesFromHwmonDir(hwmon, "amd_energy", &socketIdx) + if err != nil { + return nil, err + } + zones = append(zones, zs...) + } + return zones, nil +} + +func (r *SysfsReader) zonesFromHwmonClass() ([]device.EnergyZone, error) { + hwmonClass := filepath.Join(r.sysfsPath, "class", "hwmon") + + entries, err := os.ReadDir(hwmonClass) + if err != nil { + return nil, fmt.Errorf("esmi/sysfs: cannot read %s: %w", hwmonClass, err) + } + + var zones []device.EnergyZone + // Track socket index across multiple HSMP hwmon devices + socketIdx := 0 + for _, entry := range entries { + hwmonDir := filepath.Join(hwmonClass, entry.Name()) + nameBytes, err := os.ReadFile(filepath.Join(hwmonDir, "name")) + if err != nil { + continue + } + deviceName := strings.TrimSpace(string(nameBytes)) + + // Support both amd_energy (energy counters) and amd_hsmp_hwmon (power readings) + if deviceName != "amd_energy" && deviceName != "amd_hsmp_hwmon" { + continue + } + zs, err := r.zonesFromHwmonDir(hwmonDir, deviceName, &socketIdx) + if err != nil { + slog.Default().Warn("esmi/sysfs: skipping hwmon dir", "path", hwmonDir, "err", err) + continue + } + zones = append(zones, zs...) + } + + return zones, nil +} + +func (r *SysfsReader) zonesFromHwmonDir(hwmonDir, deviceName string, socketIdx *int) ([]device.EnergyZone, error) { + var zones []device.EnergyZone + + // First, try energy counters (amd_energy driver) + energyInputs, err := filepath.Glob(filepath.Join(hwmonDir, "energy*_input")) + if err != nil { + return nil, err + } + for _, inputPath := range energyInputs { + base := filepath.Base(inputPath) // "energy3_input" + trimmed := strings.TrimPrefix(base, "energy") // "3_input" + trimmed = strings.TrimSuffix(trimmed, "_input") // "3" + idx, err := strconv.Atoi(trimmed) + if err != nil { + continue + } + labelPath := filepath.Join(hwmonDir, fmt.Sprintf("energy%d_label", idx)) + label := r.readLabel(labelPath, idx) + + maxPath := filepath.Join(hwmonDir, fmt.Sprintf("energy%d_max", idx)) + maxVal := r.readUint64OrZero(maxPath) + + zones = append(zones, &SysfsEnergyZone{ + name: ZoneLabelToName(label), + index: idx, + path: inputPath, + maxEnergy: device.Energy(maxVal), + }) + } + + // If no energy counters found, try power sensors (amd_hsmp_hwmon driver) + if len(zones) == 0 { + powerInputs, err := filepath.Glob(filepath.Join(hwmonDir, "power*_input")) + if err != nil { + return nil, err + } + for _, inputPath := range powerInputs { + // For HSMP, each hwmon device represents one socket + // Use unique names (package-0, package-1) to match RAPL behavior + zones = append(zones, &SysfsPowerZone{ + name: fmt.Sprintf("package-%d", *socketIdx), + index: *socketIdx, + path: inputPath, + }) + // Increment socket index for next HSMP device + if deviceName == "amd_hsmp_hwmon" { + *socketIdx++ + } + } + } + + return zones, nil +} + +func (r *SysfsReader) readLabel(path string, fallbackIdx int) string { + b, err := os.ReadFile(path) + if err != nil { + return fmt.Sprintf("unknown%d", fallbackIdx) + } + return strings.TrimSpace(string(b)) +} + +func (r *SysfsReader) readUint64OrZero(path string) uint64 { + b, err := os.ReadFile(path) + if err != nil { + return 0 + } + v, _ := strconv.ParseUint(strings.TrimSpace(string(b)), 10, 64) + return v +} + +// ZoneLabelToName strips trailing digits from an AMD hwmon label and +// normalizes zone names to match RAPL naming conventions. +// +// "socket0" → "package" (normalized from "socket" to match RAPL) +// "core23" → "core" +// "l3cache1" → "l3cache" +func ZoneLabelToName(label string) string { + label = strings.ToLower(strings.TrimSpace(label)) + i := len(label) + for i > 0 && label[i-1] >= '0' && label[i-1] <= '9' { + i-- + } + if i == 0 { + return label + } + baseName := label[:i] + + // Normalize AMD zone names to match RAPL conventions + if baseName == "socket" { + return "package" + } + return baseName +} + +// ── SysfsEnergyZone ─────────────────────────────────────────────────────────── + +// SysfsEnergyZone implements device.EnergyZone for a single amd_energy counter. +type SysfsEnergyZone struct { + name string + index int + path string + maxEnergy device.Energy +} + +func (z *SysfsEnergyZone) Name() string { return z.name } +func (z *SysfsEnergyZone) Index() int { return z.index } +func (z *SysfsEnergyZone) Path() string { return z.path } + +// Energy reads the accumulated energy counter in microjoules. +func (z *SysfsEnergyZone) Energy() (device.Energy, error) { + b, err := os.ReadFile(z.path) + if err != nil { + return 0, fmt.Errorf("esmi/sysfs: read %s: %w", z.path, err) + } + val, err := strconv.ParseUint(strings.TrimSpace(string(b)), 10, 64) + if err != nil { + return 0, fmt.Errorf("esmi/sysfs: parse energy from %s: %w", z.path, err) + } + return device.Energy(val), nil +} + +// MaxEnergy returns the counter rollover value; 0 means unknown. +func (z *SysfsEnergyZone) MaxEnergy() device.Energy { return z.maxEnergy } + +// Power is unsupported; derive from successive Energy() readings. +func (z *SysfsEnergyZone) Power() (device.Power, error) { + return 0, fmt.Errorf("esmi/sysfs: amd_energy zones do not provide instantaneous power; derive from successive Energy() calls") +} + +// ── SysfsPowerZone ──────────────────────────────────────────────────────────── + +// SysfsPowerZone implements device.EnergyZone for AMD HSMP power sensors. +// These zones provide instantaneous power readings but no energy counters. +type SysfsPowerZone struct { + name string + index int + path string +} + +func (z *SysfsPowerZone) Name() string { return z.name } +func (z *SysfsPowerZone) Index() int { return z.index } +func (z *SysfsPowerZone) Path() string { return z.path } + +// Power reads the instantaneous power in microwatts. +func (z *SysfsPowerZone) Power() (device.Power, error) { + b, err := os.ReadFile(z.path) + if err != nil { + return 0, fmt.Errorf("esmi/sysfs: read %s: %w", z.path, err) + } + val, err := strconv.ParseUint(strings.TrimSpace(string(b)), 10, 64) + if err != nil { + return 0, fmt.Errorf("esmi/sysfs: parse power from %s: %w", z.path, err) + } + return device.Power(val), nil +} + +// MaxEnergy returns 0 since power-only zones have no energy counter. +func (z *SysfsPowerZone) MaxEnergy() device.Energy { return 0 } + +// Energy is unsupported for power-only zones. +func (z *SysfsPowerZone) Energy() (device.Energy, error) { + return 0, fmt.Errorf("esmi/sysfs: amd_hsmp_hwmon zones do not provide energy counters; use Power() for instantaneous power") +} + +// ── backend registration ────────────────────────────────────────────────────── + +// sysfsBackend is called by availableReaders() in backends_*.go. +func sysfsBackend(sysfsPath string) backendCandidate { + return backendCandidate{name: "sysfs", reader: NewSysfsReader(sysfsPath)} +} diff --git a/internal/device/fake_cpu_power_meter.go b/internal/device/cpu/fake_cpu_power_meter.go similarity index 78% rename from internal/device/fake_cpu_power_meter.go rename to internal/device/cpu/fake_cpu_power_meter.go index a28013b2a5..5bd3707386 100644 --- a/internal/device/fake_cpu_power_meter.go +++ b/internal/device/cpu/fake_cpu_power_meter.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 The Kepler Authors // SPDX-License-Identifier: Apache-2.0 -package device +package cpu import ( "fmt" @@ -10,10 +10,12 @@ import ( "path/filepath" "strings" "sync" + + "github.com/sustainable-computing-io/kepler/internal/device" ) // NOTE: This fake meter is not intended to be used in production and is for testing only -var defaultFakeZones = []Zone{ZonePackage, ZoneCore, ZoneDRAM} +var defaultFakeZones = []device.Zone{device.ZonePackage, device.ZoneCore, device.ZoneDRAM} const defaultRaplPath = "/sys/class/powercap/intel-rapl" @@ -22,16 +24,16 @@ type fakeEnergyZone struct { name string index int path string - energy Energy - maxEnergy Energy + energy device.Energy + maxEnergy device.Energy mu sync.Mutex // For generating fake values - increment Energy + increment device.Energy randomFactor float64 } -var _ EnergyZone = (*fakeEnergyZone)(nil) +var _ device.EnergyZone = (*fakeEnergyZone)(nil) // Name returns the zone name func (z *fakeEnergyZone) Name() string { @@ -49,23 +51,23 @@ func (z *fakeEnergyZone) Path() string { } // Energy returns energy consumed by the zone. -func (z *fakeEnergyZone) Energy() (Energy, error) { +func (z *fakeEnergyZone) Energy() (device.Energy, error) { z.mu.Lock() defer z.mu.Unlock() - randomComponent := Energy(rand.Float64() * float64(z.increment) * z.randomFactor) + randomComponent := device.Energy(rand.Float64() * float64(z.increment) * z.randomFactor) z.energy = (z.energy + z.increment + randomComponent) % z.maxEnergy return z.energy, nil } // MaxEnergy returns the maximum value of energy usage that can be read. -func (z *fakeEnergyZone) MaxEnergy() Energy { +func (z *fakeEnergyZone) MaxEnergy() device.Energy { return z.maxEnergy } // Power for fake zones implemented to satisfy interface. -func (z *fakeEnergyZone) Power() (Power, error) { +func (z *fakeEnergyZone) Power() (device.Power, error) { // Fake zones don't provide power, only energy return 0, fmt.Errorf("fake zones do not provide power readings") } @@ -73,7 +75,7 @@ func (z *fakeEnergyZone) Power() (Power, error) { // fakeRaplMeter implements the CPUPowerMeter interface type fakeRaplMeter struct { logger *slog.Logger - zones []EnergyZone + zones []device.EnergyZone devicePath string } @@ -95,7 +97,7 @@ func WithFakePath(path string) FakeOptFn { } // WithFakeMaxEnergy sets the maximum energy value before wrap-around -func WithFakeMaxEnergy(e Energy) FakeOptFn { +func WithFakeMaxEnergy(e device.Energy) FakeOptFn { return func(m *fakeRaplMeter) { for _, z := range m.zones { if fz, ok := z.(*fakeEnergyZone); ok { @@ -124,14 +126,14 @@ func NewFakeCPUMeter(zones []string, opts ...FakeOptFn) (CPUPowerMeter, error) { zones = defaultFakeZones } - zoneIncrementFactor := map[Zone]int{ - ZonePackage: 12, - ZoneCore: 8, - ZoneDRAM: 5, - ZoneUncore: 2, + zoneIncrementFactor := map[device.Zone]int{ + device.ZonePackage: 12, + device.ZoneCore: 8, + device.ZoneDRAM: 5, + device.ZoneUncore: 2, } - meter.zones = make([]EnergyZone, 0, len(zones)) + meter.zones = make([]device.EnergyZone, 0, len(zones)) for i, zoneName := range zones { meter.zones = append(meter.zones, &fakeEnergyZone{ @@ -139,7 +141,7 @@ func NewFakeCPUMeter(zones []string, opts ...FakeOptFn) (CPUPowerMeter, error) { index: i, path: filepath.Join(defaultRaplPath, fmt.Sprintf("energy_%s", zoneName)), maxEnergy: 1000000, - increment: Energy(100 + zoneIncrementFactor[zoneName]), + increment: device.Energy(100 + zoneIncrementFactor[zoneName]), randomFactor: 0.5, }) } @@ -161,12 +163,12 @@ func (m *fakeRaplMeter) Init() error { return nil } -func (m *fakeRaplMeter) Zones() ([]EnergyZone, error) { +func (m *fakeRaplMeter) Zones() ([]device.EnergyZone, error) { return m.zones, nil } // PrimaryEnergyZone returns the zone with the highest energy coverage/priority -func (m *fakeRaplMeter) PrimaryEnergyZone() (EnergyZone, error) { +func (m *fakeRaplMeter) PrimaryEnergyZone() (device.EnergyZone, error) { zones, err := m.Zones() if err != nil { return nil, err diff --git a/internal/device/fake_cpu_power_meter_test.go b/internal/device/cpu/fake_cpu_power_meter_test.go similarity index 92% rename from internal/device/fake_cpu_power_meter_test.go rename to internal/device/cpu/fake_cpu_power_meter_test.go index b85de19a73..de646e5672 100644 --- a/internal/device/fake_cpu_power_meter_test.go +++ b/internal/device/cpu/fake_cpu_power_meter_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 The Kepler Authors // SPDX-License-Identifier: Apache-2.0 -package device +package cpu import ( "log/slog" @@ -9,6 +9,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/sustainable-computing-io/kepler/internal/device" ) func TestNewFakeCPUMeter(t *testing.T) { @@ -52,7 +53,7 @@ func TestFakeEnergyZone_Basics(t *testing.T) { assert.Equal(t, "test-zone", zone.Name()) assert.Equal(t, 42, zone.Index()) assert.Equal(t, "/fake/path/energy_test-zone", zone.Path()) - assert.Equal(t, Energy(500000), zone.MaxEnergy()) + assert.Equal(t, device.Energy(500000), zone.MaxEnergy()) } func TestFakeEnergyZone_Energy(t *testing.T) { @@ -67,18 +68,18 @@ func TestFakeEnergyZone_Energy(t *testing.T) { // First read should return the increment e1, err := zone.Energy() assert.NoError(t, err) - assert.Equal(t, Energy(100), e1) + assert.Equal(t, device.Energy(100), e1) // Second read should return double the increment e2, err := zone.Energy() assert.NoError(t, err) - assert.Equal(t, Energy(200), e2) + assert.Equal(t, device.Energy(200), e2) // Test wrap-around at maxEnergy zone.energy = 950 e3, err := zone.Energy() assert.NoError(t, err) - assert.Equal(t, Energy(50), e3) // Wrapped around: 950 + 100 = 1050, but 1050 % 1000 = 50 + assert.Equal(t, device.Energy(50), e3) // Wrapped around: 950 + 100 = 1050, but 1050 % 1000 = 50 } func TestWithFakeZones(t *testing.T) { @@ -125,7 +126,7 @@ func TestWithFakePath(t *testing.T) { } func TestWithFakeMaxEnergy(t *testing.T) { - customMax := Energy(999999) + customMax := device.Energy(999999) meter, err := NewFakeCPUMeter(nil, WithFakeMaxEnergy(customMax)) assert.NoError(t, err) @@ -151,7 +152,7 @@ func TestWithFakeLogger(t *testing.T) { func TestMultipleOptions(t *testing.T) { customPath := "/custom/rapl/path" - customMax := Energy(888888) + customMax := device.Energy(888888) customZones := []string{"custom1", "custom2"} logger := slog.Default().With("test", "logger") @@ -190,7 +191,7 @@ func TestEnergyRandomness(t *testing.T) { } // Read energy multiple times - var readings []Energy + var readings []device.Energy for range 10 { e, err := zone.Energy() assert.NoError(t, err) diff --git a/internal/device/hwmon_chip_rules.go b/internal/device/cpu/hwmon_chip_rules.go similarity index 99% rename from internal/device/hwmon_chip_rules.go rename to internal/device/cpu/hwmon_chip_rules.go index 72a67e67c1..55f00cc04b 100644 --- a/internal/device/hwmon_chip_rules.go +++ b/internal/device/cpu/hwmon_chip_rules.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 The Kepler Authors // SPDX-License-Identifier: Apache-2.0 -package device +package cpu import "strings" diff --git a/internal/device/hwmon_power_meter.go b/internal/device/cpu/hwmon_power_meter.go similarity index 94% rename from internal/device/hwmon_power_meter.go rename to internal/device/cpu/hwmon_power_meter.go index 696f6dd33e..a0007a753f 100644 --- a/internal/device/hwmon_power_meter.go +++ b/internal/device/cpu/hwmon_power_meter.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 The Kepler Authors // SPDX-License-Identifier: Apache-2.0 -package device +package cpu import ( "errors" @@ -14,16 +14,17 @@ import ( "strconv" "strings" + "github.com/sustainable-computing-io/kepler/internal/device" "golang.org/x/sys/unix" ) // hwmonPowerMeter implements CPUPowerMeter using hwmon sysfs type hwmonPowerMeter struct { reader hwmonReader - cachedZones []EnergyZone + cachedZones []device.EnergyZone logger *slog.Logger zoneFilter []string - topZone EnergyZone + topZone device.EnergyZone } // HwmonOptionFn is a function that configures hwmonPowerMeter options @@ -31,7 +32,7 @@ type HwmonOptionFn func(*hwmonPowerMeter) // hwmonReader is an interface for reading hwmon data, used for mocking in tests type hwmonReader interface { - Zones() ([]EnergyZone, error) + Zones() ([]device.EnergyZone, error) } // WithHwmonReader sets the hwmonReader to be used by hwmonPowerMeter @@ -138,7 +139,7 @@ func (h *hwmonPowerMeter) needsZoneFiltering() bool { return len(h.zoneFilter) != 0 } -func (h *hwmonPowerMeter) Zones() ([]EnergyZone, error) { +func (h *hwmonPowerMeter) Zones() ([]device.EnergyZone, error) { // Return cached zones if already initialized if len(h.cachedZones) != 0 { return h.cachedZones, nil @@ -162,7 +163,7 @@ func (h *hwmonPowerMeter) Zones() ([]EnergyZone, error) { } // filterZones applies zone filters -func (h *hwmonPowerMeter) filterZones(zones []EnergyZone) []EnergyZone { +func (h *hwmonPowerMeter) filterZones(zones []device.EnergyZone) []device.EnergyZone { if !h.needsZoneFiltering() { return zones } @@ -173,7 +174,7 @@ func (h *hwmonPowerMeter) filterZones(zones []EnergyZone) []EnergyZone { } var included, excluded []string - filtered := make([]EnergyZone, 0, len(zones)) + filtered := make([]device.EnergyZone, 0, len(zones)) for _, zone := range zones { // Check zone filter @@ -192,9 +193,9 @@ func (h *hwmonPowerMeter) filterZones(zones []EnergyZone) []EnergyZone { // groupZonesByName groups zones by their base name and creates AggregatedZone // instances when multiple zones share the same name -func (h *hwmonPowerMeter) groupZonesByName(zones []EnergyZone) []EnergyZone { +func (h *hwmonPowerMeter) groupZonesByName(zones []device.EnergyZone) []device.EnergyZone { // Group zones by base name - zoneGroups := make(map[string][]EnergyZone) + zoneGroups := make(map[string][]device.EnergyZone) for _, zone := range zones { name := zone.Name() @@ -202,7 +203,7 @@ func (h *hwmonPowerMeter) groupZonesByName(zones []EnergyZone) []EnergyZone { } // Create aggregated zones for duplicates, keep single zones as-is - var result []EnergyZone + var result []device.EnergyZone for name, zones := range zoneGroups { if len(zones) == 1 { result = append(result, zones[0]) @@ -213,7 +214,7 @@ func (h *hwmonPowerMeter) groupZonesByName(zones []EnergyZone) []EnergyZone { // LIMITATION: aggregation occurs when the devices are different with coincidentally // the same labels. This should not happen. Ideally, Kepler identifies whether the zones with same // name occur due to multi-socket CPU or independent devices. - aggregated, _ := NewAggregatedZone(zones) + aggregated, _ := device.NewAggregatedZone(zones) result = append(result, aggregated) h.logger.Debug("Created aggregated zone", "name", name, @@ -229,7 +230,7 @@ func (h *hwmonPowerMeter) groupZonesByName(zones []EnergyZone) []EnergyZone { } // PrimaryEnergyZone returns the zone with the highest energy coverage/priority -func (h *hwmonPowerMeter) PrimaryEnergyZone() (EnergyZone, error) { +func (h *hwmonPowerMeter) PrimaryEnergyZone() (device.EnergyZone, error) { // Return cached zone if already initialized if h.topZone != nil { return h.topZone, nil @@ -244,7 +245,7 @@ func (h *hwmonPowerMeter) PrimaryEnergyZone() (EnergyZone, error) { return nil, fmt.Errorf("no energy zones available") } - zoneMap := map[string]EnergyZone{} + zoneMap := map[string]device.EnergyZone{} for _, zone := range zones { zoneMap[strings.ToLower(zone.Name())] = zone } @@ -284,7 +285,7 @@ var ( hwmonInvalidMetricChars = regexp.MustCompile("[^a-z0-9:_]") ) -func (r *sysfsHwmonReader) Zones() ([]EnergyZone, error) { +func (r *sysfsHwmonReader) Zones() ([]device.EnergyZone, error) { hwmonDirs, err := os.ReadDir(r.basePath) if err != nil { if os.IsNotExist(err) { @@ -293,7 +294,7 @@ func (r *sysfsHwmonReader) Zones() ([]EnergyZone, error) { return nil, fmt.Errorf("failed to read hwmon directory: %w", err) } - var zones []EnergyZone + var zones []device.EnergyZone for _, entry := range hwmonDirs { // check for valid hwmon devices if !entry.IsDir() && !isSymlink(filepath.Join(r.basePath, entry.Name())) { @@ -319,7 +320,7 @@ func (r *sysfsHwmonReader) Zones() ([]EnergyZone, error) { return zones, nil } -func (r *sysfsHwmonReader) discoverZones(hwmonPath string) ([]EnergyZone, error) { +func (r *sysfsHwmonReader) discoverZones(hwmonPath string) ([]device.EnergyZone, error) { // Get chip name chipName, err := r.getChipName(hwmonPath) if err != nil { @@ -335,7 +336,7 @@ func (r *sysfsHwmonReader) discoverZones(hwmonPath string) ([]EnergyZone, error) return nil, fmt.Errorf("failed to retrieve sensor files: %w", err) } - var zones []EnergyZone + var zones []device.EnergyZone // Tier 1: Energy sensors (preferred — cumulative µJ, like RAPL) energySensors := r.findSensorsByType(files, "energy") @@ -419,7 +420,7 @@ func (r *sysfsHwmonReader) createPowerZone( hwmonPath, chipName, humanName string, sensorNum int, sensorFiles map[string]string, -) (EnergyZone, error) { +) (device.EnergyZone, error) { // Determine the zone name from label or generate one var zoneName string if labelFile, hasLabel := sensorFiles["label"]; hasLabel { @@ -460,7 +461,7 @@ func (r *sysfsHwmonReader) createEnergyZone( hwmonPath, chipName, humanName string, sensorNum int, sensorFiles map[string]string, -) (EnergyZone, error) { +) (device.EnergyZone, error) { // Determine the zone name from label or generate one var zoneName string if labelFile, hasLabel := sensorFiles["label"]; hasLabel { @@ -510,7 +511,7 @@ var ErrVoltageCurrentNoLabels = fmt.Errorf("voltage and current sensors found bu func (r *sysfsHwmonReader) discoverVoltageCurrentZones( hwmonPath, chipName, humanName string, files []os.DirEntry, -) ([]EnergyZone, error) { +) ([]device.EnergyZone, error) { // Find voltage sensors (in*) and current sensors (curr*) voltageSensors := r.findSensorsByType(files, "in") currentSensors := r.findSensorsByType(files, "curr") @@ -609,7 +610,7 @@ func (r *sysfsHwmonReader) discoverVoltageCurrentZones( currentByIndex[sensorNum] = info } - var zones []EnergyZone + var zones []device.EnergyZone // PRIORITY 1: Label-based matching // This is the most robust method - labels explicitly identify matching sensors @@ -642,8 +643,8 @@ func (r *sysfsHwmonReader) matchByLabel( chipName, humanName string, voltageLabelMap map[string]voltageSensorInfo, currentLabelMap map[string]currentSensorInfo, -) []EnergyZone { - var zones []EnergyZone +) []device.EnergyZone { + var zones []device.EnergyZone for label, voltageInfo := range voltageLabelMap { currentInfo, found := currentLabelMap[label] @@ -668,14 +669,14 @@ func (r *sysfsHwmonReader) matchByChipRule( chipName, humanName string, voltageByIndex map[int]voltageSensorInfo, currentByIndex map[int]currentSensorInfo, -) []EnergyZone { +) []device.EnergyZone { // Get the pairing rule for this chip (config rules take precedence over hardcoded) rule := getChipPairingRule(humanName, r.configChipRules) if rule == nil { return nil } - var zones []EnergyZone + var zones []device.EnergyZone if rule.useSameIndex { // Same-index pairing with skip rules @@ -733,8 +734,8 @@ func (r *sysfsHwmonReader) matchBySameIndex( chipName, humanName string, voltageByIndex map[int]voltageSensorInfo, currentByIndex map[int]currentSensorInfo, -) []EnergyZone { - var zones []EnergyZone +) []device.EnergyZone { + var zones []device.EnergyZone for idx, voltageInfo := range voltageByIndex { currentInfo, found := currentByIndex[idx] @@ -980,18 +981,18 @@ func (z *hwmonPowerZone) Path() string { return z.path } -func (z *hwmonPowerZone) Energy() (Energy, error) { +func (z *hwmonPowerZone) Energy() (device.Energy, error) { // hwmon provides power, not energy // Return 0 for interface compatibility return 0, fmt.Errorf("hwmon zones do not provide energy readings") } -func (z *hwmonPowerZone) MaxEnergy() Energy { +func (z *hwmonPowerZone) MaxEnergy() device.Energy { // No maximum for power sensors return 0 } -func (z *hwmonPowerZone) Power() (Power, error) { +func (z *hwmonPowerZone) Power() (device.Power, error) { // Read current power value using direct syscall to avoid EAGAIN polling issues data, err := sysReadFile(z.path) if err != nil { @@ -1005,7 +1006,7 @@ func (z *hwmonPowerZone) Power() (Power, error) { } // Power type represents microwatts - return Power(powerMicrowatts), nil + return device.Power(powerMicrowatts), nil } func (z *hwmonVoltageCurrentZone) Name() string { @@ -1021,12 +1022,12 @@ func (z *hwmonVoltageCurrentZone) Path() string { return z.voltagePath } -func (z *hwmonVoltageCurrentZone) Energy() (Energy, error) { +func (z *hwmonVoltageCurrentZone) Energy() (device.Energy, error) { // Calculated power zones do not provide energy readings return 0, fmt.Errorf("hwmon calculated power zones do not provide energy readings") } -func (z *hwmonVoltageCurrentZone) MaxEnergy() Energy { +func (z *hwmonVoltageCurrentZone) MaxEnergy() device.Energy { // No maximum for calculated power zones return 0 } @@ -1054,7 +1055,7 @@ func (z *hwmonEnergyZone) Path() string { return z.path } -func (z *hwmonEnergyZone) Energy() (Energy, error) { +func (z *hwmonEnergyZone) Energy() (device.Energy, error) { data, err := sysReadFile(z.path) if err != nil { return 0, fmt.Errorf("failed to read energy from %s: %w", z.path, err) @@ -1066,21 +1067,21 @@ func (z *hwmonEnergyZone) Energy() (Energy, error) { return 0, fmt.Errorf("failed to parse energy value from %s: %w", z.path, err) } - return Energy(microjoules), nil + return device.Energy(microjoules), nil } -func (z *hwmonEnergyZone) MaxEnergy() Energy { +func (z *hwmonEnergyZone) MaxEnergy() device.Energy { // hwmon energy sensors have no max_energy_range equivalent. // The monitor detects energy zones via energyReading > 0 as fallback. return 0 } -func (z *hwmonEnergyZone) Power() (Power, error) { +func (z *hwmonEnergyZone) Power() (device.Power, error) { // Energy zones provide cumulative energy, not instantaneous power return 0, fmt.Errorf("hwmon energy zones do not provide instantaneous power readings") } -func (z *hwmonVoltageCurrentZone) Power() (Power, error) { +func (z *hwmonVoltageCurrentZone) Power() (device.Power, error) { // Read voltage in millivolts voltageData, err := sysReadFile(z.voltagePath) if err != nil { @@ -1109,5 +1110,5 @@ func (z *hwmonVoltageCurrentZone) Power() (Power, error) { // Example: 12000 mV × 5000 mA = 60,000,000 µW = 60 W powerMicrowatts := voltageMV * currentMA - return Power(powerMicrowatts), nil + return device.Power(powerMicrowatts), nil } diff --git a/internal/device/hwmon_power_meter_test.go b/internal/device/cpu/hwmon_power_meter_test.go similarity index 88% rename from internal/device/hwmon_power_meter_test.go rename to internal/device/cpu/hwmon_power_meter_test.go index 7a6df8b843..d187793f65 100644 --- a/internal/device/hwmon_power_meter_test.go +++ b/internal/device/cpu/hwmon_power_meter_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 The Kepler Authors // SPDX-License-Identifier: Apache-2.0 -package device +package cpu import ( "log/slog" @@ -12,11 +12,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/sustainable-computing-io/kepler/internal/device" ) const ( - validHwmonPath = "testdata/sys" - badHwmonPath = "testdata/bad_sysfs" + validHwmonPath = "../testdata/sys" + badHwmonPath = "../testdata/bad_sysfs" ) // TestHwmonPowerMeterInterface ensures that hwmonPowerMeter properly implements the CPUPowerMeter interface @@ -46,11 +47,11 @@ func TestWithHwmonReader(t *testing.T) { // Create a mock reader mockReader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } // Create meter with custom reader - meter, err := NewHwmonPowerMeter("testdata/sys", WithHwmonReader(mockReader)) + meter, err := NewHwmonPowerMeter("../testdata/sys", WithHwmonReader(mockReader)) require.NoError(t, err) // Verify the custom reader was set @@ -68,7 +69,7 @@ func TestWithHwmonLogger(t *testing.T) { })) // Create meter with custom logger - meter, err := NewHwmonPowerMeter("testdata/sys", WithHwmonLogger(customLogger)) + meter, err := NewHwmonPowerMeter("../testdata/sys", WithHwmonLogger(customLogger)) require.NoError(t, err) // Verify logger was set (check that it's not nil and has "service" attribute) @@ -85,7 +86,7 @@ func TestWithHwmonZoneFilter(t *testing.T) { // Create meter with zone filter zoneFilter := []string{"package", "core"} - meter, err := NewHwmonPowerMeter("testdata/sys", WithHwmonZoneFilter(zoneFilter)) + meter, err := NewHwmonPowerMeter("../testdata/sys", WithHwmonZoneFilter(zoneFilter)) require.NoError(t, err) // Verify zone filter was set @@ -101,7 +102,7 @@ func TestNewHwmonPowerMeter_WithMultipleOptions(t *testing.T) { // Create custom components mockReader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } customLogger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ Level: slog.LevelDebug, @@ -109,7 +110,7 @@ func TestNewHwmonPowerMeter_WithMultipleOptions(t *testing.T) { zoneFilter := []string{"package"} // Create meter with all options - meter, err := NewHwmonPowerMeter("testdata/sys", + meter, err := NewHwmonPowerMeter("../testdata/sys", WithHwmonReader(mockReader), WithHwmonLogger(customLogger), WithHwmonZoneFilter(zoneFilter)) @@ -211,18 +212,18 @@ func TestHwmonPowerMeter_Zones(t *testing.T) { t.Logf(" Index: %d", zone.Index()) t.Logf(" Path: %s", zone.Path()) - // Test reading capability - zones provide either Power() or Energy() + // Test reading capability - zones provide either device.Power() or device.Energy() power, powerErr := zone.Power() energy, energyErr := zone.Energy() if _, ok := zone.(*hwmonEnergyZone); ok { - // Energy zones: Energy() succeeds, Power() fails - assert.NoError(t, energyErr, "Energy() should succeed for energy zone %s", zone.Name()) - assert.Error(t, powerErr, "Power() should return error for energy zone %s", zone.Name()) + // Energy zones: device.Energy() succeeds, device.Power() fails + assert.NoError(t, energyErr, "device.Energy() should succeed for energy zone %s", zone.Name()) + assert.Error(t, powerErr, "device.Power() should return error for energy zone %s", zone.Name()) t.Logf(" Energy: %d µJ", energy) } else { - // Power zones: Power() succeeds, Energy() fails - assert.NoError(t, powerErr, "Power() should not return error for zone %s", zone.Name()) + // Power zones: device.Power() succeeds, device.Energy() fails + assert.NoError(t, powerErr, "device.Power() should not return error for zone %s", zone.Name()) t.Logf(" Power: %.2f W", power.Watts()) } } @@ -250,9 +251,9 @@ func TestHwmonPowerMeter_ZoneDetails(t *testing.T) { t.Logf("\n=== Detailed Zone Information ===") for _, zone := range zones { // Check if this is an aggregated zone or regular zone - if aggZone, ok := zone.(*AggregatedZone); ok { + if aggZone, ok := zone.(*device.AggregatedZone); ok { t.Logf("\nAggregated Zone: %s", zone.Name()) - t.Logf(" Contains: %d individual zones", len(aggZone.zones)) + t.Logf(" Contains: %d individual zones", aggZone.Count()) t.Logf(" Index: %d (aggregated)", zone.Index()) power, err := zone.Power() @@ -332,7 +333,7 @@ func TestHwmonPowerMeter_PowerReadings(t *testing.T) { } for _, zone := range zones { - // Skip energy-only zones (they don't provide Power()) + // Skip energy-only zones (they don't provide device.Power()) if _, ok := zone.(*hwmonEnergyZone); ok { energy, err := zone.Energy() require.NoError(t, err, "Failed to read energy for zone %s", zone.Name()) @@ -493,7 +494,7 @@ func TestHwmonPowerZone_Interface(t *testing.T) { zone := &hwmonPowerZone{ name: "test_zone", index: 1, - path: "testdata/sys/class/hwmon/hwmon0/power1_input", + path: "../testdata/sys/class/hwmon/hwmon0/power1_input", chipName: "test_chip", humanName: "test_human", } @@ -501,18 +502,18 @@ func TestHwmonPowerZone_Interface(t *testing.T) { t.Logf("\n=== Testing hwmonPowerZone Interface ===") assert.Equal(t, "test_zone", zone.Name()) assert.Equal(t, 1, zone.Index()) - assert.Equal(t, "testdata/sys/class/hwmon/hwmon0/power1_input", zone.Path()) + assert.Equal(t, "../testdata/sys/class/hwmon/hwmon0/power1_input", zone.Path()) - // Test Energy() returns 0 + // Test device.Energy() returns 0 energy, err := zone.Energy() assert.EqualError(t, err, "hwmon zones do not provide energy readings") - assert.Equal(t, Energy(0), energy, "Energy() should return 0") + assert.Equal(t, device.Energy(0), energy, "device.Energy() should return 0") // Test MaxEnergy() returns 0 maxEnergy := zone.MaxEnergy() - assert.Equal(t, Energy(0), maxEnergy, "MaxEnergy() should return 0") + assert.Equal(t, device.Energy(0), maxEnergy, "MaxEnergy() should return 0") - // Test Power() reads actual value + // Test device.Power() reads actual value power, err := zone.Power() assert.NoError(t, err) assert.InDelta(t, 45.0, power.Watts(), 0.01, "Power should be 45W from test file") @@ -641,13 +642,13 @@ func TestHwmonPowerMeter_RealSystem(t *testing.T) { // TestGetChipName_DevicePathStrategy tests chip name derivation from device symlink func TestGetChipName_DevicePathStrategy(t *testing.T) { reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } t.Logf("\n=== Testing Device Path Strategy for Chip Name ===") // Test with device symlink present - hwmonPath := "testdata/sys/class/hwmon/hwmon_device_path" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_device_path" chipName, err := reader.getChipName(hwmonPath) require.NoError(t, err, "getChipName should not fail with device symlink") @@ -663,13 +664,13 @@ func TestGetChipName_DevicePathStrategy(t *testing.T) { // TestGetChipName_DirectoryFallback tests chip name derivation from directory name func TestGetChipName_DirectoryFallback(t *testing.T) { reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } t.Logf("\n=== Testing Directory Name Fallback for Chip Name ===") // Test with no name file and no device symlink - hwmonPath := "testdata/sys/class/hwmon/hwmon_dir_fallback" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_dir_fallback" chipName, err := reader.getChipName(hwmonPath) require.NoError(t, err, "getChipName should fall back to directory name") @@ -684,7 +685,7 @@ func TestGetChipName_DirectoryFallback(t *testing.T) { // TestGetChipName_AllStrategies tests all chip name derivation strategies func TestGetChipName_AllStrategies(t *testing.T) { reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } testCases := []struct { @@ -696,21 +697,21 @@ func TestGetChipName_AllStrategies(t *testing.T) { }{ { name: "device_path_strategy", - hwmonPath: "testdata/sys/class/hwmon/hwmon_device_path", + hwmonPath: "../testdata/sys/class/hwmon/hwmon_device_path", expectError: false, expectedContains: "card0", description: "Should derive name from device symlink path", }, { name: "name_file_strategy", - hwmonPath: "testdata/sys/class/hwmon/hwmon0", + hwmonPath: "../testdata/sys/class/hwmon/hwmon0", expectError: false, expectedContains: "k10temp", description: "Should read name from 'name' file", }, { name: "directory_fallback_strategy", - hwmonPath: "testdata/sys/class/hwmon/hwmon_dir_fallback", + hwmonPath: "../testdata/sys/class/hwmon/hwmon_dir_fallback", expectError: false, expectedContains: "hwmon_dir_fallback", description: "Should fall back to directory name", @@ -738,12 +739,12 @@ func TestGetChipName_AllStrategies(t *testing.T) { // TestSysfsHwmonReader_DiscoverZones_DevicePath tests zone discovery with device path func TestSysfsHwmonReader_DiscoverZones_DevicePath(t *testing.T) { reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } t.Logf("\n=== Testing Zone Discovery with Device Path ===") - zones, err := reader.discoverZones("testdata/sys/class/hwmon/hwmon_device_path") + zones, err := reader.discoverZones("../testdata/sys/class/hwmon/hwmon_device_path") require.NoError(t, err, "Should discover zones in hwmon_device_path") assert.NotEmpty(t, zones, "Should find at least one zone") @@ -772,12 +773,12 @@ func TestSysfsHwmonReader_DiscoverZones_DevicePath(t *testing.T) { // TestSysfsHwmonReader_DiscoverZones_DirectoryFallback tests zone discovery with dir fallback func TestSysfsHwmonReader_DiscoverZones_DirectoryFallback(t *testing.T) { reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } t.Logf("\n=== Testing Zone Discovery with Directory Fallback ===") - zones, err := reader.discoverZones("testdata/sys/class/hwmon/hwmon_dir_fallback") + zones, err := reader.discoverZones("../testdata/sys/class/hwmon/hwmon_dir_fallback") require.NoError(t, err, "Should discover zones in hwmon_dir_fallback") assert.NotEmpty(t, zones, "Should find at least one zone") @@ -833,7 +834,7 @@ func TestGetChipName_DeviceNameOnly(t *testing.T) { // TestGetChipName_ErrorCases tests error conditions func TestGetChipName_ErrorCases(t *testing.T) { - reader := &sysfsHwmonReader{basePath: "testdata/sys/class/hwmon"} + reader := &sysfsHwmonReader{basePath: "../testdata/sys/class/hwmon"} t.Run("nonexistent_path", func(t *testing.T) { _, err := reader.getChipName("nonexistent/path/that/does/not/exist") @@ -933,7 +934,7 @@ func TestIsSymlink(t *testing.T) { // Test with existing test data t.Run("test_with_existing_symlink_in_testdata", func(t *testing.T) { // We created a device symlink in hwmon_device_path earlier - deviceSymlink := "testdata/sys/class/hwmon/hwmon_device_path/device" + deviceSymlink := "../testdata/sys/class/hwmon/hwmon_device_path/device" result := isSymlink(deviceSymlink) assert.True(t, result, "isSymlink should return true for device symlink") @@ -946,7 +947,7 @@ func TestHwmonPowerMeter_AggregatedZones(t *testing.T) { t.Logf("\n=== Testing Aggregated Zones ===") // Create a meter that will discover zones with duplicate names - meter, err := NewHwmonPowerMeter("testdata/sys") + meter, err := NewHwmonPowerMeter("../testdata/sys") require.NoError(t, err) // Discover zones - this should trigger aggregation @@ -956,7 +957,7 @@ func TestHwmonPowerMeter_AggregatedZones(t *testing.T) { t.Logf("Found %d zones after aggregation", len(zones)) // Look for the aggregated "package" zone - var aggregatedZone EnergyZone + var aggregatedZone device.EnergyZone for _, zone := range zones { if zone.Name() == "package" { aggregatedZone = zone @@ -966,20 +967,20 @@ func TestHwmonPowerMeter_AggregatedZones(t *testing.T) { require.NotNil(t, aggregatedZone, "Should find 'package' zone") - // Check if it's an AggregatedZone by checking the index - // AggregatedZone has index = -1 + // Check if it's an device.AggregatedZone by checking the index + // device.AggregatedZone has index = -1 if aggregatedZone.Index() == -1 { - t.Logf("✓ Zone 'package' is an AggregatedZone (index=-1)") + t.Logf("✓ Zone 'package' is an device.AggregatedZone (index=-1)") - // Verify it's actually an AggregatedZone type - aggZone, ok := aggregatedZone.(*AggregatedZone) - require.True(t, ok, "Zone should be *AggregatedZone type") + // Verify it's actually an device.AggregatedZone type + aggZone, ok := aggregatedZone.(*device.AggregatedZone) + require.True(t, ok, "Zone should be *device.AggregatedZone type") - t.Logf(" Aggregated zone contains %d individual zones", len(aggZone.zones)) + t.Logf(" Aggregated zone contains %d individual zones", aggZone.Count()) assert.Equal(t, "package", aggZone.Name()) assert.Equal(t, -1, aggZone.Index()) - // Test Power() - should sum power from all package zones + // Test device.Power() - should sum power from all package zones totalPower, err := aggZone.Power() require.NoError(t, err) @@ -989,14 +990,14 @@ func TestHwmonPowerMeter_AggregatedZones(t *testing.T) { // Total should be sum of all package zones found assert.Greater(t, totalPower.Watts(), 0.0, "Aggregated power should be positive") - assert.Equal(t, len(aggZone.zones), 3, + assert.Equal(t, aggZone.Count(), 3, "Should aggregate all package zones from test data") // Expected: 45W + 50W + 30W = 125W assert.InDelta(t, 125.0, totalPower.Watts(), 0.01, "Aggregated power should be sum of all package zones (45W + 50W + 30W = 125W)") t.Logf(" Total aggregated power: %.2f W from %d zones", - totalPower.Watts(), len(aggZone.zones)) + totalPower.Watts(), aggZone.Count()) } else { t.Logf("Zone 'package' is a single zone (index=%d)", aggregatedZone.Index()) @@ -1010,7 +1011,7 @@ func TestGroupZonesByName(t *testing.T) { } t.Run("single_zone_per_name", func(t *testing.T) { - zones := []EnergyZone{ + zones := []device.EnergyZone{ &hwmonPowerZone{name: "package", index: 0}, &hwmonPowerZone{name: "core", index: 1}, &hwmonPowerZone{name: "gpu", index: 2}, @@ -1029,7 +1030,7 @@ func TestGroupZonesByName(t *testing.T) { }) t.Run("multiple_zones_same_name", func(t *testing.T) { - zones := []EnergyZone{ + zones := []device.EnergyZone{ &hwmonPowerZone{name: "package", index: 0, path: "/path1"}, &hwmonPowerZone{name: "package", index: 1, path: "/path2"}, &hwmonPowerZone{name: "core", index: 2, path: "/path3"}, @@ -1041,8 +1042,8 @@ func TestGroupZonesByName(t *testing.T) { "Should have 2 zones (package aggregated, core single)") // Find the package zone - var packageZone EnergyZone - var coreZone EnergyZone + var packageZone device.EnergyZone + var coreZone device.EnergyZone for _, zone := range result { if zone.Name() == "package" { packageZone = zone @@ -1068,7 +1069,7 @@ func TestGroupZonesByName(t *testing.T) { }) t.Run("all_zones_same_name", func(t *testing.T) { - zones := []EnergyZone{ + zones := []device.EnergyZone{ &hwmonPowerZone{name: "package", index: 0}, &hwmonPowerZone{name: "package", index: 1}, &hwmonPowerZone{name: "package", index: 2}, @@ -1082,9 +1083,9 @@ func TestGroupZonesByName(t *testing.T) { assert.Equal(t, -1, result[0].Index(), "Should be aggregated zone with index=-1") - aggZone, ok := result[0].(*AggregatedZone) - require.True(t, ok, "Should be AggregatedZone type") - assert.Equal(t, 3, len(aggZone.zones), + aggZone, ok := result[0].(*device.AggregatedZone) + require.True(t, ok, "Should be device.AggregatedZone type") + assert.Equal(t, 3, aggZone.Count(), "Should aggregate all 3 zones") t.Logf("✓ All zones with same name are aggregated into one") @@ -1094,13 +1095,13 @@ func TestGroupZonesByName(t *testing.T) { // TestSysfsHwmonReader_DiscoverZones_Aggregation tests zone discovery with aggregation func TestSysfsHwmonReader_DiscoverZones_Aggregation(t *testing.T) { reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } t.Logf("\n=== Testing Zone Discovery with Aggregation ===") // hwmon_aggregate has two power sensors with the same label "package" - zones, err := reader.discoverZones("testdata/sys/class/hwmon/hwmon_aggregate") + zones, err := reader.discoverZones("../testdata/sys/class/hwmon/hwmon_aggregate") require.NoError(t, err, "Should discover zones in hwmon_aggregate") assert.Equal(t, 2, len(zones), "Should find 2 zones with same name") @@ -1161,20 +1162,20 @@ func TestCleanMetricName(t *testing.T) { // mockHwmonReader is a mock implementation of hwmonReader for testing error paths type mockHwmonReader struct { - zones []EnergyZone + zones []device.EnergyZone err error } -func (m *mockHwmonReader) Zones() ([]EnergyZone, error) { +func (m *mockHwmonReader) Zones() ([]device.EnergyZone, error) { return m.zones, m.err } -// TestHwmonPowerMeter_Init_PowerReadError tests Init() error path when Power() fails +// TestHwmonPowerMeter_Init_PowerReadError tests Init() error path when device.Power() fails func TestHwmonPowerMeter_Init_PowerReadError(t *testing.T) { t.Logf("\n=== Testing Init() Error Path: Power Read Failure ===") - // Create a mock zone that fails on both Power() and Energy() calls - mockZone := &mockEnergyZone{ + // Create a mock zone that fails on both device.Power() and device.Energy() calls + mockZone := &testMockEnergyZone{ name: "test_zone", index: 0, path: "/fake/path", @@ -1185,17 +1186,17 @@ func TestHwmonPowerMeter_Init_PowerReadError(t *testing.T) { // Create mock reader that returns the failing zone mockReader := &mockHwmonReader{ - zones: []EnergyZone{mockZone}, + zones: []device.EnergyZone{mockZone}, err: nil, } // Create meter with mock reader - meter, err := NewHwmonPowerMeter("testdata/sys", WithHwmonReader(mockReader)) + meter, err := NewHwmonPowerMeter("../testdata/sys", WithHwmonReader(mockReader)) require.NoError(t, err) - // Init should fail when both Power() and Energy() fail on first zone + // Init should fail when both device.Power() and device.Energy() fail on first zone err = meter.Init() - assert.Error(t, err, "Init() should fail when both Power() and Energy() fail on first zone") + assert.Error(t, err, "Init() should fail when both device.Power() and device.Energy() fail on first zone") t.Logf("✓ Init() correctly failed with error: %v", err) } @@ -1210,7 +1211,7 @@ func TestHwmonPowerMeter_Zones_ReaderError(t *testing.T) { } // Create meter with failing mock reader - meter, err := NewHwmonPowerMeter("testdata/sys", WithHwmonReader(mockReader)) + meter, err := NewHwmonPowerMeter("../testdata/sys", WithHwmonReader(mockReader)) require.NoError(t, err) // Zones() should return the error from reader @@ -1225,7 +1226,7 @@ func TestHwmonPowerMeter_Zones_AllFiltered(t *testing.T) { t.Logf("\n=== Testing Zones() Error Path: All Zones Filtered Out ===") // Create meter with zone filter that excludes all zones - meter, err := NewHwmonPowerMeter("testdata/sys", + meter, err := NewHwmonPowerMeter("../testdata/sys", WithHwmonZoneFilter([]string{"nonexistent_zone"})) require.NoError(t, err) @@ -1249,7 +1250,7 @@ func TestHwmonPowerMeter_PrimaryEnergyZone_ZonesError(t *testing.T) { } // Create meter with failing mock reader - meter, err := NewHwmonPowerMeter("testdata/sys", WithHwmonReader(mockReader)) + meter, err := NewHwmonPowerMeter("../testdata/sys", WithHwmonReader(mockReader)) require.NoError(t, err) // PrimaryEnergyZone() should return the error from Zones() @@ -1265,12 +1266,12 @@ func TestHwmonPowerMeter_PrimaryEnergyZone_NoZones(t *testing.T) { // Create mock reader that returns empty zones list mockReader := &mockHwmonReader{ - zones: []EnergyZone{}, + zones: []device.EnergyZone{}, err: nil, } // Create meter with mock reader - meter, err := NewHwmonPowerMeter("testdata/sys", WithHwmonReader(mockReader)) + meter, err := NewHwmonPowerMeter("../testdata/sys", WithHwmonReader(mockReader)) require.NoError(t, err) // PrimaryEnergyZone() should fail when zones list is empty @@ -1288,7 +1289,7 @@ func TestSysReadFile_ErrorPaths(t *testing.T) { t.Run("nonexistent_file", func(t *testing.T) { // Try to read a file that doesn't exist - data, err := sysReadFile("testdata/nonexistent_file_that_does_not_exist.txt") + data, err := sysReadFile("../testdata/nonexistent_file_that_does_not_exist.txt") assert.Error(t, err, "sysReadFile should fail for nonexistent file") assert.Nil(t, data, "Data should be nil on error") t.Logf("✓ Nonexistent file error: %v", err) @@ -1296,7 +1297,7 @@ func TestSysReadFile_ErrorPaths(t *testing.T) { t.Run("directory_instead_of_file", func(t *testing.T) { // Try to read a directory instead of a file - data, err := sysReadFile("testdata/sys/class/hwmon") + data, err := sysReadFile("../testdata/sys/class/hwmon") assert.Error(t, err, "sysReadFile should fail when trying to read a directory") assert.Nil(t, data, "Data should be nil on error") t.Logf("✓ Directory read error: %v", err) @@ -1309,12 +1310,12 @@ func TestHwmonPowerMeter_Init_NoZonesFound(t *testing.T) { // Create mock reader that returns empty zones mockReader := &mockHwmonReader{ - zones: []EnergyZone{}, + zones: []device.EnergyZone{}, err: nil, } // Create meter with mock reader - meter, err := NewHwmonPowerMeter("testdata/sys", WithHwmonReader(mockReader)) + meter, err := NewHwmonPowerMeter("../testdata/sys", WithHwmonReader(mockReader)) require.NoError(t, err) // Init should fail when no zones are found @@ -1331,12 +1332,12 @@ func TestHwmonPowerMeter_Zones_ReaderReturnsEmpty(t *testing.T) { // Create mock reader that returns empty zones list without error mockReader := &mockHwmonReader{ - zones: []EnergyZone{}, + zones: []device.EnergyZone{}, err: nil, } // Create meter with mock reader - meter, err := NewHwmonPowerMeter("testdata/sys", WithHwmonReader(mockReader)) + meter, err := NewHwmonPowerMeter("../testdata/sys", WithHwmonReader(mockReader)) require.NoError(t, err) // Zones() should fail when reader returns empty list @@ -1348,7 +1349,7 @@ func TestHwmonPowerMeter_Zones_ReaderReturnsEmpty(t *testing.T) { t.Logf("✓ Zones() correctly failed with: %v", err) } -// TestHwmonPowerZone_Power_FileReadError tests Power() error when file read fails +// TestHwmonPowerZone_Power_FileReadError tests device.Power() error when file read fails func TestHwmonPowerZone_Power_FileReadError(t *testing.T) { t.Logf("\n=== Testing hwmonPowerZone.Power() Error Paths ===") @@ -1356,12 +1357,12 @@ func TestHwmonPowerZone_Power_FileReadError(t *testing.T) { zone := &hwmonPowerZone{ name: "test_zone", index: 0, - path: "testdata/nonexistent_power_file.txt", + path: "../testdata/nonexistent_power_file.txt", } power, err := zone.Power() - assert.Error(t, err, "Power() should fail for nonexistent file") - assert.Equal(t, Power(0), power, "Power should be 0 on error") + assert.Error(t, err, "device.Power() should fail for nonexistent file") + assert.Equal(t, device.Power(0), power, "Power should be 0 on error") assert.Contains(t, err.Error(), "failed to read power", "Error should mention power read failure") t.Logf("✓ Nonexistent file error: %v", err) @@ -1381,8 +1382,8 @@ func TestHwmonPowerZone_Power_FileReadError(t *testing.T) { } power, err := zone.Power() - assert.Error(t, err, "Power() should fail for invalid content") - assert.Equal(t, Power(0), power, "Power should be 0 on error") + assert.Error(t, err, "device.Power() should fail for invalid content") + assert.Equal(t, device.Power(0), power, "Power should be 0 on error") assert.Contains(t, err.Error(), "failed to parse power value", "Error should mention parse failure") t.Logf("✓ Invalid content error: %v", err) @@ -1394,7 +1395,7 @@ func TestSysfsHwmonReader_Zones_HwmonNotAvailable(t *testing.T) { t.Logf("\n=== Testing sysfsHwmonReader.Zones() Error: hwmon Not Available ===") reader := &sysfsHwmonReader{ - basePath: "testdata/nonexistent_hwmon_directory", + basePath: "../testdata/nonexistent_hwmon_directory", } zones, err := reader.Zones() @@ -1407,12 +1408,12 @@ func TestSysfsHwmonReader_Zones_HwmonNotAvailable(t *testing.T) { // TestHwmonPowerMeter_PrimaryEnergyZone_FallbackToFirstZone tests fallback when no priority zones exist func TestHwmonPowerMeter_PrimaryEnergyZone_FallbackToFirstZone(t *testing.T) { - t.Logf("\n=== Testing PrimaryEnergyZone() Fallback to First Zone ===") + t.Logf("\n=== Testing PrimaryEnergyZone() Fallback to Firstdevice.Zone ===") // Create zones with names that don't match any priority names - mockZones := []EnergyZone{ - &mockEnergyZone{name: "unknown_sensor_1", index: 0, power: 10.0, maxEnergy: 1000}, - &mockEnergyZone{name: "unknown_sensor_2", index: 1, power: 20.0, maxEnergy: 1000}, + mockZones := []device.EnergyZone{ + &testMockEnergyZone{name: "unknown_sensor_1", index: 0, power: 10.0, maxEnergy: 1000}, + &testMockEnergyZone{name: "unknown_sensor_2", index: 1, power: 20.0, maxEnergy: 1000}, } mockReader := &mockHwmonReader{ @@ -1420,7 +1421,7 @@ func TestHwmonPowerMeter_PrimaryEnergyZone_FallbackToFirstZone(t *testing.T) { err: nil, } - meter, err := NewHwmonPowerMeter("testdata/sys", WithHwmonReader(mockReader)) + meter, err := NewHwmonPowerMeter("../testdata/sys", WithHwmonReader(mockReader)) require.NoError(t, err) // PrimaryEnergyZone should fall back to first zone @@ -1477,18 +1478,18 @@ func TestGetHumanReadableChipName_ErrorCases(t *testing.T) { t.Logf("\n=== Testing getHumanReadableChipName() Error Cases ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } t.Run("no_name_file", func(t *testing.T) { // Directory without a name file (using dir_fallback test fixture) - _, err := reader.getHumanReadableChipName("testdata/sys/class/hwmon/hwmon_dir_fallback") + _, err := reader.getHumanReadableChipName("../testdata/sys/class/hwmon/hwmon_dir_fallback") assert.Error(t, err, "Should return error when name file doesn't exist") t.Logf("✓ Correctly returned error for missing name file") }) t.Run("nonexistent_directory", func(t *testing.T) { - _, err := reader.getHumanReadableChipName("testdata/nonexistent_hwmon_path") + _, err := reader.getHumanReadableChipName("../testdata/nonexistent_hwmon_path") assert.Error(t, err, "Should return error for nonexistent directory") t.Logf("✓ Correctly returned error for nonexistent directory") }) @@ -1499,7 +1500,7 @@ func TestDiscoverZones_GetChipNameError(t *testing.T) { t.Logf("\n=== Testing discoverZones() When getChipName Fails ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } // Test with a path that will cause getChipName to fail @@ -1527,7 +1528,7 @@ func TestDiscoverZones_GetChipNameError(t *testing.T) { // TestHwmonCalculatedPowerZoneInterface ensures hwmonVoltageCurrentZone implements EnergyZone func TestHwmonCalculatedPowerZoneInterface(t *testing.T) { - var _ EnergyZone = (*hwmonVoltageCurrentZone)(nil) + var _ device.EnergyZone = (*hwmonVoltageCurrentZone)(nil) t.Log("✓ hwmonVoltageCurrentZone implements EnergyZone interface") } @@ -1538,14 +1539,14 @@ func TestHwmonCalculatedPowerZone_Power(t *testing.T) { zone := &hwmonVoltageCurrentZone{ name: "vdd_cpu", index: 1, - voltagePath: "testdata/sys/class/hwmon/hwmon_voltage_current/in1_input", - currentPath: "testdata/sys/class/hwmon/hwmon_voltage_current/curr1_input", + voltagePath: "../testdata/sys/class/hwmon/hwmon_voltage_current/in1_input", + currentPath: "../testdata/sys/class/hwmon/hwmon_voltage_current/curr1_input", chipName: "ina3221", humanName: "ina3221", } power, err := zone.Power() - require.NoError(t, err, "Power() should not return error") + require.NoError(t, err, "device.Power() should not return error") // Expected: 12000 mV × 5000 mA = 60,000,000 µW = 60 W expectedPowerMicrowatts := float64(12000 * 5000) @@ -1584,15 +1585,15 @@ func TestHwmonCalculatedPowerZone_Methods(t *testing.T) { assert.Equal(t, "/path/to/voltage", zone.Path()) t.Logf("✓ Path() = %s", zone.Path()) - // Test Energy() - returns error + // Test device.Energy() - returns error energy, err := zone.Energy() - assert.Error(t, err, "Energy() should return error for calculated power zones") - assert.Equal(t, Energy(0), energy) - t.Logf("✓ Energy() correctly returns error: %v", err) + assert.Error(t, err, "device.Energy() should return error for calculated power zones") + assert.Equal(t, device.Energy(0), energy) + t.Logf("✓ device.Energy() correctly returns error: %v", err) // Test MaxEnergy() - returns 0 maxEnergy := zone.MaxEnergy() - assert.Equal(t, Energy(0), maxEnergy) + assert.Equal(t, device.Energy(0), maxEnergy) t.Logf("✓ MaxEnergy() = %d", maxEnergy) } @@ -1603,16 +1604,16 @@ func TestHwmonCalculatedPowerZone_Power_VoltageReadError(t *testing.T) { zone := &hwmonVoltageCurrentZone{ name: "test_zone", index: 1, - voltagePath: "testdata/nonexistent_voltage_file", - currentPath: "testdata/sys/class/hwmon/hwmon_voltage_current/curr1_input", + voltagePath: "../testdata/nonexistent_voltage_file", + currentPath: "../testdata/sys/class/hwmon/hwmon_voltage_current/curr1_input", chipName: "test_chip", humanName: "test_human", } power, err := zone.Power() - assert.Error(t, err, "Power() should fail when voltage file doesn't exist") + assert.Error(t, err, "device.Power() should fail when voltage file doesn't exist") assert.Contains(t, err.Error(), "failed to read voltage") - assert.Equal(t, Power(0), power) + assert.Equal(t, device.Power(0), power) t.Logf("✓ Correctly returned error: %v", err) } @@ -1623,16 +1624,16 @@ func TestHwmonCalculatedPowerZone_Power_CurrentReadError(t *testing.T) { zone := &hwmonVoltageCurrentZone{ name: "test_zone", index: 1, - voltagePath: "testdata/sys/class/hwmon/hwmon_voltage_current/in1_input", - currentPath: "testdata/nonexistent_current_file", + voltagePath: "../testdata/sys/class/hwmon/hwmon_voltage_current/in1_input", + currentPath: "../testdata/nonexistent_current_file", chipName: "test_chip", humanName: "test_human", } power, err := zone.Power() - assert.Error(t, err, "Power() should fail when current file doesn't exist") + assert.Error(t, err, "device.Power() should fail when current file doesn't exist") assert.Contains(t, err.Error(), "failed to read current") - assert.Equal(t, Power(0), power) + assert.Equal(t, device.Power(0), power) t.Logf("✓ Correctly returned error: %v", err) } @@ -1650,15 +1651,15 @@ func TestHwmonCalculatedPowerZone_Power_InvalidVoltageContent(t *testing.T) { name: "test_zone", index: 1, voltagePath: invalidFile, - currentPath: "testdata/sys/class/hwmon/hwmon_voltage_current/curr1_input", + currentPath: "../testdata/sys/class/hwmon/hwmon_voltage_current/curr1_input", chipName: "test_chip", humanName: "test_human", } power, err := zone.Power() - assert.Error(t, err, "Power() should fail for invalid voltage content") + assert.Error(t, err, "device.Power() should fail for invalid voltage content") assert.Contains(t, err.Error(), "failed to parse voltage") - assert.Equal(t, Power(0), power) + assert.Equal(t, device.Power(0), power) t.Logf("✓ Correctly returned error: %v", err) } @@ -1675,16 +1676,16 @@ func TestHwmonCalculatedPowerZone_Power_InvalidCurrentContent(t *testing.T) { zone := &hwmonVoltageCurrentZone{ name: "test_zone", index: 1, - voltagePath: "testdata/sys/class/hwmon/hwmon_voltage_current/in1_input", + voltagePath: "../testdata/sys/class/hwmon/hwmon_voltage_current/in1_input", currentPath: invalidFile, chipName: "test_chip", humanName: "test_human", } power, err := zone.Power() - assert.Error(t, err, "Power() should fail for invalid current content") + assert.Error(t, err, "device.Power() should fail for invalid current content") assert.Contains(t, err.Error(), "failed to parse current") - assert.Equal(t, Power(0), power) + assert.Equal(t, device.Power(0), power) t.Logf("✓ Correctly returned error: %v", err) } @@ -1693,11 +1694,11 @@ func TestDiscoverVoltageCurrentZones_MatchByLabel(t *testing.T) { t.Logf("\n=== Testing Voltage/Current Discovery with Label Matching ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } // Read files from the test fixture - hwmonPath := "testdata/sys/class/hwmon/hwmon_voltage_current" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_voltage_current" files, err := os.ReadDir(hwmonPath) require.NoError(t, err) @@ -1733,11 +1734,11 @@ func TestDiscoverVoltageCurrentZones_NoLabels(t *testing.T) { t.Logf("\n=== Testing Voltage/Current Discovery Without Labels ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } // This fixture has voltage and current but no labels - hwmonPath := "testdata/sys/class/hwmon/hwmon_voltage_current_no_labels" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_voltage_current_no_labels" files, err := os.ReadDir(hwmonPath) require.NoError(t, err) @@ -1755,10 +1756,10 @@ func TestDiscoverVoltageCurrentZones_MultiplePairs(t *testing.T) { t.Logf("\n=== Testing Voltage/Current Discovery with Multiple Pairs ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } - hwmonPath := "testdata/sys/class/hwmon/hwmon_voltage_current_multi" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_voltage_current_multi" files, err := os.ReadDir(hwmonPath) require.NoError(t, err) @@ -1770,7 +1771,7 @@ func TestDiscoverVoltageCurrentZones_MultiplePairs(t *testing.T) { assert.Equal(t, 2, len(zones), "Should find 2 matched pairs") // Create a map of zone names for verification - zoneNames := make(map[string]EnergyZone) + zoneNames := make(map[string]device.EnergyZone) for _, zone := range zones { zoneNames[zone.Name()] = zone } @@ -1811,7 +1812,7 @@ func TestDiscoverVoltageCurrentZones_PrefersAverageOverInput(t *testing.T) { t.Logf("\n=== Testing Voltage/Current Discovery Prefers _average Over _input ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } // This fixture has both _input and _average files with different values @@ -1819,7 +1820,7 @@ func TestDiscoverVoltageCurrentZones_PrefersAverageOverInput(t *testing.T) { // curr1_input: 5000 mA, curr1_average: 4900 mA // If _average is preferred: Power = 11800 × 4900 = 57,820,000 µW = 57.82 W // If _input was used: Power = 12000 × 5000 = 60,000,000 µW = 60 W - hwmonPath := "testdata/sys/class/hwmon/hwmon_voltage_current_average" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_voltage_current_average" files, err := os.ReadDir(hwmonPath) require.NoError(t, err) @@ -1853,7 +1854,7 @@ func TestDiscoverVoltageCurrentZones_PartialAverageFallsBackToInput(t *testing.T t.Logf("\n=== Testing Partial _average Falls Back to _input for Both ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } // This fixture has: @@ -1862,7 +1863,7 @@ func TestDiscoverVoltageCurrentZones_PartialAverageFallsBackToInput(t *testing.T // Since current lacks _average, both should use _input: // Power = 12000 × 5000 = 60,000,000 µW = 60 W // NOT: 11800 × 5000 = 59 W (which would be mixing average/input) - hwmonPath := "testdata/sys/class/hwmon/hwmon_voltage_current_partial_average" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_voltage_current_partial_average" files, err := os.ReadDir(hwmonPath) require.NoError(t, err) @@ -1894,11 +1895,11 @@ func TestDiscoverZones_FallbackToVoltageCurrentWhenNoPower(t *testing.T) { t.Logf("\n=== Testing discoverZones() Fallback to Voltage/Current ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } // hwmon_voltage_current has no power sensors, only voltage/current pairs - zones, err := reader.discoverZones("testdata/sys/class/hwmon/hwmon_voltage_current") + zones, err := reader.discoverZones("../testdata/sys/class/hwmon/hwmon_voltage_current") require.NoError(t, err, "discoverZones should succeed with voltage/current fallback") require.NotEmpty(t, zones, "Should find zones via voltage/current fallback") @@ -1921,11 +1922,11 @@ func TestDiscoverZones_PreferDirectPowerOverCalculated(t *testing.T) { t.Logf("\n=== Testing discoverZones() Prefers Direct Power ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } // hwmon0 has direct power sensors - zones, err := reader.discoverZones("testdata/sys/class/hwmon/hwmon0") + zones, err := reader.discoverZones("../testdata/sys/class/hwmon/hwmon0") require.NoError(t, err) require.NotEmpty(t, zones) @@ -1949,7 +1950,7 @@ func TestHwmonPowerMeter_WithVoltageCurrentZones(t *testing.T) { require.NoError(t, err) // Copy voltage/current fixture files - srcDir := "testdata/sys/class/hwmon/hwmon_voltage_current" + srcDir := "../testdata/sys/class/hwmon/hwmon_voltage_current" files := []string{"name", "in1_input", "in1_label", "curr1_input", "curr1_label"} for _, f := range files { srcData, err := os.ReadFile(filepath.Join(srcDir, f)) @@ -1995,7 +1996,7 @@ func TestDiscoverVoltageCurrentZones_EmptySensors(t *testing.T) { t.Logf("\n=== Testing Voltage/Current Discovery with No Sensors ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } // Create temporary directory with only temperature sensors @@ -2023,7 +2024,7 @@ func TestDiscoverVoltageCurrentZones_VoltageOnly(t *testing.T) { t.Logf("\n=== Testing Voltage/Current Discovery with Voltage Only ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } tmpDir := t.TempDir() @@ -2052,7 +2053,7 @@ func TestDiscoverVoltageCurrentZones_CurrentOnly(t *testing.T) { t.Logf("\n=== Testing Voltage/Current Discovery with Current Only ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } tmpDir := t.TempDir() @@ -2083,8 +2084,8 @@ func TestHwmonCalculatedPowerZone_MultipleReadings(t *testing.T) { zone := &hwmonVoltageCurrentZone{ name: "vdd_cpu", index: 1, - voltagePath: "testdata/sys/class/hwmon/hwmon_voltage_current/in1_input", - currentPath: "testdata/sys/class/hwmon/hwmon_voltage_current/curr1_input", + voltagePath: "../testdata/sys/class/hwmon/hwmon_voltage_current/in1_input", + currentPath: "../testdata/sys/class/hwmon/hwmon_voltage_current/curr1_input", chipName: "ina3221", humanName: "ina3221", } @@ -2248,10 +2249,10 @@ func TestDiscoverVoltageCurrentZones_INA226ChipRule(t *testing.T) { t.Logf("INA226 has: in0 (shunt), in1 (bus), curr1 - rule: in1 ↔ curr1, skip in0") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } - hwmonPath := "testdata/sys/class/hwmon/hwmon_ina226" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_ina226" files, err := os.ReadDir(hwmonPath) require.NoError(t, err) @@ -2279,10 +2280,10 @@ func TestDiscoverVoltageCurrentZones_INA3221ChipRule(t *testing.T) { t.Logf("Rule: in{N} ↔ curr{N} for N=1..3, skip in4-7") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } - hwmonPath := "testdata/sys/class/hwmon/hwmon_ina3221" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_ina3221" files, err := os.ReadDir(hwmonPath) require.NoError(t, err) @@ -2308,10 +2309,10 @@ func TestDiscoverVoltageCurrentZones_MAX20730ExceptionRule(t *testing.T) { t.Logf("MAX20730 EXCEPTION: curr1 is output current, pairs with in2 (VOUT), not in1") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } - hwmonPath := "testdata/sys/class/hwmon/hwmon_max20730" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_max20730" files, err := os.ReadDir(hwmonPath) require.NoError(t, err) @@ -2343,10 +2344,10 @@ func TestDiscoverVoltageCurrentZones_SameIndexFallback(t *testing.T) { t.Logf("When chip is unknown, fall back to same-index matching: in{N} ↔ curr{N}") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } - hwmonPath := "testdata/sys/class/hwmon/hwmon_same_index" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_same_index" files, err := os.ReadDir(hwmonPath) require.NoError(t, err) @@ -2370,13 +2371,13 @@ func TestDiscoverVoltageCurrentZones_PriorityOrder(t *testing.T) { t.Logf("\n=== Testing Priority Order: Label > Chip Rule > Same-Index ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } // Test 1: Label matching takes priority // hwmon_voltage_current has labels, should use label matching even for known chip t.Run("LabelMatchingTakesPriority", func(t *testing.T) { - hwmonPath := "testdata/sys/class/hwmon/hwmon_voltage_current" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_voltage_current" files, err := os.ReadDir(hwmonPath) require.NoError(t, err) @@ -2394,7 +2395,7 @@ func TestDiscoverVoltageCurrentZones_PriorityOrder(t *testing.T) { // Test 2: Chip rule used when no labels t.Run("ChipRuleWhenNoLabels", func(t *testing.T) { - hwmonPath := "testdata/sys/class/hwmon/hwmon_ina226" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_ina226" files, err := os.ReadDir(hwmonPath) require.NoError(t, err) @@ -2406,7 +2407,7 @@ func TestDiscoverVoltageCurrentZones_PriorityOrder(t *testing.T) { // Test 3: Same-index used when no labels and unknown chip t.Run("SameIndexWhenUnknownChip", func(t *testing.T) { - hwmonPath := "testdata/sys/class/hwmon/hwmon_same_index" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_same_index" files, err := os.ReadDir(hwmonPath) require.NoError(t, err) @@ -2453,34 +2454,34 @@ func TestIsSensorEnabled(t *testing.T) { t.Run("enabled_channel", func(t *testing.T) { // in1_enable = 1 in the disabled_channel test directory - result := isSensorEnabled("testdata/sys/class/hwmon/hwmon_disabled_channel", "in", 1, nil) + result := isSensorEnabled("../testdata/sys/class/hwmon/hwmon_disabled_channel", "in", 1, nil) assert.True(t, result, "Channel with enable=1 should be enabled") t.Logf("✓ Channel with enable=1 correctly identified as enabled") }) t.Run("disabled_channel", func(t *testing.T) { // in3_enable = 0 in the disabled_channel test directory - result := isSensorEnabled("testdata/sys/class/hwmon/hwmon_disabled_channel", "in", 3, nil) + result := isSensorEnabled("../testdata/sys/class/hwmon/hwmon_disabled_channel", "in", 3, nil) assert.False(t, result, "Channel with enable=0 should be disabled") t.Logf("✓ Channel with enable=0 correctly identified as disabled") }) t.Run("no_enable_file", func(t *testing.T) { // hwmon0 doesn't have enable files - should default to enabled - result := isSensorEnabled("testdata/sys/class/hwmon/hwmon0", "power", 1, nil) + result := isSensorEnabled("../testdata/sys/class/hwmon/hwmon0", "power", 1, nil) assert.True(t, result, "Channel without enable file should be considered enabled") t.Logf("✓ Channel without enable file correctly defaults to enabled") }) t.Run("enabled_power_channel", func(t *testing.T) { // power1_enable = 1 in the disabled_power test directory - result := isSensorEnabled("testdata/sys/class/hwmon/hwmon_disabled_power", "power", 1, nil) + result := isSensorEnabled("../testdata/sys/class/hwmon/hwmon_disabled_power", "power", 1, nil) assert.True(t, result, "Power channel with enable=1 should be enabled") }) t.Run("disabled_power_channel", func(t *testing.T) { // power3_enable = 0 in the disabled_power test directory - result := isSensorEnabled("testdata/sys/class/hwmon/hwmon_disabled_power", "power", 3, nil) + result := isSensorEnabled("../testdata/sys/class/hwmon/hwmon_disabled_power", "power", 3, nil) assert.False(t, result, "Power channel with enable=0 should be disabled") }) } @@ -2490,10 +2491,10 @@ func TestDisabledChannelSkipping(t *testing.T) { t.Logf("\n=== Testing Disabled Channel Skipping ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } - hwmonPath := "testdata/sys/class/hwmon/hwmon_disabled_channel" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_disabled_channel" zones, err := reader.discoverZones(hwmonPath) require.NoError(t, err, "discoverZones should not fail") @@ -2522,10 +2523,10 @@ func TestDisabledPowerChannelSkipping(t *testing.T) { t.Logf("\n=== Testing Disabled Power Channel Skipping ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } - hwmonPath := "testdata/sys/class/hwmon/hwmon_disabled_power" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_disabled_power" zones, err := reader.discoverZones(hwmonPath) require.NoError(t, err, "discoverZones should not fail") @@ -2556,12 +2557,12 @@ func TestHwmonEnergyZone_Energy(t *testing.T) { zone := &hwmonEnergyZone{ name: "total", index: 1, - path: "testdata/sys/class/hwmon/hwmon_energy/energy1_input", + path: "../testdata/sys/class/hwmon/hwmon_energy/energy1_input", } energy, err := zone.Energy() require.NoError(t, err) - assert.Equal(t, Energy(500000000), energy, "Should read 500000000 microjoules") + assert.Equal(t, device.Energy(500000000), energy, "Should read 500000000 microjoules") } // TestHwmonEnergyZone_MaxEnergy tests that MaxEnergy returns 0 for energy zones @@ -2569,10 +2570,10 @@ func TestHwmonEnergyZone_MaxEnergy(t *testing.T) { zone := &hwmonEnergyZone{ name: "total", index: 1, - path: "testdata/sys/class/hwmon/hwmon_energy/energy1_input", + path: "../testdata/sys/class/hwmon/hwmon_energy/energy1_input", } - assert.Equal(t, Energy(0), zone.MaxEnergy(), "MaxEnergy should return 0 for hwmon energy zones") + assert.Equal(t, device.Energy(0), zone.MaxEnergy(), "MaxEnergy should return 0 for hwmon energy zones") } // TestHwmonEnergyZone_Power tests that Power returns error for energy zones @@ -2580,11 +2581,11 @@ func TestHwmonEnergyZone_Power(t *testing.T) { zone := &hwmonEnergyZone{ name: "total", index: 1, - path: "testdata/sys/class/hwmon/hwmon_energy/energy1_input", + path: "../testdata/sys/class/hwmon/hwmon_energy/energy1_input", } _, err := zone.Power() - assert.Error(t, err, "Power() should return error for energy zones") + assert.Error(t, err, "device.Power() should return error for energy zones") } // TestHwmonEnergyZone_Methods tests Name, Index, Path accessors @@ -2602,7 +2603,7 @@ func TestHwmonEnergyZone_Methods(t *testing.T) { assert.Equal(t, "/sys/class/hwmon/hwmon0/energy1_input", zone.Path()) } -// TestHwmonEnergyZone_Energy_ReadError tests Energy() when the input file is unreadable +// TestHwmonEnergyZone_Energy_ReadError tests device.Energy() when the input file is unreadable func TestHwmonEnergyZone_Energy_ReadError(t *testing.T) { zone := &hwmonEnergyZone{ name: "total", @@ -2611,11 +2612,11 @@ func TestHwmonEnergyZone_Energy_ReadError(t *testing.T) { } energy, err := zone.Energy() - assert.Error(t, err, "Energy() should return error for unreadable file") - assert.Equal(t, Energy(0), energy, "Energy should be 0 on error") + assert.Error(t, err, "device.Energy() should return error for unreadable file") + assert.Equal(t, device.Energy(0), energy, "Energy should be 0 on error") } -// TestHwmonEnergyZone_Energy_InvalidContent tests Energy() when the input file contains non-numeric data +// TestHwmonEnergyZone_Energy_InvalidContent tests device.Energy() when the input file contains non-numeric data func TestHwmonEnergyZone_Energy_InvalidContent(t *testing.T) { tmpDir := t.TempDir() inputFile := filepath.Join(tmpDir, "energy1_input") @@ -2629,17 +2630,17 @@ func TestHwmonEnergyZone_Energy_InvalidContent(t *testing.T) { } energy, err := zone.Energy() - assert.Error(t, err, "Energy() should return error for non-numeric content") - assert.Equal(t, Energy(0), energy, "Energy should be 0 on parse error") + assert.Error(t, err, "device.Energy() should return error for non-numeric content") + assert.Equal(t, device.Energy(0), energy, "Energy should be 0 on parse error") } // TestDiscoverZones_EnergySensors tests discovery of a single energy sensor func TestDiscoverZones_EnergySensors(t *testing.T) { reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } - zones, err := reader.discoverZones("testdata/sys/class/hwmon/hwmon_energy") + zones, err := reader.discoverZones("../testdata/sys/class/hwmon/hwmon_energy") require.NoError(t, err) require.Len(t, zones, 1, "Should find 1 energy zone") @@ -2650,16 +2651,16 @@ func TestDiscoverZones_EnergySensors(t *testing.T) { energy, err := zone.Energy() require.NoError(t, err) - assert.Equal(t, Energy(500000000), energy) + assert.Equal(t, device.Energy(500000000), energy) } // TestDiscoverZones_EnergySensorsMultiple tests discovery of multiple energy sensors func TestDiscoverZones_EnergySensorsMultiple(t *testing.T) { reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } - zones, err := reader.discoverZones("testdata/sys/class/hwmon/hwmon_energy_multi") + zones, err := reader.discoverZones("../testdata/sys/class/hwmon/hwmon_energy_multi") require.NoError(t, err) require.Len(t, zones, 3, "Should find 3 energy zones") @@ -2679,10 +2680,10 @@ func TestDiscoverZones_EnergySensorsMultiple(t *testing.T) { // TestDiscoverZones_EnergySensorNoLabel tests fallback naming when no label is present func TestDiscoverZones_EnergySensorNoLabel(t *testing.T) { reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } - zones, err := reader.discoverZones("testdata/sys/class/hwmon/hwmon_energy_no_label") + zones, err := reader.discoverZones("../testdata/sys/class/hwmon/hwmon_energy_no_label") require.NoError(t, err) require.Len(t, zones, 1, "Should find 1 energy zone") @@ -2694,10 +2695,10 @@ func TestDiscoverZones_EnergySensorNoLabel(t *testing.T) { // TestDiscoverZones_EnergySensorDisabled tests that disabled energy channels are skipped func TestDiscoverZones_EnergySensorDisabled(t *testing.T) { reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } - zones, err := reader.discoverZones("testdata/sys/class/hwmon/hwmon_energy_disabled") + zones, err := reader.discoverZones("../testdata/sys/class/hwmon/hwmon_energy_disabled") require.NoError(t, err) require.Len(t, zones, 2, "Should find 2 enabled energy zones (energy3 is disabled)") @@ -2785,7 +2786,7 @@ func TestDiscoverZones_EnergyPreferredOverVoltCurr(t *testing.T) { // TestIsSensorEnabled_Energy tests isSensorEnabled with energy sensor enable files func TestIsSensorEnabled_Energy(t *testing.T) { - hwmonPath := "testdata/sys/class/hwmon/hwmon_energy_disabled" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_energy_disabled" // energy1 has enable=1 → enabled assert.True(t, isSensorEnabled(hwmonPath, "energy", 1, nil), "energy1 should be enabled (enable=1)") @@ -2797,6 +2798,25 @@ func TestIsSensorEnabled_Energy(t *testing.T) { assert.False(t, isSensorEnabled(hwmonPath, "energy", 3, nil), "energy3 should be disabled (enable=0)") // No enable file → enabled by default - assert.True(t, isSensorEnabled("testdata/sys/class/hwmon/hwmon_energy", "energy", 1, nil), + assert.True(t, isSensorEnabled("../testdata/sys/class/hwmon/hwmon_energy", "energy", 1, nil), "Should be enabled when no enable file exists") } + +// testMockEnergyZone is a local mock for testing +type testMockEnergyZone struct { + name string + index int + path string + energy device.Energy + maxEnergy device.Energy + power device.Power + err error + powerErr error +} + +func (m *testMockEnergyZone) Name() string { return m.name } +func (m *testMockEnergyZone) Index() int { return m.index } +func (m *testMockEnergyZone) Path() string { return m.path } +func (m *testMockEnergyZone) Energy() (device.Energy, error) { return m.energy, m.err } +func (m *testMockEnergyZone) MaxEnergy() device.Energy { return m.maxEnergy } +func (m *testMockEnergyZone) Power() (device.Power, error) { return m.power, m.powerErr } diff --git a/internal/device/mock_cpu_power_meter.go b/internal/device/cpu/mock_cpu_power_meter.go similarity index 70% rename from internal/device/mock_cpu_power_meter.go rename to internal/device/cpu/mock_cpu_power_meter.go index bee1fee9b6..42eb31d6aa 100644 --- a/internal/device/mock_cpu_power_meter.go +++ b/internal/device/cpu/mock_cpu_power_meter.go @@ -1,38 +1,38 @@ // SPDX-FileCopyrightText: 2025 The Kepler Authors // SPDX-License-Identifier: Apache-2.0 -package device +package cpu // TODO: Move this mock to a separate testutil package import ( + "fmt" "slices" "testing" - "fmt" - "github.com/prometheus/procfs/sysfs" "github.com/stretchr/testify/require" + "github.com/sustainable-computing-io/kepler/internal/device" ) const ( - validSysFSPath = "testdata/sys" - badSysFSPath = "testdata/bad_sysfs" + validSysFSPath = "../testdata/sys" + badSysFSPath = "../testdata/bad_sysfs" ) type ( MockRaplZone struct { - energy Energy + energy device.Energy energyErr error name string index int path string - maxMicroJoules Energy + maxMicroJoules device.Energy } MockPowerZone struct { - power Power + power device.Power powerErr error name string @@ -41,7 +41,7 @@ type ( } ) -func NewMockRaplZone(name string, index int, path string, maxMicroJoules Energy) *MockRaplZone { +func NewMockRaplZone(name string, index int, path string, maxMicroJoules device.Energy) *MockRaplZone { return &MockRaplZone{ name: name, index: index, @@ -62,25 +62,25 @@ func (m MockRaplZone) Name() string { return m.name } -func (m MockRaplZone) Energy() (Energy, error) { +func (m MockRaplZone) Energy() (device.Energy, error) { return m.energy, m.energyErr } -func (m MockRaplZone) MaxEnergy() Energy { +func (m MockRaplZone) MaxEnergy() device.Energy { return m.maxMicroJoules } -func (m MockRaplZone) Power() (Power, error) { +func (m MockRaplZone) Power() (device.Power, error) { // Mock RAPL zones don't provide power return 0, fmt.Errorf("mock rapl zones do not provide power readings") } -func (m *MockRaplZone) OnEnergy(j Energy, err error) { +func (m *MockRaplZone) OnEnergy(j device.Energy, err error) { m.energy = j m.energyErr = err } -func (m *MockRaplZone) Inc(delta Energy) { +func (m *MockRaplZone) Inc(delta device.Energy) { m.energy = (m.energy + delta) % m.maxMicroJoules } @@ -104,26 +104,26 @@ func (m MockPowerZone) Name() string { return m.name } -func (m MockPowerZone) Energy() (Energy, error) { +func (m MockPowerZone) Energy() (device.Energy, error) { // Power zones don't provide energy readings return 0, nil } -func (m MockPowerZone) MaxEnergy() Energy { +func (m MockPowerZone) MaxEnergy() device.Energy { // Power zones don't have max energy return 0 } -func (m MockPowerZone) Power() (Power, error) { +func (m MockPowerZone) Power() (device.Power, error) { return m.power, m.powerErr } -func (m *MockPowerZone) OnPower(power Power, err error) { +func (m *MockPowerZone) OnPower(power device.Power, err error) { m.power = power m.powerErr = err } -func (m *MockPowerZone) SetPower(power Power) { +func (m *MockPowerZone) SetPower(power device.Power) { m.power = power } @@ -141,7 +141,7 @@ func invalidSysFSFixtures(t *testing.T) sysfs.FS { return fs } -func sortedZoneNames(zones []EnergyZone) []string { +func sortedZoneNames(zones []device.EnergyZone) []string { names := make([]string, len(zones)) for i, zone := range zones { names[i] = zone.Name() diff --git a/internal/device/rapl_sysfs_power_meter.go b/internal/device/cpu/rapl_sysfs_power_meter.go similarity index 82% rename from internal/device/rapl_sysfs_power_meter.go rename to internal/device/cpu/rapl_sysfs_power_meter.go index 755e3cbb4f..c2fd7ca9ff 100644 --- a/internal/device/rapl_sysfs_power_meter.go +++ b/internal/device/cpu/rapl_sysfs_power_meter.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 The Kepler Authors // SPDX-License-Identifier: Apache-2.0 -package device +package cpu import ( "fmt" @@ -9,22 +9,23 @@ import ( "strings" "github.com/prometheus/procfs/sysfs" + "github.com/sustainable-computing-io/kepler/internal/device" ) // raplPowerMeter implements CPUPowerMeter using sysfs type raplPowerMeter struct { reader sysfsReader - cachedZones []EnergyZone + cachedZones []device.EnergyZone logger *slog.Logger zoneFilter []string - topZone EnergyZone + topZone device.EnergyZone } type OptionFn func(*raplPowerMeter) // sysfsReader is an interface for a sysfs filesystem used by raplPowerMeter to mock for testing type sysfsReader interface { - Zones() ([]EnergyZone, error) + Zones() ([]device.EnergyZone, error) } // WithSysFSReader sets the sysfsReader used by raplPowerMeter @@ -93,7 +94,7 @@ func (r *raplPowerMeter) needsFiltering() bool { // filterZones applies the configured zone filter // If the filter is empty, all zones are returned -func (r *raplPowerMeter) filterZones(zones []EnergyZone) []EnergyZone { +func (r *raplPowerMeter) filterZones(zones []device.EnergyZone) []device.EnergyZone { if !r.needsFiltering() { return zones } @@ -103,7 +104,7 @@ func (r *raplPowerMeter) filterZones(zones []EnergyZone) []EnergyZone { wanted[strings.ToLower(name)] = true } var included, excluded []string - filtered := make([]EnergyZone, 0, len(zones)) + filtered := make([]device.EnergyZone, 0, len(zones)) for _, zone := range zones { if wanted[strings.ToLower(zone.Name())] { filtered = append(filtered, zone) @@ -116,7 +117,7 @@ func (r *raplPowerMeter) filterZones(zones []EnergyZone) []EnergyZone { return filtered } -func (r *raplPowerMeter) Zones() ([]EnergyZone, error) { +func (r *raplPowerMeter) Zones() ([]device.EnergyZone, error) { // Return cached zones if already initialized if len(r.cachedZones) != 0 { return r.cachedZones, nil @@ -136,9 +137,9 @@ func (r *raplPowerMeter) Zones() ([]EnergyZone, error) { // filter out non-standard zones - stdZoneMap := map[zoneKey]EnergyZone{} + stdZoneMap := map[device.ZoneKey]device.EnergyZone{} for _, zone := range zones { - key := zoneKey{name: zone.Name(), index: zone.Index()} + key := device.ZoneKey{Name: zone.Name(), Index: zone.Index()} // ignore non-standard zones if a standard zone already exists if existingZone, exists := stdZoneMap[key]; exists && isStandardRaplPath(existingZone.Path()) { @@ -154,16 +155,16 @@ func (r *raplPowerMeter) Zones() ([]EnergyZone, error) { // groupZonesByName groups zones by their base name and creates AggregatedZone // instances when multiple zones share the same name (multi-socket systems) -func (r *raplPowerMeter) groupZonesByName(stdZoneMap map[zoneKey]EnergyZone) []EnergyZone { +func (r *raplPowerMeter) groupZonesByName(stdZoneMap map[device.ZoneKey]device.EnergyZone) []device.EnergyZone { // Group zones by base name (e.g., "package", "dram") - zoneGroups := make(map[string][]EnergyZone) + zoneGroups := make(map[string][]device.EnergyZone) for _, zone := range stdZoneMap { name := zone.Name() zoneGroups[name] = append(zoneGroups[name], zone) } - var result []EnergyZone + var result []device.EnergyZone for name, zones := range zoneGroups { if len(zones) == 1 { result = append(result, zones[0]) @@ -171,7 +172,7 @@ func (r *raplPowerMeter) groupZonesByName(stdZoneMap map[zoneKey]EnergyZone) []E } // Multiple zones with same name - create AggregatedZone - aggregated, _ := NewAggregatedZone(zones) + aggregated, _ := device.NewAggregatedZone(zones) result = append(result, aggregated) r.logger.Debug("Created aggregated zone", "name", name, @@ -182,7 +183,7 @@ func (r *raplPowerMeter) groupZonesByName(stdZoneMap map[zoneKey]EnergyZone) []E } // zoneNames returns a slice of zone names for logging -func (r *raplPowerMeter) zoneNames(zones []EnergyZone) []string { +func (r *raplPowerMeter) zoneNames(zones []device.EnergyZone) []string { names := make([]string, len(zones)) for i, zone := range zones { names[i] = fmt.Sprintf("%s-%d", zone.Name(), zone.Index()) @@ -191,7 +192,7 @@ func (r *raplPowerMeter) zoneNames(zones []EnergyZone) []string { } // PrimaryEnergyZone returns the zone with the highest energy coverage/priority -func (r *raplPowerMeter) PrimaryEnergyZone() (EnergyZone, error) { +func (r *raplPowerMeter) PrimaryEnergyZone() (device.EnergyZone, error) { // Return cached zone if already initialized if r.topZone != nil { return r.topZone, nil @@ -206,7 +207,7 @@ func (r *raplPowerMeter) PrimaryEnergyZone() (EnergyZone, error) { return nil, fmt.Errorf("no energy zones available") } - zoneMap := map[string]EnergyZone{} + zoneMap := map[string]device.EnergyZone{} for _, zone := range zones { zoneMap[strings.ToLower(zone.Name())] = zone } @@ -236,14 +237,14 @@ type sysfsRaplReader struct { fs sysfs.FS } -func (r sysfsRaplReader) Zones() ([]EnergyZone, error) { +func (r sysfsRaplReader) Zones() ([]device.EnergyZone, error) { raplZones, err := sysfs.GetRaplZones(r.fs) if err != nil { return nil, fmt.Errorf("failed to read rapl zones: %w", err) } // convert sysfs.RaplZones to EnergyZones - energyZones := make([]EnergyZone, 0, len(raplZones)) + energyZones := make([]device.EnergyZone, 0, len(raplZones)) for _, zone := range raplZones { energyZones = append(energyZones, sysfsRaplZone{zone}) } @@ -273,18 +274,18 @@ func (s sysfsRaplZone) Path() string { } // Energy returns the current energy value -func (s sysfsRaplZone) Energy() (Energy, error) { +func (s sysfsRaplZone) Energy() (device.Energy, error) { mj, err := s.zone.GetEnergyMicrojoules() - return Energy(mj), err + return device.Energy(mj), err } // MaxEnergy returns the maximum energy value before wraparound -func (s sysfsRaplZone) MaxEnergy() Energy { - return Energy(s.zone.MaxMicrojoules) +func (s sysfsRaplZone) MaxEnergy() device.Energy { + return device.Energy(s.zone.MaxMicrojoules) } // Power returns the current power consumption // RAPL zones provide cumulative energy, not instantaneous power -func (s sysfsRaplZone) Power() (Power, error) { +func (s sysfsRaplZone) Power() (device.Power, error) { return 0, fmt.Errorf("RAPL zones do not provide instantaneous power readings") } diff --git a/internal/device/rapl_sysfs_power_meter_test.go b/internal/device/cpu/rapl_sysfs_power_meter_test.go similarity index 83% rename from internal/device/rapl_sysfs_power_meter_test.go rename to internal/device/cpu/rapl_sysfs_power_meter_test.go index 136fd9786b..e304cf39eb 100644 --- a/internal/device/rapl_sysfs_power_meter_test.go +++ b/internal/device/cpu/rapl_sysfs_power_meter_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 The Kepler Authors // SPDX-License-Identifier: Apache-2.0 -package device +package cpu import ( "errors" @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/sustainable-computing-io/kepler/internal/device" ) // TestCPUPowerMeterInterface ensures that raplPowerMeter properly implements the CPUPowerMeter interface @@ -23,7 +24,7 @@ func TestCPUPowerMeterInterface(t *testing.T) { } func TestNewCPUPowerMeter(t *testing.T) { - meter, err := NewCPUPowerMeter("testdata/sys") + meter, err := NewCPUPowerMeter("../testdata/sys") assert.NotNil(t, meter, "NewCPUPowerMeter should not return nil") assert.NoError(t, err, "NewCPUPowerMeter should not return error") assert.IsType(t, &raplPowerMeter{}, meter, "NewCPUPowerMeter should return a *cpuPowerMeter") @@ -89,15 +90,15 @@ func TestSysFSRaplZone_Power(t *testing.T) { zone := sysfsRaplZone{zone: pkg} - // Test that Power() returns an error for RAPL zones + // Test that device.Power() returns an error for RAPL zones power, err := zone.Power() - assert.Error(t, err, "Power() should return an error for RAPL zones") - assert.Equal(t, Power(0), power, "Power() should return 0 when error occurs") + assert.Error(t, err, "device.Power() should return an error for RAPL zones") + assert.Equal(t, device.Power(0), power, "device.Power() should return 0 when error occurs") assert.Contains(t, err.Error(), "RAPL zones do not provide instantaneous power readings", "Error message should explain that RAPL zones don't provide power readings") } -// TestSysFSRaplZone_Power_AllZones tests Power() for multiple RAPL zone types +// TestSysFSRaplZone_Power_AllZones tests device.Power() for multiple RAPL zone types func TestSysFSRaplZone_Power_AllZones(t *testing.T) { testCases := []struct { name string @@ -148,10 +149,10 @@ func TestSysFSRaplZone_Power_AllZones(t *testing.T) { zone := sysfsRaplZone{zone: raplZone} - // All RAPL zones should return an error for Power() + // All RAPL zones should return an error for device.Power() power, err := zone.Power() - assert.Error(t, err, "%s should return error for Power()", tc.zoneName) - assert.Equal(t, Power(0), power, "%s should return 0 for power", tc.zoneName) + assert.Error(t, err, "%s should return error for device.Power()", tc.zoneName) + assert.Equal(t, device.Power(0), power, "%s should return 0 for power", tc.zoneName) assert.Contains(t, err.Error(), "RAPL zones do not provide instantaneous power readings", "%s error message should be correct", tc.zoneName) }) @@ -189,7 +190,7 @@ func TestSysFSRaplPowerMeter(t *testing.T) { // Test that each zone implements the interface correctly assert.NoError(t, err) - // With aggregation: two package zones become one AggregatedZone + one core zone = 2 total + // With aggregation: two package zones become one device.AggregatedZone + one core zone = 2 total assert.Equal(t, 2, len(zones), "find 2 zones after aggregation (package + core)") assert.Equal(t, []string{"core", "package"}, sortedZoneNames(zones), "Expected to find aggregated zones in test fixtures") @@ -197,17 +198,17 @@ func TestSysFSRaplPowerMeter(t *testing.T) { for _, zone := range zones { assert.NotEmpty(t, zone.Name(), "Zone name should not be empty") assert.NotEmpty(t, zone.Path(), "Zone path should not be empty") - assert.GreaterOrEqual(t, zone.MaxEnergy(), 1000.0*Joule, "Max energy should not be negative") + assert.GreaterOrEqual(t, zone.MaxEnergy(), 1000.0*device.Joule, "Max energy should not be negative") - // Zone could be either sysfsRaplZone or AggregatedZone + // Zone could be either sysfsRaplZone or device.AggregatedZone switch z := zone.(type) { case sysfsRaplZone: // Individual zone assert.NotNil(t, z) - case *AggregatedZone: + case *device.AggregatedZone: // Aggregated zone assert.NotNil(t, z) - assert.Equal(t, -1, z.Index(), "AggregatedZone should have index -1") + assert.Equal(t, -1, z.Index(), "device.AggregatedZone should have index -1") default: t.Fatalf("Unexpected zone type: %T", zone) } @@ -217,14 +218,14 @@ func TestSysFSRaplPowerMeter(t *testing.T) { energy, err := zone.Energy() assert.NoError(t, err, zone.Path()) - assert.GreaterOrEqual(t, energy, 1000.0*Joule, "Energy should not be negative") + assert.GreaterOrEqual(t, energy, 1000.0*device.Joule, "Energy should not be negative") } } func TestAggregatedZoneIntegration(t *testing.T) { - // Test that RAPL reader creates AggregatedZone for multiple zones with same name + // Test that RAPL reader creates device.AggregatedZone for multiple zones with same name mockReader := &mockSysFSReader{ - response: []EnergyZone{ + response: []device.EnergyZone{ // Two package zones with same name but different indices and one core zone mockZone{name: "package", index: 0, path: "/intel-rapl:0", energy: 1000, maxEnergy: 100000}, mockZone{name: "package", index: 1, path: "/intel-rapl:1", energy: 2000, maxEnergy: 100000}, @@ -243,9 +244,9 @@ func TestAggregatedZoneIntegration(t *testing.T) { // Should have 2 zones: 1 aggregated package zone + 1 core zone assert.Equal(t, 2, len(zones), "Expected 2 zones after aggregation") - // Find the package zone - should be AggregatedZone - var packageZone EnergyZone - var coreZone EnergyZone + // Find the package zone - should be device.AggregatedZone + var packageZone device.EnergyZone + var coreZone device.EnergyZone for _, zone := range zones { if zone.Name() == "package" { packageZone = zone @@ -256,11 +257,11 @@ func TestAggregatedZoneIntegration(t *testing.T) { // Verify package zone is aggregated require.NotNil(t, packageZone, "Package zone should exist") - aggregated, isAggregated := packageZone.(*AggregatedZone) - assert.True(t, isAggregated, "Package zone should be AggregatedZone") + aggregated, isAggregated := packageZone.(*device.AggregatedZone) + assert.True(t, isAggregated, "Package zone should be device.AggregatedZone") assert.Equal(t, "package", aggregated.Name()) assert.Equal(t, -1, aggregated.Index()) - assert.Equal(t, Energy(200000), aggregated.MaxEnergy()) // Sum of both package zones + assert.Equal(t, device.Energy(200000), aggregated.MaxEnergy()) // Sum of both package zones // Verify core zone is not aggregated require.NotNil(t, coreZone, "Core zone should exist") @@ -270,32 +271,32 @@ func TestAggregatedZoneIntegration(t *testing.T) { // Test energy aggregation packageEnergy, err := packageZone.Energy() require.NoError(t, err) - assert.Equal(t, Energy(3000), packageEnergy) // 1000 + 2000 from both package zones + assert.Equal(t, device.Energy(3000), packageEnergy) // 1000 + 2000 from both package zones } type mockZone struct { name string index int path string - energy Energy - maxEnergy Energy + energy device.Energy + maxEnergy device.Energy } -func (m mockZone) Name() string { return m.name } -func (m mockZone) Index() int { return m.index } -func (m mockZone) Path() string { return m.path } -func (m mockZone) Energy() (Energy, error) { return m.energy, nil } -func (m mockZone) MaxEnergy() Energy { return m.maxEnergy } -func (m mockZone) Power() (Power, error) { +func (m mockZone) Name() string { return m.name } +func (m mockZone) Index() int { return m.index } +func (m mockZone) Path() string { return m.path } +func (m mockZone) Energy() (device.Energy, error) { return m.energy, nil } +func (m mockZone) MaxEnergy() device.Energy { return m.maxEnergy } +func (m mockZone) Power() (device.Power, error) { return 0, fmt.Errorf("rapl zones do not provide power readings") } type mockSysFSReader struct { - response []EnergyZone + response []device.EnergyZone err error } -func (m *mockSysFSReader) Zones() ([]EnergyZone, error) { +func (m *mockSysFSReader) Zones() ([]device.EnergyZone, error) { return m.response, m.err } @@ -346,9 +347,9 @@ type mockRaplReader struct { mock.Mock } -func (m *mockRaplReader) Zones() ([]EnergyZone, error) { +func (m *mockRaplReader) Zones() ([]device.EnergyZone, error) { args := m.Called() - return args.Get(0).([]EnergyZone), args.Error(1) + return args.Get(0).([]device.EnergyZone), args.Error(1) } // TestStandardPathPreference tests that standard paths are preferred over non-standard ones @@ -365,13 +366,13 @@ func TestStandardPathPreference(t *testing.T) { index: 0, } tt := []struct { - zones []EnergyZone - expected EnergyZone + zones []device.EnergyZone + expected device.EnergyZone }{ - {[]EnergyZone{stdPkg}, stdPkg}, - {[]EnergyZone{mmio}, mmio}, - {[]EnergyZone{mmio, stdPkg}, stdPkg}, - {[]EnergyZone{stdPkg, mmio}, stdPkg}, + {[]device.EnergyZone{stdPkg}, stdPkg}, + {[]device.EnergyZone{mmio}, mmio}, + {[]device.EnergyZone{mmio, stdPkg}, stdPkg}, + {[]device.EnergyZone{stdPkg, mmio}, stdPkg}, } for _, test := range tt { @@ -413,7 +414,7 @@ func TestZoneCaching(t *testing.T) { path: "/sys/class/powercap/intel-rapl/intel-rapl:0:0", index: 1, } - raplZones := []EnergyZone{pkg, core} + raplZones := []device.EnergyZone{pkg, core} mockReader := &mockRaplReader{} mockReader.On("Zones").Return(raplZones, nil).Once() @@ -437,7 +438,7 @@ func TestZoneCaching_Error(t *testing.T) { rapl, err := NewCPUPowerMeter(validSysFSPath, WithSysFSReader(mockReader)) t.Run("Zone Read Error", func(t *testing.T) { - mockReader.On("Zones").Return([]EnergyZone(nil), errors.New("error")).Once() + mockReader.On("Zones").Return([]device.EnergyZone(nil), errors.New("error")).Once() assert.NoError(t, err) zones, err := rapl.Zones() assert.Error(t, err) @@ -456,7 +457,7 @@ func TestZoneCaching_Error(t *testing.T) { path: "/sys/class/powercap/intel-rapl/intel-rapl:0:0", index: 1, } - raplZones := []EnergyZone{pkg, core} + raplZones := []device.EnergyZone{pkg, core} t.Run("Zone Read Succeeds", func(t *testing.T) { mockReader.On("Zones").Return(raplZones, nil).Once() for range 3 { @@ -475,7 +476,7 @@ func TestZone_None(t *testing.T) { rapl, err := NewCPUPowerMeter(validSysFSPath, WithSysFSReader(mockReader)) assert.NoError(t, err) - mockReader.On("Zones").Return([]EnergyZone(nil), nil).Once() + mockReader.On("Zones").Return([]device.EnergyZone(nil), nil).Once() zones, err := rapl.Zones() assert.Error(t, err) assert.Equal(t, 0, len(zones)) @@ -493,7 +494,7 @@ func TestNewCPUPowerMeter_InvalidPath(t *testing.T) { func TestCPUPowerMeter_ZonesError(t *testing.T) { mockReader := &mockRaplReader{} expectedErr := errors.New("error") - mockReader.On("Zones").Return([]EnergyZone{}, expectedErr) + mockReader.On("Zones").Return([]device.EnergyZone{}, expectedErr) meter := &raplPowerMeter{reader: mockReader} zones, err := meter.Zones() @@ -507,7 +508,7 @@ func TestCPUPowerMeter_ZonesError(t *testing.T) { // TestCPUPowerMeter_NoZones tests that Zones returns an error when no zones are found func TestCPUPowerMeter_NoZones(t *testing.T) { mockReader := &mockRaplReader{} - mockReader.On("Zones").Return([]EnergyZone{}, nil) + mockReader.On("Zones").Return([]device.EnergyZone{}, nil) meter := &raplPowerMeter{reader: mockReader} zones, err := meter.Zones() @@ -521,7 +522,7 @@ func TestCPUPowerMeter_NoZones(t *testing.T) { // TestCPUPowerMeter_InitNoZones tests that Start returns an error when no zones are found func TestCPUPowerMeter_InitNoZones(t *testing.T) { mockReader := &mockRaplReader{} - mockReader.On("Zones").Return([]EnergyZone{}, nil) + mockReader.On("Zones").Return([]device.EnergyZone{}, nil) meter := &raplPowerMeter{reader: mockReader} err := meter.Init() @@ -536,11 +537,11 @@ func TestPrimaryEnergyZone(t *testing.T) { t.Run("Priority hierarchy", func(t *testing.T) { tests := []struct { name string - zones []EnergyZone + zones []device.EnergyZone expected string }{{ name: "psys has highest priority", - zones: []EnergyZone{ + zones: []device.EnergyZone{ mockZone{name: "package", index: 0}, mockZone{name: "psys", index: 0}, mockZone{name: "core", index: 0}, @@ -548,7 +549,7 @@ func TestPrimaryEnergyZone(t *testing.T) { expected: "psys", }, { name: "package has priority over core", - zones: []EnergyZone{ + zones: []device.EnergyZone{ mockZone{name: "core", index: 0}, mockZone{name: "package", index: 0}, mockZone{name: "dram", index: 0}, @@ -556,7 +557,7 @@ func TestPrimaryEnergyZone(t *testing.T) { expected: "package", }, { name: "core has priority over dram", - zones: []EnergyZone{ + zones: []device.EnergyZone{ mockZone{name: "dram", index: 0}, mockZone{name: "core", index: 0}, mockZone{name: "uncore", index: 0}, @@ -564,7 +565,7 @@ func TestPrimaryEnergyZone(t *testing.T) { expected: "core", }, { name: "dram has priority over uncore", - zones: []EnergyZone{ + zones: []device.EnergyZone{ mockZone{name: "uncore", index: 0}, mockZone{name: "dram", index: 0}, }, @@ -588,7 +589,7 @@ func TestPrimaryEnergyZone(t *testing.T) { t.Run("Case insensitive matching", func(t *testing.T) { mockReader := &mockRaplReader{} - mockReader.On("Zones").Return([]EnergyZone{ + mockReader.On("Zones").Return([]device.EnergyZone{ mockZone{name: "PACKAGE", index: 0}, mockZone{name: "Core", index: 0}, }, nil) @@ -602,7 +603,7 @@ func TestPrimaryEnergyZone(t *testing.T) { }) t.Run("Fallback to first zone", func(t *testing.T) { - zones := []EnergyZone{ + zones := []device.EnergyZone{ mockZone{name: "unknown1", index: 0}, mockZone{name: "unknown2", index: 1}, } @@ -623,7 +624,7 @@ func TestPrimaryEnergyZone(t *testing.T) { t.Run("Caching behavior", func(t *testing.T) { mockReader := &mockRaplReader{} - mockReader.On("Zones").Return([]EnergyZone{ + mockReader.On("Zones").Return([]device.EnergyZone{ mockZone{name: "package", index: 0}, }, nil).Once() @@ -645,7 +646,7 @@ func TestPrimaryEnergyZone(t *testing.T) { t.Run("Error handling", func(t *testing.T) { t.Run("Zones() returns error", func(t *testing.T) { mockReader := &mockRaplReader{} - mockReader.On("Zones").Return([]EnergyZone{}, errors.New("zones error")) + mockReader.On("Zones").Return([]device.EnergyZone{}, errors.New("zones error")) meter := &raplPowerMeter{reader: mockReader, logger: slog.Default()} zone, err := meter.PrimaryEnergyZone() @@ -658,7 +659,7 @@ func TestPrimaryEnergyZone(t *testing.T) { t.Run("Empty zones list", func(t *testing.T) { mockReader := &mockRaplReader{} - mockReader.On("Zones").Return([]EnergyZone{}, nil) + mockReader.On("Zones").Return([]device.EnergyZone{}, nil) meter := &raplPowerMeter{reader: mockReader, logger: slog.Default()} zone, err := meter.PrimaryEnergyZone() diff --git a/internal/device/rapl_zone_filtering_test.go b/internal/device/cpu/rapl_zone_filtering_test.go similarity index 94% rename from internal/device/rapl_zone_filtering_test.go rename to internal/device/cpu/rapl_zone_filtering_test.go index 773f08d6ee..f5afddd441 100644 --- a/internal/device/rapl_zone_filtering_test.go +++ b/internal/device/cpu/rapl_zone_filtering_test.go @@ -1,13 +1,14 @@ // SPDX-FileCopyrightText: 2025 The Kepler Authors // SPDX-License-Identifier: Apache-2.0 -package device +package cpu import ( "log/slog" "testing" "github.com/stretchr/testify/assert" + "github.com/sustainable-computing-io/kepler/internal/device" ) func TestRaplZoneFiltering(t *testing.T) { @@ -33,7 +34,7 @@ func TestRaplZoneFiltering(t *testing.T) { index: 3, } - allZones := []EnergyZone{packageZone, coreZone, dramZone, uncoreZone} + allZones := []device.EnergyZone{packageZone, coreZone, dramZone, uncoreZone} tests := []struct { name string @@ -118,7 +119,7 @@ func TestRaplZoneFiltering_Init(t *testing.T) { energy: 50000, } - allZones := []EnergyZone{packageZone, coreZone} + allZones := []device.EnergyZone{packageZone, coreZone} t.Run("Init succeeds with valid filter", func(t *testing.T) { mockReader := &mockRaplReader{} @@ -166,7 +167,7 @@ func TestRaplZoneFiltering_Zones(t *testing.T) { energy: 50000, } - allZones := []EnergyZone{packageZone, coreZone} + allZones := []device.EnergyZone{packageZone, coreZone} tests := []struct { name string @@ -239,7 +240,7 @@ func TestRaplZoneFiltering_WithOptions(t *testing.T) { maxMicroJoules: 1000000, energy: 50000, } - mockReader.On("Zones").Return([]EnergyZone{packageZone, coreZone}, nil) + mockReader.On("Zones").Return([]device.EnergyZone{packageZone, coreZone}, nil) // Create meter with WithZoneFilter option meter, err := NewCPUPowerMeter( diff --git a/internal/device/cpu_power_meter_test.go b/internal/device/cpu_power_meter_test.go deleted file mode 100644 index d35cad7c0b..0000000000 --- a/internal/device/cpu_power_meter_test.go +++ /dev/null @@ -1,157 +0,0 @@ -// SPDX-FileCopyrightText: 2025 The Kepler Authors -// SPDX-License-Identifier: Apache-2.0 - -package device - -import ( - "log/slog" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/sustainable-computing-io/kepler/config" -) - -func discardLogger() *slog.Logger { - return slog.New(slog.DiscardHandler) -} - -func TestCreateCPUMeter_FakeOnly(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Cpu.PreferredMeters = []string{"fake"} - - meter, err := CreateCPUMeter(discardLogger(), cfg) - require.NoError(t, err) - require.NotNil(t, meter) - assert.Equal(t, "fake-cpu-meter", meter.Name()) -} - -func TestCreateCPUMeter_UnknownBackend(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Cpu.PreferredMeters = []string{"rappl"} - - meter, err := CreateCPUMeter(discardLogger(), cfg) - require.Error(t, err) - assert.Nil(t, meter) - assert.Contains(t, err.Error(), "unknown cpu meter") -} - -func TestCreateCPUMeter_FallthroughToFake(t *testing.T) { - // Unknown name, then fake — exercises the factory-error path - // and the success-after-continue branch. - cfg := config.DefaultConfig() - cfg.Cpu.PreferredMeters = []string{"rappl", "fake"} - - meter, err := CreateCPUMeter(discardLogger(), cfg) - require.NoError(t, err) - require.NotNil(t, meter) - assert.Equal(t, "fake-cpu-meter", meter.Name()) -} - -func TestCreateCPUMeter_EmptyMeters(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Cpu.PreferredMeters = nil - - meter, err := CreateCPUMeter(discardLogger(), cfg) - require.Error(t, err) - assert.Nil(t, meter) - assert.Contains(t, err.Error(), "cpu.preferredMeters is empty") -} - -func TestCreateCPUMeter_RaplFactoryError_FallsThroughToFake(t *testing.T) { - // A bogus sysfs path makes NewCPUPowerMeter (rapl) fail at construction - // because sysfs.NewFS validates the path. Falls through to fake. - cfg := config.DefaultConfig() - cfg.Host.SysFS = "/nonexistent/sysfs/path" - cfg.Cpu.PreferredMeters = []string{"rapl", "fake"} - cfg.Rapl.Zones = []string{"package"} // exercise the "rapl zones are filtered" log line - - meter, err := CreateCPUMeter(discardLogger(), cfg) - require.NoError(t, err) - require.NotNil(t, meter) - assert.Equal(t, "fake-cpu-meter", meter.Name()) -} - -func TestCreateCPUMeter_HwmonInitError_FallsThroughToFake(t *testing.T) { - // hwmon's NewHwmonPowerMeter constructs successfully on any path; Init - // fails when the path has no hwmon zones. Exercises the Init-error path - // in CreateCPUMeter and the experimental-config wiring in buildCPUMeter - // (zones + chip rules). - cfg := config.DefaultConfig() - cfg.Host.SysFS = "/nonexistent/sysfs/path" - cfg.Cpu.PreferredMeters = []string{"hwmon", "fake"} - cfg.Experimental = &config.Experimental{} - cfg.Experimental.Hwmon.Zones = []string{"power1"} - cfg.Experimental.Hwmon.ChipRules = []config.ChipPairingRule{ - {Name: "ina3221", UseSameIndex: true}, - } - - meter, err := CreateCPUMeter(discardLogger(), cfg) - require.NoError(t, err) - require.NotNil(t, meter) - assert.Equal(t, "fake-cpu-meter", meter.Name()) -} - -func TestCreateCPUMeter_AllFail_AggregatedError(t *testing.T) { - // Both rapl and hwmon fail (bogus sysfs); no fallback. Aggregated error. - cfg := config.DefaultConfig() - cfg.Host.SysFS = "/nonexistent/sysfs/path" - cfg.Cpu.PreferredMeters = []string{"rapl", "hwmon"} - - meter, err := CreateCPUMeter(discardLogger(), cfg) - require.Error(t, err) - assert.Nil(t, meter) - assert.Contains(t, err.Error(), "rapl") - assert.Contains(t, err.Error(), "hwmon") -} - -func TestBuildCPUMeter_Fake(t *testing.T) { - cfg := config.DefaultConfig() - - meter, err := buildCPUMeter("fake", discardLogger(), cfg) - require.NoError(t, err) - require.NotNil(t, meter) - assert.Equal(t, "fake-cpu-meter", meter.Name()) -} - -func TestBuildCPUMeter_Rapl_FactoryFails(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Host.SysFS = "/nonexistent" - - meter, err := buildCPUMeter("rapl", discardLogger(), cfg) - require.Error(t, err) - assert.Nil(t, meter) -} - -func TestBuildCPUMeter_Hwmon_ConstructsWithExperimentalConfig(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Experimental = &config.Experimental{} - cfg.Experimental.Hwmon.Zones = []string{"power1", "power2"} - cfg.Experimental.Hwmon.ChipRules = []config.ChipPairingRule{ - {Name: "ltc2945", Pairings: map[int]int{1: 1}}, - } - - meter, err := buildCPUMeter("hwmon", discardLogger(), cfg) - require.NoError(t, err) - require.NotNil(t, meter) - assert.Equal(t, "hwmon", meter.Name()) -} - -func TestBuildCPUMeter_Hwmon_NilExperimental(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Experimental = nil - - meter, err := buildCPUMeter("hwmon", discardLogger(), cfg) - require.NoError(t, err) - require.NotNil(t, meter) -} - -func TestBuildCPUMeter_Unknown(t *testing.T) { - cfg := config.DefaultConfig() - - meter, err := buildCPUMeter("nope", discardLogger(), cfg) - require.Error(t, err) - assert.Nil(t, meter) - assert.Contains(t, err.Error(), "unknown cpu meter") -} diff --git a/internal/device/energy_zone.go b/internal/device/energy_zone.go index b2d73cb5aa..9e8e47e3d6 100644 --- a/internal/device/energy_zone.go +++ b/internal/device/energy_zone.go @@ -22,10 +22,10 @@ const ( ZonePP1 Zone = "pp1" // Power Plane 1 - uncore (e.g., integrated GPU) ) -// zoneKey uniquely identifies a zone by name and index -type zoneKey struct { - name string - index int +// ZoneKey uniquely identifies a zone by name and index +type ZoneKey struct { + Name string + Index int } // AggregatedZone implements EnergyZone interface by aggregating multiple zones @@ -36,7 +36,7 @@ type AggregatedZone struct { name string index int zones []EnergyZone - lastReadings map[zoneKey]Energy + lastReadings map[ZoneKey]Energy currentEnergy Energy // Aggregated energy counter maxEnergy Energy // Cached sum of all zone MaxEnergy values mu sync.RWMutex @@ -70,7 +70,7 @@ func NewAggregatedZone(zones []EnergyZone) (*AggregatedZone, error) { name: name, index: -1, // Indicates this is an aggregated zone zones: zones, - lastReadings: make(map[zoneKey]Energy), + lastReadings: make(map[ZoneKey]Energy), currentEnergy: 0, maxEnergy: totalMax, // Cache the combined MaxEnergy }, nil @@ -86,6 +86,11 @@ func (az *AggregatedZone) Index() int { return az.index } +// Count returns the number of zones aggregated +func (az *AggregatedZone) Count() int { + return len(az.zones) +} + // Path returns path for the aggregated zone func (az *AggregatedZone) Path() string { // TODO: decide if all the paths should be returned @@ -106,7 +111,7 @@ func (az *AggregatedZone) Energy() (Energy, error) { return 0, fmt.Errorf("no valid energy readings from aggregated zones - %s: %w", zone.Name(), err) } - zoneID := zoneKey{zone.Name(), zone.Index()} + zoneID := ZoneKey{zone.Name(), zone.Index()} if lastReading, exists := az.lastReadings[zoneID]; exists { diff --git a/internal/device/energy_zone_test.go b/internal/device/energy_zone_test.go index e30c5c4c64..fdf1197503 100644 --- a/internal/device/energy_zone_test.go +++ b/internal/device/energy_zone_test.go @@ -485,7 +485,7 @@ func TestAggregatedZone_StateManagement(t *testing.T) { _, err = az.Energy() require.NoError(t, err) - zoneID := zoneKey{"package", 0} + zoneID := ZoneKey{"package", 0} lastReading := az.lastReadings[zoneID] assert.Equal(t, Energy(500), lastReading) @@ -509,7 +509,7 @@ func TestAggregatedZone_StateManagement(t *testing.T) { assert.Len(t, az.lastReadings, 1) // Verify zone ID format - expectedZoneID := zoneKey{"package", 0} + expectedZoneID := ZoneKey{"package", 0} _, exists := az.lastReadings[expectedZoneID] assert.True(t, exists, "Expected zone ID %v to exist", expectedZoneID) }) @@ -540,8 +540,8 @@ func TestAggregatedZone_StateManagement(t *testing.T) { assert.Len(t, az.lastReadings, 2) // Verify both zone IDs exist - zoneKey0 := zoneKey{"package", 0} - zoneKey1 := zoneKey{"package", 1} + zoneKey0 := ZoneKey{"package", 0} + zoneKey1 := ZoneKey{"package", 1} _, exists0 := az.lastReadings[zoneKey0] _, exists1 := az.lastReadings[zoneKey1] diff --git a/internal/device/power_meter.go b/internal/device/power_meter.go index 7b3c2aba7d..fd592ae835 100644 --- a/internal/device/power_meter.go +++ b/internal/device/power_meter.go @@ -14,3 +14,30 @@ type PowerMeter interface { service.Service // Name() service.Initializer // Init() } + +// EnergyZone represents a measurable energy or power zone/domain exposed by a power meter. +// An EnergyZone typically represents a logical zone of the hardware unit, e.g. cpu core, cpu package +// dram, uncore etc. +// Reference: https://firefox-source-docs.mozilla.org/performance/power_profiling_overview.html +type EnergyZone interface { + // Name() returns the zone name + Name() string + + // Index() returns the index of the zone + Index() int + + // Path() returns the path from which the energy usage value ie being read + Path() string + + // Energy() returns energy consumed by the zone. + Energy() (Energy, error) + + // MaxEnergy returns the maximum value of energy usage that can be read. + // When energy usage reaches this value, the energy value returned by Energy() + // will wrap around and start again from zero. + MaxEnergy() Energy + + // Power() returns the current power consumption by the zone. + // This method is used for zones that provide instantaneous power readings. + Power() (Power, error) +} diff --git a/internal/exporter/prometheus/collector/power_collector_concurrency_test.go b/internal/exporter/prometheus/collector/power_collector_concurrency_test.go index b8f50f2061..6f4c988ab3 100644 --- a/internal/exporter/prometheus/collector/power_collector_concurrency_test.go +++ b/internal/exporter/prometheus/collector/power_collector_concurrency_test.go @@ -20,6 +20,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/sustainable-computing-io/kepler/config" "github.com/sustainable-computing-io/kepler/internal/device" + "github.com/sustainable-computing-io/kepler/internal/device/cpu" "github.com/sustainable-computing-io/kepler/internal/monitor" ) @@ -38,7 +39,7 @@ func TestPowerCollectorConcurrency(t *testing.T) { ri.SetExpectations(t, tr) ri.On("Refresh").Return(nil) fakeMonitor := monitor.NewPowerMonitor( - musT(device.NewFakeCPUMeter(nil)), + musT(cpu.NewFakeCPUMeter(nil)), monitor.WithResourceInformer(ri), ) collector := NewPowerCollector(fakeMonitor, "test-node", newLogger(), config.MetricsLevelAll) @@ -114,9 +115,9 @@ func TestPowerCollectorConcurrency(t *testing.T) { func TestPowerCollectorWithRegistry(t *testing.T) { mockMonitor := NewMockPowerMonitor() - package0Zone := device.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) - package1Zone := device.NewMockRaplZone("package", 1, "/sys/class/powercap/intel-rapl/intel-rapl:1", 1000) - dramZone := device.NewMockRaplZone("dram", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0:1", 1000) + package0Zone := cpu.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) + package1Zone := cpu.NewMockRaplZone("package", 1, "/sys/class/powercap/intel-rapl/intel-rapl:1", 1000) + dramZone := cpu.NewMockRaplZone("dram", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0:1", 1000) nodePkgAbs := 12300 * device.Joule nodePkgDelta := 123 * device.Joule @@ -241,7 +242,7 @@ func TestUpdateDuringCollection(t *testing.T) { collectingCh := make(chan struct{}) allowCollectCh := make(chan struct{}) - packageZone := device.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) + packageZone := cpu.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) mockMonitor.On("Snapshot").Run(func(args mock.Arguments) { // NOTE: this waits for allow collect to close @@ -336,7 +337,7 @@ func TestConcurrentRegistration(t *testing.T) { ri.On("Refresh").Return(nil) fakeMonitor := monitor.NewPowerMonitor( - musT(device.NewFakeCPUMeter(nil)), + musT(cpu.NewFakeCPUMeter(nil)), monitor.WithResourceInformer(ri), ) @@ -407,7 +408,7 @@ func TestFastCollectAndDescribe(t *testing.T) { ri.On("Refresh").Return(nil) fakeMonitor := monitor.NewPowerMonitor( - musT(device.NewFakeCPUMeter(nil)), + musT(cpu.NewFakeCPUMeter(nil)), monitor.WithResourceInformer(ri), ) collector := NewPowerCollector(fakeMonitor, "test-node", newLogger(), config.MetricsLevelAll) diff --git a/internal/exporter/prometheus/collector/power_collector_test.go b/internal/exporter/prometheus/collector/power_collector_test.go index 3a33b059cf..52440d74fd 100644 --- a/internal/exporter/prometheus/collector/power_collector_test.go +++ b/internal/exporter/prometheus/collector/power_collector_test.go @@ -18,6 +18,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/sustainable-computing-io/kepler/config" "github.com/sustainable-computing-io/kepler/internal/device" + "github.com/sustainable-computing-io/kepler/internal/device/cpu" "github.com/sustainable-computing-io/kepler/internal/monitor" "github.com/sustainable-computing-io/kepler/internal/resource" ) @@ -179,8 +180,8 @@ func TestPowerCollector(t *testing.T) { mockMonitor := NewMockPowerMonitor() // Setup test zones - packageZone := device.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) - dramZone := device.NewMockRaplZone("dram", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0:1", 1000) + packageZone := cpu.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) + dramZone := cpu.NewMockRaplZone("dram", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0:1", 1000) nodePkgAbs := 12300 * device.Joule nodePkgDelta := 123 * device.Joule @@ -606,7 +607,7 @@ func TestTerminatedProcessExport(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) mockMonitor := NewMockPowerMonitor() - packageZone := device.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) + packageZone := cpu.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) testSnapshot := &monitor.Snapshot{ Timestamp: time.Now(), @@ -712,7 +713,7 @@ func TestEnhancedErrorReporting(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) mockMonitor := NewMockPowerMonitor() - packageZone := device.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) + packageZone := cpu.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) testSnapshot := &monitor.Snapshot{ Timestamp: time.Now(), @@ -846,7 +847,7 @@ func TestPowerCollector_MetricsLevelFiltering(t *testing.T) { mockMonitor := NewMockPowerMonitor() // Create test data with all types of metrics - packageZone := device.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) + packageZone := cpu.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) testData := &monitor.Snapshot{ Timestamp: time.Now(), Node: &monitor.Node{ @@ -958,7 +959,7 @@ func TestTerminatedContainerExport(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) mockMonitor := NewMockPowerMonitor() - packageZone := device.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) + packageZone := cpu.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) testSnapshot := &monitor.Snapshot{ Timestamp: time.Now(), @@ -1092,7 +1093,7 @@ func TestTerminatedVMExport(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) mockMonitor := NewMockPowerMonitor() - packageZone := device.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) + packageZone := cpu.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) testSnapshot := &monitor.Snapshot{ Timestamp: time.Now(), diff --git a/internal/exporter/stdout/stdout_test.go b/internal/exporter/stdout/stdout_test.go index c5dddbc126..7013e9673a 100644 --- a/internal/exporter/stdout/stdout_test.go +++ b/internal/exporter/stdout/stdout_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/sustainable-computing-io/kepler/internal/device" + "github.com/sustainable-computing-io/kepler/internal/device/cpu" "github.com/sustainable-computing-io/kepler/internal/monitor" ) @@ -152,8 +153,8 @@ func getTestNodeSnapshot() *monitor.Snapshot { func getTestNodeData() *monitor.Node { // Setup test zones - packageZone := device.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) - dramZone := device.NewMockRaplZone("dram", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0:1", 1000) + packageZone := cpu.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) + dramZone := cpu.NewMockRaplZone("dram", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0:1", 1000) nodePkgAbs := 12300 * device.Joule nodePkgPower := 12 * device.Watt diff --git a/internal/monitor/mock_utils.go b/internal/monitor/mock_utils.go index 162d11abbb..3f83228bdd 100644 --- a/internal/monitor/mock_utils.go +++ b/internal/monitor/mock_utils.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/sustainable-computing-io/kepler/internal/device" + "github.com/sustainable-computing-io/kepler/internal/device/cpu" "github.com/sustainable-computing-io/kepler/internal/resource" ) @@ -18,14 +19,14 @@ type MockCPUPowerMeter struct { mock.Mock } -func (m *MockCPUPowerMeter) Zones() ([]EnergyZone, error) { +func (m *MockCPUPowerMeter) Zones() ([]device.EnergyZone, error) { args := m.Called() - return args.Get(0).([]EnergyZone), args.Error(1) + return args.Get(0).([]device.EnergyZone), args.Error(1) } -func (m *MockCPUPowerMeter) PrimaryEnergyZone() (EnergyZone, error) { +func (m *MockCPUPowerMeter) PrimaryEnergyZone() (device.EnergyZone, error) { args := m.Called() - return args.Get(0).(EnergyZone), args.Error(1) + return args.Get(0).(device.EnergyZone), args.Error(1) } func (m *MockCPUPowerMeter) Name() string { @@ -62,17 +63,17 @@ func (m *MockEnergyZone) Path() string { return args.String(0) } -func (m *MockEnergyZone) Energy() (Energy, error) { +func (m *MockEnergyZone) Energy() (device.Energy, error) { args := m.Called() return args.Get(0).(Energy), args.Error(1) } -func (m *MockEnergyZone) MaxEnergy() Energy { +func (m *MockEnergyZone) MaxEnergy() device.Energy { args := m.Called() return args.Get(0).(Energy) } -func (m *MockEnergyZone) Power() (Power, error) { +func (m *MockEnergyZone) Power() (device.Power, error) { args := m.Called() return args.Get(0).(Power), args.Error(1) } @@ -154,14 +155,14 @@ var _ resource.Informer = (*MockResourceInformer)(nil) // Helper functions for creating test data // CreateTestZones creates mock energy zones for testing -func CreateTestZones() []EnergyZone { - pkg := device.NewMockRaplZone("package-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000*Joule) - core := device.NewMockRaplZone("core-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0/intel-rapl:0:0", 500*Joule) - return []EnergyZone{pkg, core} +func CreateTestZones() []device.EnergyZone { + pkg := cpu.NewMockRaplZone("package-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000*Joule) + core := cpu.NewMockRaplZone("core-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0/intel-rapl:0:0", 500*Joule) + return []device.EnergyZone{pkg, core} } // createNodeSnapshot creates a node snapshot with realistic power values -func createNodeSnapshot(zones []EnergyZone, timestamp time.Time, usageRatio float64) *Node { +func createNodeSnapshot(zones []device.EnergyZone, timestamp time.Time, usageRatio float64) *Node { node := &Node{ Timestamp: timestamp, UsageRatio: usageRatio, diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index eb608e3aaa..883929c7af 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -11,7 +11,7 @@ import ( "sync/atomic" "time" - "github.com/sustainable-computing-io/kepler/internal/device" + "github.com/sustainable-computing-io/kepler/internal/device/cpu" "github.com/sustainable-computing-io/kepler/internal/device/gpu" "github.com/sustainable-computing-io/kepler/internal/resource" "github.com/sustainable-computing-io/kepler/internal/service" @@ -40,7 +40,7 @@ type Service interface { type PowerMonitor struct { // passed externally logger *slog.Logger - cpu device.CPUPowerMeter + cpu cpu.CPUPowerMeter gpuMeters []gpu.GPUPowerMeter // optional, empty if no GPUs available interval time.Duration @@ -87,7 +87,7 @@ type PowerMonitor struct { var _ Service = (*PowerMonitor)(nil) // NewPowerMonitor creates a new PowerMonitor instance -func NewPowerMonitor(meter device.CPUPowerMeter, applyOpts ...OptionFn) *PowerMonitor { +func NewPowerMonitor(meter cpu.CPUPowerMeter, applyOpts ...OptionFn) *PowerMonitor { opts := DefaultOpts() for _, apply := range applyOpts { apply(&opts) diff --git a/internal/monitor/monitor_concurrency_test.go b/internal/monitor/monitor_concurrency_test.go index 6edd2b1c87..da72ac5641 100644 --- a/internal/monitor/monitor_concurrency_test.go +++ b/internal/monitor/monitor_concurrency_test.go @@ -17,13 +17,14 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/sustainable-computing-io/kepler/internal/device" + "github.com/sustainable-computing-io/kepler/internal/device/cpu" testingclock "k8s.io/utils/clock/testing" ) // TestSnapshotThreadSafety tests that multiple goroutines can call Snapshot concurrently without races. func TestSnapshotThreadSafety(t *testing.T) { fakeClock := testingclock.NewFakeClock(time.Now()) - fakeMeter, err := device.NewFakeCPUMeter(nil) + fakeMeter, err := cpu.NewFakeCPUMeter(nil) require.NoError(t, err) tr := CreateTestResources() resourceInformer := &MockResourceInformer{} @@ -143,7 +144,7 @@ func TestFreshSnapshotCaching(t *testing.T) { func TestStaleSnapshotRefreshing(t *testing.T) { // repeat the above using fake cpu meter fakeClock := testingclock.NewFakeClock(time.Now()) - fakeMeter, err := device.NewFakeCPUMeter(nil) + fakeMeter, err := cpu.NewFakeCPUMeter(nil) require.NoError(t, err) tr := CreateTestResources() diff --git a/internal/monitor/monitor_test.go b/internal/monitor/monitor_test.go index 4f9ea04ac3..5ef3b95b6d 100644 --- a/internal/monitor/monitor_test.go +++ b/internal/monitor/monitor_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/sustainable-computing-io/kepler/internal/device" + "github.com/sustainable-computing-io/kepler/internal/device/cpu" "github.com/sustainable-computing-io/kepler/internal/resource" testingclock "k8s.io/utils/clock/testing" ) @@ -82,15 +83,15 @@ func TestPowerMonitor_Init(t *testing.T) { mockPowerMeter.On("PrimaryEnergyZone").Return(pkg, nil) mockPowerMeter.On("Name").Return("mock-cpu") - fakePowerMeter, err := device.NewFakeCPUMeter(nil) + fakePowerMeter, err := cpu.NewFakeCPUMeter(nil) require.NoError(t, err) - powerMeters := []device.CPUPowerMeter{ + powerMeters := []cpu.CPUPowerMeter{ mockPowerMeter, fakePowerMeter, } - zoneNamesFromMeter := func(meter device.CPUPowerMeter) []string { + zoneNamesFromMeter := func(meter cpu.CPUPowerMeter) []string { energyZones, err := meter.Zones() if err != nil { log.Fatal(err) @@ -172,7 +173,7 @@ func TestPowerMonitor_Snapshot(t *testing.T) { } func TestPowerMonitor_InitZones(t *testing.T) { - fakePowerMeter, err := device.NewFakeCPUMeter(nil) + fakePowerMeter, err := cpu.NewFakeCPUMeter(nil) require.NoError(t, err, "failed to create fake power meter") monitor := NewPowerMonitor(fakePowerMeter) @@ -407,11 +408,11 @@ func TestPowerMonitor_FullInitRunShutdownCycle(t *testing.T) { func TestMonitorRefreshSnapshot(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) - pkg := device.NewMockRaplZone( + pkg := cpu.NewMockRaplZone( "package-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 200*Joule) - core := device.NewMockRaplZone( + core := cpu.NewMockRaplZone( "core-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0/intel-rapl:0:0", 150*Joule) testZones := []EnergyZone{pkg, core} @@ -612,11 +613,11 @@ func TestRefreshSnapshotError(t *testing.T) { t.Run("Fix first read", func(t *testing.T) { mockCPUPowerMeter.ExpectedCalls = nil - pkg := device.NewMockRaplZone( + pkg := cpu.NewMockRaplZone( "package-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 200*Joule) - core := device.NewMockRaplZone( + core := cpu.NewMockRaplZone( "core-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0/intel-rapl:0:0", 150*Joule) testZones := []EnergyZone{pkg, core} @@ -648,11 +649,11 @@ func TestRefreshSnapshotError(t *testing.T) { t.Run("Fix computePower", func(t *testing.T) { mockCPUPowerMeter.ExpectedCalls = nil - pkg := device.NewMockRaplZone( + pkg := cpu.NewMockRaplZone( "package-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 200*Joule) - core := device.NewMockRaplZone( + core := cpu.NewMockRaplZone( "core-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0/intel-rapl:0:0", 150*Joule) testZones := []EnergyZone{pkg, core} diff --git a/internal/monitor/node_test.go b/internal/monitor/node_test.go index f0fef526f8..b99edebcf6 100644 --- a/internal/monitor/node_test.go +++ b/internal/monitor/node_test.go @@ -12,15 +12,15 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/sustainable-computing-io/kepler/internal/device" + "github.com/sustainable-computing-io/kepler/internal/device/cpu" "github.com/sustainable-computing-io/kepler/internal/resource" test_clock "k8s.io/utils/clock/testing" ) type ( - MockRaplZone = device.MockRaplZone - MockPowerZone = device.MockPowerZone + MockRaplZone = cpu.MockRaplZone + MockPowerZone = cpu.MockPowerZone ) // TestNodePowerCollection tests the PowerMonitor.collectNodePower method @@ -29,11 +29,11 @@ func TestNodePowerCollection(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) // Create test zones - pkg := device.NewMockRaplZone( + pkg := cpu.NewMockRaplZone( "package-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 200*Joule) - core := device.NewMockRaplZone( + core := cpu.NewMockRaplZone( "core-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0/intel-rapl:0:0", 150*Joule) testZones := []EnergyZone{pkg, core} @@ -213,11 +213,11 @@ func TestNodeErrorHandling(t *testing.T) { // Create a logger that writes to nowhere for testing logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) - pkg := device.NewMockRaplZone( + pkg := cpu.NewMockRaplZone( "package-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 200*Joule) - core := device.NewMockRaplZone( + core := cpu.NewMockRaplZone( "core-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0/intel-rapl:0:0", 150*Joule) testZones := []EnergyZone{pkg, core} @@ -375,7 +375,7 @@ func TestCalculateEnergyDelta(t *testing.T) { func TestNodeActiveEnergyCounterBehavior(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) - pkg := device.NewMockRaplZone( + pkg := cpu.NewMockRaplZone( "package-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000*Joule) @@ -551,7 +551,7 @@ func TestNodeActiveEnergyTotalAccumulation(t *testing.T) { // over multiple measurements, and that activeEnergy represents only the current interval logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) - pkg := device.NewMockRaplZone( + pkg := cpu.NewMockRaplZone( "package-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000*Joule) @@ -695,8 +695,8 @@ func TestPowerSensorCollection(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) // Create test power zones (hwmon-style) - hwmon0 := device.NewMockPowerZone("hwmon0", 0, "/sys/class/hwmon/hwmon0") - hwmon1 := device.NewMockPowerZone("hwmon1", 1, "/sys/class/hwmon/hwmon1") + hwmon0 := cpu.NewMockPowerZone("hwmon0", 0, "/sys/class/hwmon/hwmon0") + hwmon1 := cpu.NewMockPowerZone("hwmon1", 1, "/sys/class/hwmon/hwmon1") testZones := []EnergyZone{hwmon0, hwmon1} mockCPUPowerMeter := &MockCPUPowerMeter{} @@ -827,8 +827,8 @@ func TestMixedSensorCollection(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) // Create mixed zones: RAPL (energy) and hwmon (power) - raplPkg := device.NewMockRaplZone("package-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 200*Joule) - hwmon0 := device.NewMockPowerZone("hwmon0", 0, "/sys/class/hwmon/hwmon0") + raplPkg := cpu.NewMockRaplZone("package-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 200*Joule) + hwmon0 := cpu.NewMockPowerZone("hwmon0", 0, "/sys/class/hwmon/hwmon0") testZones := []EnergyZone{raplPkg, hwmon0} mockCPUPowerMeter := &MockCPUPowerMeter{} @@ -909,8 +909,8 @@ func TestMixedSensorCollection(t *testing.T) { func TestPowerSensorErrorHandling(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) - hwmon0 := device.NewMockPowerZone("hwmon0", 0, "/sys/class/hwmon/hwmon0") - hwmon1 := device.NewMockPowerZone("hwmon1", 1, "/sys/class/hwmon/hwmon1") + hwmon0 := cpu.NewMockPowerZone("hwmon0", 0, "/sys/class/hwmon/hwmon0") + hwmon1 := cpu.NewMockPowerZone("hwmon1", 1, "/sys/class/hwmon/hwmon1") testZones := []EnergyZone{hwmon0, hwmon1} mockCPUPowerMeter := &MockCPUPowerMeter{} @@ -1000,7 +1000,7 @@ func TestPowerSensorErrorHandling(t *testing.T) { func TestPowerSensorActiveIdleSplit(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) - hwmon0 := device.NewMockPowerZone("hwmon0", 0, "/sys/class/hwmon/hwmon0") + hwmon0 := cpu.NewMockPowerZone("hwmon0", 0, "/sys/class/hwmon/hwmon0") testZones := []EnergyZone{hwmon0} mockCPUPowerMeter := &MockCPUPowerMeter{} diff --git a/manifests/helm/kepler/values.yaml b/manifests/helm/kepler/values.yaml index c726f90507..ce041023fc 100644 --- a/manifests/helm/kepler/values.yaml +++ b/manifests/helm/kepler/values.yaml @@ -142,6 +142,8 @@ config: forceEnabled: false # DEPRECATED: set cpu.preferredMeters: ["hwmon"] instead. Emits a deprecation warning when true. zones: [] # List of zones to enable (default enable all) chipRules: [] # User-defined chip pairing rules (override/add to hardcoded defaults) + esmi: + enabled: false # Enable experimental AMD ESMI power monitoring (prepends "esmi" to cpu.preferredMeters) gpu: # Enabling this also triggers the chart's NVML init container and # LD_LIBRARY_PATH injection (daemonset.nvidia.*). Leave false if you diff --git a/manifests/k8s/configmap.yaml b/manifests/k8s/configmap.yaml index 5cc3f1a5f3..ebe67ac9af 100644 --- a/manifests/k8s/configmap.yaml +++ b/manifests/k8s/configmap.yaml @@ -64,6 +64,8 @@ data: forceEnabled: false # DEPRECATED: set cpu.preferredMeters: ["hwmon"] instead. Emits a deprecation warning when true. zones: [] # List of zones to enable (default enable all) chipRules: [] # User-defined chip pairing rules (override/add to hardcoded defaults) + esmi: + enabled: false # Enable experimental AMD ESMI power monitoring (prepends "esmi" to cpu.preferredMeters) gpu: enabled: false idlePower: 0 # GPU idle power in Watts (0 = auto-detect)