diff --git a/internal/collector/redfishprofile/matcher_test.go b/internal/collector/redfishprofile/matcher_test.go index 3224a1e..8a63e06 100644 --- a/internal/collector/redfishprofile/matcher_test.go +++ b/internal/collector/redfishprofile/matcher_test.go @@ -352,7 +352,15 @@ func TestMatchProfiles_LenovoXCCSelectsMatchedModeAndExcludesSensors(t *testing. ChassisManufacturer: "Lenovo", OEMNamespaces: []string{"Lenovo"}, }) - wantExcluded := []string{"/Sensors/", "/Oem/Lenovo/LEDs/", "/Oem/Lenovo/Slots/"} + wantExcluded := []string{ + "/Sensors/", + "/Oem/Lenovo/LEDs/", + "/Oem/Lenovo/Slots/", + "/Oem/Lenovo/Configuration", + "/NetworkProtocol/Oem/Lenovo/", + "/VirtualMedia/", + "/ThermalSubsystem/Fans/", + } for _, want := range wantExcluded { found := false for _, ex := range plan.Tuning.SnapshotExcludeContains { @@ -367,6 +375,46 @@ func TestMatchProfiles_LenovoXCCSelectsMatchedModeAndExcludesSensors(t *testing. } } +func TestResolveAcquisitionPlan_LenovoFiltersNonInventoryChassisBranches(t *testing.T) { + signals := MatchSignals{ + SystemManufacturer: "Lenovo", + ChassisManufacturer: "Lenovo", + OEMNamespaces: []string{"Lenovo"}, + ResourceHints: []string{ + "/redfish/v1/Chassis/1/Power", + "/redfish/v1/Chassis/1/Thermal", + "/redfish/v1/Chassis/1/NetworkAdapters", + "/redfish/v1/Chassis/3", + "/redfish/v1/Chassis/IO_Board", + }, + } + match := MatchProfiles(signals) + plan := BuildAcquisitionPlan(signals) + resolved := ResolveAcquisitionPlan(match, plan, DiscoveredResources{ + ChassisPaths: []string{ + "/redfish/v1/Chassis/1", + "/redfish/v1/Chassis/3", + "/redfish/v1/Chassis/IO_Board", + }, + }, signals) + + if !containsString(resolved.CriticalPaths, "/redfish/v1/Chassis/1/Power") { + t.Fatal("expected primary Lenovo chassis power path to remain critical") + } + if containsString(resolved.CriticalPaths, "/redfish/v1/Chassis/3/Power") { + t.Fatal("did not expect non-inventory Lenovo backplane chassis power path") + } + if containsString(resolved.CriticalPaths, "/redfish/v1/Chassis/IO_Board/Assembly") { + t.Fatal("did not expect IO board assembly path without inventory hints") + } + if containsString(resolved.Plan.PlanBPaths, "/redfish/v1/Chassis/3/Assembly") { + t.Fatal("did not expect non-inventory Lenovo chassis plan-b target") + } + if !containsString(resolved.CriticalPaths, "/redfish/v1/Chassis/3") { + t.Fatal("expected chassis root to remain discoverable even when suffixes are filtered") + } +} + func TestMatchProfiles_OrderingIsDeterministic(t *testing.T) { signals := MatchSignals{ SystemManufacturer: "Micro-Star International Co., Ltd.", diff --git a/internal/collector/redfishprofile/profile_lenovo.go b/internal/collector/redfishprofile/profile_lenovo.go index 77fe8c1..1197eaa 100644 --- a/internal/collector/redfishprofile/profile_lenovo.go +++ b/internal/collector/redfishprofile/profile_lenovo.go @@ -1,5 +1,7 @@ package redfishprofile +import "strings" + func lenovoProfile() Profile { return staticProfile{ name: "lenovo", @@ -33,14 +35,30 @@ func lenovoProfile() Profile { // Lenovo OEM subtrees under Oem/Lenovo/LEDs and Oem/Lenovo/Slots also // enumerate dozens of individual documents not relevant to inventory. ensureSnapshotExcludeContains(plan, - "/Sensors/", // individual sensor docs (Chassis/1/Sensors/NNN) - "/Oem/Lenovo/LEDs/", // individual LED status entries (~47 per server) - "/Oem/Lenovo/Slots/", // individual slot detail entries (~26 per server) - "/Oem/Lenovo/Metrics/", // operational metrics, not inventory - "/Oem/Lenovo/History", // historical telemetry - "/Oem/Lenovo/ScheduledPower", // power scheduling config - "/Oem/Lenovo/BootSettings/BootOrder", // individual boot order lists - "/PortForwardingMap/", // network port forwarding config + "/Sensors/", // individual sensor docs (Chassis/1/Sensors/NNN) + "/Oem/Lenovo/LEDs/", // individual LED status entries (~47 per server) + "/Oem/Lenovo/Slots/", // individual slot detail entries (~26 per server) + "/Oem/Lenovo/Metrics/", // operational metrics, not inventory + "/Oem/Lenovo/History", // historical telemetry + "/Oem/Lenovo/Configuration", // BMC config service, not inventory + "/Oem/Lenovo/DateTimeService", // BMC time service config + "/Oem/Lenovo/GroupService", // XCC fleet/group management state + "/Oem/Lenovo/Recipients", // alert recipient config + "/Oem/Lenovo/RemoteControl", // remote-media/session management + "/Oem/Lenovo/RemoteMap", // remote-media mapping config + "/Oem/Lenovo/SecureKeyLifecycleService", // key lifecycle/cert config + "/Oem/Lenovo/ServerProfile", // profile export/import config + "/Oem/Lenovo/ServiceData", // support/service metadata + "/Oem/Lenovo/SsoCertificates", // SSO certificate config + "/Oem/Lenovo/SystemGuard", // snapshot/history service + "/Oem/Lenovo/Watchdogs", // watchdog config + "/Oem/Lenovo/ScheduledPower", // power scheduling config + "/Oem/Lenovo/BootSettings/BootOrder", // individual boot order lists + "/NetworkProtocol/Oem/Lenovo/", // DNS/LDAP/SMTP/SNMP manager config + "/PortForwardingMap/", // network port forwarding config + "/VirtualMedia/", // virtual media inventory/config, not hardware + "/Boot/Certificates", // secure boot certificate stores, not inventory + "/ThermalSubsystem/Fans/", // per-fan member docs; replay uses aggregate Thermal only ) // Lenovo XCC BMC is typically slow (p95 latency often 3-5s even under // normal load). Set rate thresholds that don't over-throttle on the @@ -61,5 +79,97 @@ func lenovoProfile() Profile { }) addPlanNote(plan, "lenovo xcc acquisition extensions enabled: noisy sensor/oem paths excluded from snapshot") }, + refineAcquisition: func(resolved *ResolvedAcquisitionPlan, discovered DiscoveredResources, signals MatchSignals) { + allowedChassis := lenovoAllowedInventoryChassis(discovered.ChassisPaths, signals.ResourceHints) + resolved.SeedPaths = filterLenovoChassisInventoryPaths(resolved.SeedPaths, allowedChassis) + resolved.CriticalPaths = filterLenovoChassisInventoryPaths(resolved.CriticalPaths, allowedChassis) + resolved.Plan.SeedPaths = filterLenovoChassisInventoryPaths(resolved.Plan.SeedPaths, allowedChassis) + resolved.Plan.CriticalPaths = filterLenovoChassisInventoryPaths(resolved.Plan.CriticalPaths, allowedChassis) + resolved.Plan.PlanBPaths = filterLenovoChassisInventoryPaths(resolved.Plan.PlanBPaths, allowedChassis) + }, } } + +func lenovoAllowedInventoryChassis(chassisPaths, resourceHints []string) map[string]struct{} { + allowed := make(map[string]struct{}, len(chassisPaths)) + for _, chassisPath := range chassisPaths { + normalized := normalizePath(chassisPath) + if normalized == "" { + continue + } + if normalized == "/redfish/v1/Chassis/1" { + allowed[normalized] = struct{}{} + continue + } + for _, hint := range resourceHints { + hint = normalizePath(hint) + if !strings.HasPrefix(hint, normalized+"/") { + continue + } + if lenovoHintLooksLikeChassisInventory(hint) { + allowed[normalized] = struct{}{} + break + } + } + } + return allowed +} + +func lenovoHintLooksLikeChassisInventory(path string) bool { + for _, suffix := range []string{ + "/Power", + "/PowerSubsystem", + "/PowerSubsystem/PowerSupplies", + "/Thermal", + "/ThresholdSensors", + "/DiscreteSensors", + "/SensorsList", + "/NetworkAdapters", + "/PCIeDevices", + "/Drives", + "/Assembly", + } { + if strings.HasSuffix(path, suffix) || strings.Contains(path, suffix+"/") { + return true + } + } + return false +} + +func filterLenovoChassisInventoryPaths(paths []string, allowedChassis map[string]struct{}) []string { + if len(paths) == 0 { + return nil + } + out := make([]string, 0, len(paths)) + for _, path := range paths { + normalized := normalizePath(path) + chassis := lenovoPathChassisRoot(normalized) + if chassis == "" { + out = append(out, normalized) + continue + } + if normalized == chassis { + out = append(out, normalized) + continue + } + if _, ok := allowedChassis[chassis]; ok { + out = append(out, normalized) + } + } + return dedupeSorted(out) +} + +func lenovoPathChassisRoot(path string) string { + const prefix = "/redfish/v1/Chassis/" + if !strings.HasPrefix(path, prefix) { + return "" + } + rest := strings.TrimPrefix(path, prefix) + if rest == "" { + return "" + } + if idx := strings.IndexByte(rest, '/'); idx >= 0 { + return prefix + rest[:idx] + } + return prefix + rest +}