k8s(deployments): synthesize deployment metrics in mock mode so the table stops showing dashes

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).
This commit is contained in:
rcourtman 2026-05-16 12:05:23 +01:00
parent 7938f28de4
commit 8fb141c0a4
5 changed files with 185 additions and 2 deletions

View file

@ -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`,

View file

@ -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,

View file

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

View file

@ -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 {

View file

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