mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-22 23:43:30 +00:00
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:
parent
7938f28de4
commit
8fb141c0a4
5 changed files with 185 additions and 2 deletions
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue