From 8fb141c0a48e657c52da2bc47ec294b1faccd9ed Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 16 May 2026 12:05:23 +0100 Subject: [PATCH] k8s(deployments): synthesize deployment metrics in mock mode so the table stops showing dashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Kubernetes Deployments tab was rendering CPU / Memory / Disk as "—" for every row because `resourceFromKubernetesDeployment` never set the Resource.Metrics payload. Upstream Deployments do not natively expose CPU/memory metrics (they are scheduling abstractions over their controlled pods), so the unified adapter would have to aggregate pod metrics into the owning deployment to back the table with real data. That aggregation is a longer thread because mock pods today are tagged with random `OwnerName` values that do not necessarily resolve to the generated deployment names. For the immediate platform-page quality fix, add `metricsFromKubernetesDeployment(cluster, deployment)` to the canonical metrics layer: - Real-mode: returns nil (preserves current behaviour for live clusters; deployments still show dashes until owner-based pod-metric aggregation lands). - Mock-mode: synthesizes deployment-stable CPU / Memory / Disk / NetIn / NetOut values via `syntheticKubernetesDeploymentMetrics`, scaled by the deployment's ready/desired/available replica state so degraded deployments (ready < desired) read as elevated pressure on surviving replicas, and by replica count so larger deployments show proportionally higher absolute network throughput. `resourceFromKubernetesDeployment` now calls the helper inline. Live mock verification: `/api/resources?type=k8s-deployment` rows now carry populated `metrics.cpu/memory/disk` percentages varied per deployment, so `/kubernetes/deployments` renders bars and values instead of dashes. Tests: - `go test ./internal/unifiedresources/... ./internal/mock/... ./internal/monitoring/...` all green. - New `TestResourceFromKubernetesDeployment_PopulatesMetricsUnderMockMode`, `TestResourceFromKubernetesDeployment_NilMetricsOutsideMockMode`, `TestMetricsFromKubernetesDeployment_NilOutsideMockMode`, and `TestMetricsFromKubernetesDeployment_SyntheticUnderMockMode` lock the contract: real-mode returns nil; mock-mode synthesises and degraded deployments read as elevated CPU vs healthy. - Playwright (9/9) green against the live mock-mode dev runtime. Contract updated: `unified-resources.md` Extension Points records the canonical position of `metricsFromKubernetesDeployment` (real-mode nil fallback today, mock-mode synthetic) and the future expansion path (real pod-metric aggregation). --- .../internal/subsystems/unified-resources.md | 14 +++- internal/unifiedresources/adapters.go | 1 + internal/unifiedresources/adapters_test.go | 46 ++++++++++ internal/unifiedresources/metrics.go | 84 +++++++++++++++++++ .../unifiedresources/metrics_targets_test.go | 42 +++++++++- 5 files changed, 185 insertions(+), 2 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 6953771de..4a5cb2c1c 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -271,7 +271,19 @@ payloads may expose a camelCase transport projection, but the counts must be derived from `internal/unifiedresources/policy_posture.go` after canonical policy metadata has been refreshed, not recomputed from frontend labels, AI-only summary payloads, or page-local heuristics. -4. Add metrics-target normalization or synthetic metrics support through `internal/unifiedresources/metrics_targets.go` and `internal/unifiedresources/metrics.go` +4. Add metrics-target normalization or synthetic metrics support through `internal/unifiedresources/metrics_targets.go` and `internal/unifiedresources/metrics.go`. + Kubernetes deployment metrics live on the canonical adapter through + `metricsFromKubernetesDeployment(cluster, deployment)`. Upstream + Deployments do not expose CPU / memory natively because they are + scheduling abstractions over their controlled pods, so the helper + returns nil for non-mock runtimes (until the adapter aggregates pod + metrics into the owning deployment) and synthesizes deployment-stable + CPU / memory / disk / network values for mock mode so the + platform-page Deployments table renders meaningful operator values + instead of dashes. The synthetic branch is gated by + `mockmode.IsEnabled()` and scales with the deployment's + ready/desired/available replica state so degraded deployments read as + elevated pressure on the surviving replicas. 5. Add platform registry, resolution, host-dedup, or monitored-system projection behavior through `internal/unifiedresources/registry.go`, `internal/unifiedresources/resolve.go`, diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go index 5ae419e7c..57835ca99 100644 --- a/internal/unifiedresources/adapters.go +++ b/internal/unifiedresources/adapters.go @@ -2169,6 +2169,7 @@ func resourceFromKubernetesDeployment(cluster models.KubernetesCluster, deployme Status: statusFromKubernetesDeployment(deployment), LastSeen: cluster.LastSeen, UpdatedAt: time.Now().UTC(), + Metrics: metricsFromKubernetesDeployment(cluster, deployment), Kubernetes: &K8sData{ ClusterID: cluster.ID, ClusterName: clusterName, diff --git a/internal/unifiedresources/adapters_test.go b/internal/unifiedresources/adapters_test.go index a25611ff0..e24abb154 100644 --- a/internal/unifiedresources/adapters_test.go +++ b/internal/unifiedresources/adapters_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/mockruntime" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/storagehealth" ) @@ -628,3 +629,48 @@ func TestResourceFromHostSMARTDiskNormalizesLegacyUnraidKiBSize(t *testing.T) { t.Fatalf("agent Unraid disk sizeBytes = %d, want %d", got, normalizedDiskBytes) } } + +func TestResourceFromKubernetesDeployment_PopulatesMetricsUnderMockMode(t *testing.T) { + mockruntime.SetEnabled(true) + t.Cleanup(func() { mockruntime.SetEnabled(false) }) + + cluster := models.KubernetesCluster{ + ID: "cluster-1", + Name: "production", + } + deployment := models.KubernetesDeployment{ + UID: "dep-uid-1", + Name: "checkout-api", + Namespace: "services", + DesiredReplicas: 3, + UpdatedReplicas: 3, + ReadyReplicas: 2, + AvailableReplicas: 2, + } + + resource, _ := resourceFromKubernetesDeployment(cluster, deployment, nil) + if resource.Metrics == nil { + t.Fatal("expected mock-mode deployment to carry synthetic Metrics, got nil") + } + if resource.Metrics.CPU == nil || resource.Metrics.CPU.Percent <= 0 { + t.Fatalf("expected non-zero CPU percent, got %+v", resource.Metrics.CPU) + } + if resource.Metrics.Memory == nil || resource.Metrics.Memory.Percent <= 0 { + t.Fatalf("expected non-zero Memory percent, got %+v", resource.Metrics.Memory) + } + if resource.Metrics.Disk == nil || resource.Metrics.Disk.Percent < 0 { + t.Fatalf("expected Disk metric, got %+v", resource.Metrics.Disk) + } +} + +func TestResourceFromKubernetesDeployment_NilMetricsOutsideMockMode(t *testing.T) { + mockruntime.SetEnabled(false) + cluster := models.KubernetesCluster{ID: "cluster-2", Name: "live"} + deployment := models.KubernetesDeployment{ + Name: "frontend", Namespace: "web", DesiredReplicas: 2, ReadyReplicas: 2, AvailableReplicas: 2, + } + resource, _ := resourceFromKubernetesDeployment(cluster, deployment, nil) + if resource.Metrics != nil { + t.Fatalf("expected nil Metrics outside mock mode (no canonical aggregation yet), got %+v", resource.Metrics) + } +} diff --git a/internal/unifiedresources/metrics.go b/internal/unifiedresources/metrics.go index a99d45652..ef81e04f1 100644 --- a/internal/unifiedresources/metrics.go +++ b/internal/unifiedresources/metrics.go @@ -456,6 +456,90 @@ func metricsFromKubernetesNode(_ models.KubernetesCluster, node models.Kubernete return metrics } +// metricsFromKubernetesDeployment projects deployment-level metrics so the +// platform-page Deployments table stops rendering dashes for CPU / Memory / +// Disk. Real upstream Deployments do not natively expose resource metrics — +// they are scheduling abstractions over their controlled pods — so this +// helper currently synthesises mock-mode values from the deployment's +// replica state. Production deployments without aggregated pod metrics +// still return nil here; once the canonical adapter learns how to roll up +// pod metrics into the owning deployment, swap the synthetic branch for the +// real aggregate while keeping the synthetic fallback for mock mode. +func metricsFromKubernetesDeployment(cluster models.KubernetesCluster, deployment models.KubernetesDeployment) *ResourceMetrics { + if !mockmode.IsEnabled() { + return nil + } + values := syntheticKubernetesDeploymentMetrics(cluster, deployment) + return &ResourceMetrics{ + CPU: &MetricValue{Value: values.CPU, Percent: values.CPU, Unit: "percent", Source: SourceK8s}, + Memory: &MetricValue{Value: values.Memory, Percent: values.Memory, Unit: "percent", Source: SourceK8s}, + Disk: &MetricValue{Value: values.Disk, Percent: values.Disk, Unit: "percent", Source: SourceK8s}, + NetIn: &MetricValue{Value: values.NetIn, Unit: "bytes/s", Source: SourceK8s}, + NetOut: &MetricValue{Value: values.NetOut, Unit: "bytes/s", Source: SourceK8s}, + } +} + +type kubernetesDeploymentSyntheticMetrics struct { + CPU float64 + Memory float64 + Disk float64 + NetIn float64 + NetOut float64 +} + +func syntheticKubernetesDeploymentMetrics(cluster models.KubernetesCluster, deployment models.KubernetesDeployment) kubernetesDeploymentSyntheticMetrics { + seed := hashMetricsSeed( + cluster.ID, + cluster.Name, + cluster.DisplayName, + deployment.UID, + deployment.Namespace, + deployment.Name, + ) + rng := rand.New(rand.NewSource(int64(seed))) + + desired := float64(deployment.DesiredReplicas) + if desired <= 0 { + desired = 1 + } + ready := float64(deployment.ReadyReplicas) + if ready < 0 { + ready = 0 + } + if ready > desired { + ready = desired + } + available := float64(deployment.AvailableReplicas) + if available < 0 { + available = 0 + } + if available > desired { + available = desired + } + readiness := ready / desired + availability := available / desired + + // Healthy deployments cluster around moderate utilisation; degraded + // deployments (ready < desired) read as elevated CPU / memory pressure + // because the surviving pods absorb the dropped replicas' load. + pressure := 1.0 + (1.0-readiness)*0.85 + scale := math.Min(1.0+desired*0.05, 1.6) + + cpu := (24 + rng.Float64()*18) * pressure * scale + memory := (38 + rng.Float64()*22) * pressure * scale + disk := (12 + rng.Float64()*16) * scale + netIn := (12_000 + rng.Float64()*60_000) * scale * availability + netOut := (9_000 + rng.Float64()*48_000) * scale * availability + + return kubernetesDeploymentSyntheticMetrics{ + CPU: clampMetricValue(cpu, 0, 100), + Memory: clampMetricValue(memory, 0, 100), + Disk: clampMetricValue(disk, 0, 100), + NetIn: netIn, + NetOut: netOut, + } +} + func metricsFromKubernetesPod(cluster models.KubernetesCluster, pod models.KubernetesPod) *ResourceMetrics { metrics := &ResourceMetrics{} if pod.UsageCPUPercent > 0 { diff --git a/internal/unifiedresources/metrics_targets_test.go b/internal/unifiedresources/metrics_targets_test.go index cdf0b1821..cc50eb9b6 100644 --- a/internal/unifiedresources/metrics_targets_test.go +++ b/internal/unifiedresources/metrics_targets_test.go @@ -1,6 +1,11 @@ package unifiedresources -import "testing" +import ( + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/mockruntime" + "github.com/rcourtman/pulse-go-rewrite/internal/models" +) func TestBuildMetricsTarget_UsesCanonicalAgentTypeForInfrastructureFamilies(t *testing.T) { tests := []struct { @@ -358,3 +363,38 @@ func TestBuildMetricsTarget_UsesCanonicalAgentMetricIDForTrueNAS(t *testing.T) { t.Fatalf("ResourceID = %q, want truenas-main", target.ResourceID) } } + +func TestMetricsFromKubernetesDeployment_NilOutsideMockMode(t *testing.T) { + mockruntime.SetEnabled(false) + got := metricsFromKubernetesDeployment( + models.KubernetesCluster{ID: "c1", Name: "prod"}, + models.KubernetesDeployment{Name: "frontend", DesiredReplicas: 2, ReadyReplicas: 2}, + ) + if got != nil { + t.Fatalf("expected nil metrics when mock mode is disabled, got %+v", got) + } +} + +func TestMetricsFromKubernetesDeployment_SyntheticUnderMockMode(t *testing.T) { + mockruntime.SetEnabled(true) + t.Cleanup(func() { mockruntime.SetEnabled(false) }) + + healthy := metricsFromKubernetesDeployment( + models.KubernetesCluster{ID: "c1", Name: "prod"}, + models.KubernetesDeployment{Name: "checkout", UID: "u1", DesiredReplicas: 3, ReadyReplicas: 3, AvailableReplicas: 3}, + ) + if healthy == nil || healthy.CPU == nil || healthy.Memory == nil { + t.Fatalf("expected synthetic metrics for healthy deployment, got %+v", healthy) + } + + degraded := metricsFromKubernetesDeployment( + models.KubernetesCluster{ID: "c1", Name: "prod"}, + models.KubernetesDeployment{Name: "checkout", UID: "u1", DesiredReplicas: 3, ReadyReplicas: 1, AvailableReplicas: 1}, + ) + if degraded == nil || degraded.CPU == nil { + t.Fatalf("expected synthetic metrics for degraded deployment, got %+v", degraded) + } + if degraded.CPU.Percent <= healthy.CPU.Percent { + t.Fatalf("expected degraded deployment CPU (%.1f) to exceed healthy CPU (%.1f)", degraded.CPU.Percent, healthy.CPU.Percent) + } +}