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) + } +}