diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 4121e47a5..50ea99e48 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -487,7 +487,16 @@ changes. metric writes. Proxmox guest memory history stores both the canonical guest-relative `memory` percentage and raw `memoryused` bytes so API consumers can apply alternate capacity denominators without reconstructing - bytes from a mutable guest allocation. + bytes from a mutable guest allocation. Mock mode owes the same pair on + every chart window. Seeded mock history covers a bounded window, so ranges + beyond it fall through to the synthetic generator in + `internal/monitoring/mock_chart_history.go`, and that generator must derive + `memoryused` from the sampled `memory` percentage and the fixture memory + capacity rather than omitting the series. Capacity resolves through the + fixture registries synced in `internal/mock/metric_personas.go`, never a + per-call fixture graph clone on the chart path. A percentage series without + its byte companion silently empties the host-capacity memory column instead + of degrading it. Discovery config and configured-host IP resolution must stay off the monitor lock. `internal/monitoring/monitor_discovery_helpers.go` exposes the canonical `discoveryConfigSnapshot()` that discovery providers consume, diff --git a/internal/mock/fixture_graph.go b/internal/mock/fixture_graph.go index 71fa01a53..0b968c37c 100644 --- a/internal/mock/fixture_graph.go +++ b/internal/mock/fixture_graph.go @@ -35,12 +35,12 @@ func buildFixtureGraph(cfg MockConfig, now time.Time) FixtureGraph { AvailabilityFixtures: defaultAvailabilityFixtures(now), } applyDemoScenarioGraph(&graph, now) - syncMetricRoleRegistryFromGraph(graph) + syncMetricFixtureRegistriesFromGraph(graph) graph.UpdateMetrics(cfg, now) graph.AlertHistory = buildAlertHistory(graph.State.Nodes, graph.State.VMs, graph.State.Containers) resources, _ := graph.UnifiedResourceSnapshot() graph.ActionFixtures = buildActionFixtures(resources, now) - syncMetricRoleRegistryFromGraph(graph) + syncMetricFixtureRegistriesFromGraph(graph) return graph } @@ -62,13 +62,13 @@ func (g *FixtureGraph) UpdateMetrics(cfg MockConfig, now time.Time) { setMockUpdateInterval(cfg.UpdateInterval) applyDemoScenarioGraph(g, now) - syncMetricRoleRegistryFromGraph(*g) + syncMetricFixtureRegistriesFromGraph(*g) updateFixtureStateMetricsAt(&g.State, cfg, now) g.PlatformFixtures = rebasePlatformFixtures(g.PlatformFixtures, now) g.AvailabilityFixtures = rebaseAvailabilityFixtures(g.AvailabilityFixtures, now) applyDemoScenarioGraph(g, now) g.DiscoveryFixtures = buildDiscoveryFixtures(g.State, now) - syncMetricRoleRegistryFromGraph(*g) + syncMetricFixtureRegistriesFromGraph(*g) } func (g *FixtureGraph) UpdateAlertSnapshots(active []alerts.Alert, resolved []models.ResolvedAlert) { diff --git a/internal/mock/generator_test.go b/internal/mock/generator_test.go index d1a6aa61b..e60f3f401 100644 --- a/internal/mock/generator_test.go +++ b/internal/mock/generator_test.go @@ -802,3 +802,49 @@ func diffIdentitySets(first, second []string) string { } return out } + +// Host-relative guest memory history is a byte series derived from fixture +// capacity, so the fixture registries must expose that capacity per guest +// without a caller cloning the whole graph on every lookup. +func TestMemoryTotalForResourceTracksFixtureCapacity(t *testing.T) { + mustSetEnabled(t, true) + t.Cleanup(func() { + mustSetEnabled(t, false) + }) + + graph := CurrentFixtureGraph() + + checked := 0 + for _, vm := range graph.State.VMs { + if vm.ID == "" || vm.Memory.Total <= 0 { + continue + } + if got := MemoryTotalForResource("vm", vm.ID); got != float64(vm.Memory.Total) { + t.Fatalf("MemoryTotalForResource(vm, %s) = %f, want %d", vm.ID, got, vm.Memory.Total) + } + checked++ + if checked >= 3 { + break + } + } + if checked == 0 { + t.Fatal("fixture graph has no VM with a memory total") + } + + for _, ct := range graph.State.Containers { + if ct.ID == "" || ct.Memory.Total <= 0 { + continue + } + if got := MemoryTotalForResource("container", ct.ID); got != float64(ct.Memory.Total) { + t.Fatalf("MemoryTotalForResource(container, %s) = %f, want %d", ct.ID, got, ct.Memory.Total) + } + break + } + + if got := MemoryTotalForResource("vm", "definitely-not-a-fixture-guest"); got != 0 { + t.Fatalf("unknown guest capacity = %f, want 0", got) + } + if got := MemoryTotalForResource("vm", " "); got != 0 { + t.Fatalf("blank guest capacity = %f, want 0", got) + } +} diff --git a/internal/mock/metric_personas.go b/internal/mock/metric_personas.go index 621174a08..2105ee3e5 100644 --- a/internal/mock/metric_personas.go +++ b/internal/mock/metric_personas.go @@ -31,8 +31,14 @@ type weightedMetricRole struct { var metricRoleRegistry atomic.Value +// metricMemoryTotalRegistry holds fixture memory capacity in bytes per guest. +// Host-relative memory history is a byte series, so generators derive it from +// this capacity rather than sampling a second percentage. +var metricMemoryTotalRegistry atomic.Value + func init() { metricRoleRegistry.Store(map[string]string{}) + metricMemoryTotalRegistry.Store(map[string]float64{}) } func metricRoleRegistryKey(resourceClass, resourceID string) string { @@ -76,8 +82,55 @@ func MetricRole(resourceClass, resourceID string) string { return inferMetricRole(resourceClass, resourceID) } -func syncMetricRoleRegistryFromGraph(graph FixtureGraph) { +// MemoryTotalForResource returns the fixture memory capacity in bytes for a +// mock guest, or 0 when that resource has no known capacity. +func MemoryTotalForResource(resourceClass, resourceID string) float64 { + resourceID = strings.TrimSpace(resourceID) + if resourceID == "" { + return 0 + } + registry, _ := metricMemoryTotalRegistry.Load().(map[string]float64) + if registry == nil { + return 0 + } + return registry[metricRoleRegistryKey(resourceClass, resourceID)] +} + +func setMetricMemoryTotalRegistry(registry map[string]float64) { + cloned := make(map[string]float64, len(registry)) + for key, value := range registry { + key = strings.TrimSpace(key) + if key == "" || value <= 0 { + continue + } + cloned[key] = value + } + metricMemoryTotalRegistry.Store(cloned) +} + +func buildMetricMemoryTotalRegistry(graph FixtureGraph) map[string]float64 { + registry := make(map[string]float64) + record := func(resourceClass, resourceID string, total int64) { + resourceID = strings.TrimSpace(resourceID) + if resourceID == "" || total <= 0 { + return + } + registry[metricRoleRegistryKey(resourceClass, resourceID)] = float64(total) + } + + for _, vm := range graph.State.VMs { + record("vm", vm.ID, vm.Memory.Total) + } + for _, ct := range graph.State.Containers { + record("container", ct.ID, ct.Memory.Total) + } + + return registry +} + +func syncMetricFixtureRegistriesFromGraph(graph FixtureGraph) { setMetricRoleRegistry(buildMetricRoleRegistry(graph)) + setMetricMemoryTotalRegistry(buildMetricMemoryTotalRegistry(graph)) } func buildMetricRoleRegistry(graph FixtureGraph) map[string]string { diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index f62b9b9ee..9831fb5da 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -2743,3 +2743,73 @@ func TestResourceStaleThresholdsPreserveDefaultFloors(t *testing.T) { t.Fatalf("default PMG threshold = %v, want %v", got, 120*time.Second) } } + +// Mock history is seeded for a bounded window, so chart ranges longer than the +// seed fall through to the synthetic generator. That generator must still carry +// the host-relative `memoryused` byte series, or the workloads memory column in +// host-capacity mode has nothing to draw at 7d while every other series renders. +func TestMockGuestChartHistoryCarriesMemoryUsedBeyondSeededWindow(t *testing.T) { + previous := mock.IsMockEnabled() + mustSetMockEnabled(t, true) + defer mustSetMockEnabled(t, previous) + + graph := mock.CurrentFixtureGraph() + var ( + guestID string + total float64 + ) + for _, vm := range graph.State.VMs { + if vm.ID != "" && vm.Memory.Total > 0 { + guestID = vm.ID + total = float64(vm.Memory.Total) + break + } + } + if guestID == "" { + t.Fatal("mock fixture graph has no VM with a memory total") + } + + series := mockGuestMetricsForChart("vm", guestID, 7*24*time.Hour) + memoryUsed := series["memoryused"] + memoryPercent := series["memory"] + + if len(memoryUsed) == 0 { + t.Fatalf("expected a memoryused series for %s over 7d, got none", guestID) + } + if len(memoryUsed) != len(memoryPercent) { + t.Fatalf( + "expected memoryused to track the memory series length, got %d vs %d", + len(memoryUsed), + len(memoryPercent), + ) + } + + for i, point := range memoryUsed { + if !point.Timestamp.Equal(memoryPercent[i].Timestamp) { + t.Fatalf("memoryused timestamp %d diverged from the memory series", i) + } + want := total * (math.Max(0, math.Min(100, memoryPercent[i].Value)) / 100) + if math.Abs(point.Value-want) > 1 { + t.Fatalf("memoryused[%d] = %f, want %f bytes", i, point.Value, want) + } + } +} + +// Docker containers and pods are outside the Proxmox host-capacity memory +// contract, so the synthetic generator must not start emitting bytes for them. +func TestMockGuestChartHistorySkipsMemoryUsedForNonProxmoxGuests(t *testing.T) { + previous := mock.IsMockEnabled() + mustSetMockEnabled(t, true) + defer mustSetMockEnabled(t, previous) + + for _, resourceType := range []string{"dockerContainer", "k8s"} { + series := mockGuestMetricsForChart(resourceType, "any-guest-id", 7*24*time.Hour) + if len(series["memoryused"]) != 0 { + t.Fatalf( + "expected no memoryused series for %s, got %d points", + resourceType, + len(series["memoryused"]), + ) + } + } +} diff --git a/internal/monitoring/mock_chart_history.go b/internal/monitoring/mock_chart_history.go index bd9a932ba..586d24fae 100644 --- a/internal/monitoring/mock_chart_history.go +++ b/internal/monitoring/mock_chart_history.go @@ -4,6 +4,8 @@ import ( "math" "strings" "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/mock" ) var ( @@ -75,13 +77,47 @@ func mockGuestMetricsForChart(resourceType, resourceID string, duration time.Dur } timestamps := mockChartTimestamps(duration) - result := make(map[string][]MetricPoint, len(mockGuestChartMetricTypes)) + result := make(map[string][]MetricPoint, len(mockGuestChartMetricTypes)+1) for _, metricType := range mockGuestChartMetricTypes { result[metricType] = mockCanonicalMetricSeries(resourceType, resourceID, metricType, timestamps) } + if series := mockGuestMemoryUsedSeries(resourceType, resourceID, result["memory"]); len(series) > 0 { + result["memoryused"] = series + } return result } +// mockGuestMemoryUsedSeries derives the host-relative memory byte series from +// the guest-relative percentage series and the fixture memory capacity, the +// same derivation live mock ticks and the history seeder use. Without it a +// chart window that falls outside the seeded history, such as 7d, returns no +// `memoryused` at all and the host-capacity memory column has nothing to draw. +func mockGuestMemoryUsedSeries(resourceType, resourceID string, memoryPercent []MetricPoint) []MetricPoint { + switch resourceType { + case "vm", "container": + default: + return nil + } + if len(memoryPercent) == 0 { + return nil + } + + total := mock.MemoryTotalForResource(resourceType, resourceID) + if total <= 0 { + return nil + } + + series := make([]MetricPoint, len(memoryPercent)) + for i, point := range memoryPercent { + percent := math.Max(0, math.Min(100, point.Value)) + series[i] = MetricPoint{ + Timestamp: point.Timestamp, + Value: total * (percent / 100), + } + } + return series +} + func mockNodeMetricsForChart(nodeID string, metricTypes []string, duration time.Duration) map[string][]MetricPoint { nodeID = strings.TrimSpace(nodeID) if nodeID == "" {