From b740708e042741bda8f5aa26a586865586b6b22a Mon Sep 17 00:00:00 2001 From: Weslei Paulo Pereira Date: Fri, 9 Oct 2026 16:16:39 -0300 Subject: [PATCH 1/4] feat(dashboard): add cluster overview backend telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend GET /api/v1/dashboard/summary with three new additive fields that surface platform-wide telemetry for the operator: - hosts: node count, ready nodes, allocatable CPU and memory (millicores / bytes), distinct kubelet versions, OS images and architectures. Sourced from core/v1/nodes via KubeVirtDriver.ClusterMetrics. - hosts.usage: real CPU and memory consumption aggregated from metrics.k8s.io/v1beta1/nodes (Tier 2 — requires metrics-server, documented prerequisite in helm-charts). Served with a 30s in-memory cache, and degrades gracefully when the API is absent, Forbidden, or transiently unavailable. - storage: tenant-scoped PVC capacity and consumption (Bound vs reserved) plus the count. Sourced from the existing PVC list path in KubeVirtDriver. - addons: cluster-wide health of critical dependencies (KubeVirt, CDI, Multus, metrics-server, networking, and any chart that follows the Helm label conventions), discovered dynamically from CustomResourceDefinitions with the 'app.kubernetes.io/name' label and a small alias table for the well-known API groups. Each entry reports 'ok' (Established) or 'degraded'. All four reads are fail-soft: 404, 403, 401, 503, timeouts, and connection-refused return (nil, nil) so the endpoint never 500s. Memory mode and nil client leave the fields empty. docs/rbac-contract.yaml documents the dynamic-client access to metrics.k8s.io in a new dynamicClient section — rbac_contract_test keeps passing because the typed client AST scan does not see Discovery().RESTClient(). PlatformService gains a kv field so the service can read the hypervisor. CHANGELOG entry under Unreleased. A new 2026-10-08-dashboard-host-metrics.md captures the design and the locked decisions for this slice. --- docs/rbac-contract.yaml | 20 +- .../infra/hypervisor/addons_health_test.go | 205 +++++++++ .../infra/hypervisor/cluster_metrics_test.go | 119 ++++++ .../infra/hypervisor/cluster_usage_test.go | 214 ++++++++++ internal/infra/hypervisor/kubevirt.go | 396 +++++++++++++++++- .../infra/hypervisor/storage_summary_test.go | 104 +++++ internal/infra/hypervisor/types.go | 63 +++ internal/service/dashboard.go | 75 ++++ internal/service/platform.go | 2 + 9 files changed, 1195 insertions(+), 3 deletions(-) create mode 100644 internal/infra/hypervisor/addons_health_test.go create mode 100644 internal/infra/hypervisor/cluster_metrics_test.go create mode 100644 internal/infra/hypervisor/cluster_usage_test.go create mode 100644 internal/infra/hypervisor/storage_summary_test.go diff --git a/docs/rbac-contract.yaml b/docs/rbac-contract.yaml index 56fa79a..a15fdb8 100644 --- a/docs/rbac-contract.yaml +++ b/docs/rbac-contract.yaml @@ -11,7 +11,9 @@ # fails at deploy time (core#196 to core#198 came from exactly that). # # Not covered: dynamic-client access (virtfoundry.io CRDs, KubeVirt, CDI, Multus), which is -# granted per resource in the chart, and calls made through a stored client variable. +# granted per resource in the chart, and calls made through a stored client variable. These +# accesses are listed under `dynamicClient` below for the chart to grant; rbac_contract_test.go +# does not enforce them (it only AST-scans typed client-go calls). # # scope: cluster the grant must be in the ClusterRole (tenant namespaces are created at # runtime, so RBAC cannot name them). This is the default. @@ -57,3 +59,19 @@ rules: - apiGroup: networking.k8s.io resource: networkpolicies verbs: [get, create, update, delete] +# dynamicClient documents access through the discovery REST client +# (cs.Discovery().RESTClient() / dynamic client) — a pattern used when +# the typed client-go does not expose the group (e.g. metrics.k8s.io +# comes from metrics-server, not from client-go). rbac_contract_test.go +# does not scan these calls, so the chart is the source of truth and +# must grant them. Each entry lists (apiGroup, resource, verbs). +dynamicClient: + - apiGroup: metrics.k8s.io + resource: nodes + verbs: [get, list] + note: >- + Tier 2 of the dashboard host metrics spec. Source: KubeVirtDriver.fetchClusterUsage + (internal/infra/hypervisor/kubevirt.go). Reachable only when metrics-server is + installed (documented prerequisite in helm-charts). The dashboard degrades gracefully + when this API is absent, Forbidden, or transiently unavailable. + diff --git a/internal/infra/hypervisor/addons_health_test.go b/internal/infra/hypervisor/addons_health_test.go new file mode 100644 index 0000000..5bdbfc5 --- /dev/null +++ b/internal/infra/hypervisor/addons_health_test.go @@ -0,0 +1,205 @@ +package hypervisor + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +func newCRDTestServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, kubernetes.Interface) { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + cfg := &rest.Config{Host: srv.URL} + cs, err := kubernetes.NewForConfig(cfg) + if err != nil { + t.Fatalf("build client: %v", err) + } + return srv, cs +} + +func crdWithLabel(name, labelValue string, established bool) string { + cond := `{"type":"Established","status":"False"}` + if established { + cond = `{"type":"Established","status":"True"}` + } + return `{ + "metadata": { + "name": "` + name + `", + "labels": {"app.kubernetes.io/name": "` + labelValue + `"} + }, + "status": {"conditions": [` + cond + `]} + }` +} + +func crdWithGroup(name, group string, established bool) string { + cond := `{"type":"Established","status":"False"}` + if established { + cond = `{"type":"Established","status":"True"}` + } + return `{ + "metadata": {"name": "` + name + `"}, + "spec": {"group": "` + group + `"}, + "status": {"conditions": [` + cond + `]} + }` +} + +func TestAddonsHealthDiscoversFromCRDs(t *testing.T) { + t.Parallel() + srv, cs := newCRDTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/apis/apiextensions.k8s.io/v1/customresourcedefinitions": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items": [ + ` + crdWithLabel("virtualmachines.kubevirt.io", "kubevirt", true) + `, + ` + crdWithLabel("datavolumes.cdi.kubevirt.io", "cdi", true) + `, + ` + crdWithLabel("networkattachmentdefinitions.k8s.cni.cncf.io", "multus", true) + `, + ` + crdWithLabel("certificates.cert-manager.io", "cert-manager", true) + `, + ` + crdWithLabel("foo.example.com", "failing-addon", false) + `, + ` + crdWithGroup("networking.k8s.io", "networking.k8s.io", true) + `, + ` + crdWithGroup("unknown.example.com", "not-in-aliases.example.com", true) + ` + ]}`)) + default: + http.NotFound(w, r) + } + }) + _ = srv + + d := &KubeVirtDriver{k8sClient: cs} + got, err := d.AddonsHealth(context.Background()) + if err != nil { + t.Fatalf("AddonsHealth: %v", err) + } + byName := map[string]string{} + for _, a := range got.Addons { + byName[a.Name] = a.Status + } + want := map[string]string{ + "kubevirt": "ok", + "cdi": "ok", + "multus": "ok", + "cert-manager": "ok", + "failing-addon": "degraded", + } + for name, status := range want { + if got := byName[name]; got != status { + t.Fatalf("addon %q: got %q want %q", name, got, status) + } + } + if _, present := byName["unknown"]; present { + t.Fatal("CRDs whose group has no alias must be ignored") + } + if _, present := byName["networking"]; present { + t.Fatal("kubernetes core networking group must not be mapped to an addon") + } + if got.CheckedAt.IsZero() { + t.Fatal("checked_at must be set") + } +} + +func TestAliasFromGroup(t *testing.T) { + t.Parallel() + cases := []struct { + group string + want string + }{ + {"kubevirt.io", "kubevirt"}, + {"cdi.kubevirt.io", "cdi"}, + {"k8s.cni.cncf.io", "multus"}, + {"cert-manager.io", "cert-manager"}, + {"networking.istio.io", "istio"}, + {"security.istio.io", "istio"}, + {"monitoring.coreos.com", "prometheus"}, + {"tekton.dev", "tekton"}, + {"argoproj.io", "argocd"}, + {"networking.k8s.io", ""}, + {"", ""}, + } + for _, c := range cases { + c := c + t.Run(c.group, func(t *testing.T) { + t.Parallel() + if got := aliasFromGroup(c.group); got != c.want { + t.Fatalf("aliasFromGroup(%q)=%q want %q", c.group, got, c.want) + } + }) + } +} + +func TestResolveAddonName(t *testing.T) { + t.Parallel() + cases := []struct { + name string + labels map[string]string + group string + want string + }{ + {"name wins over group", map[string]string{"app.kubernetes.io/name": "x"}, "kubevirt.io", "x"}, + {"part-of fallback", map[string]string{"app.kubernetes.io/part-of": "y"}, "kubevirt.io", "y"}, + {"component fallback", map[string]string{"app.kubernetes.io/component": "z"}, "kubevirt.io", "z"}, + {"group alias fallback", nil, "kubevirt.io", "kubevirt"}, + {"nothing matches", nil, "random.example.com", ""}, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + if got := resolveAddonName(c.labels, c.group); got != c.want { + t.Fatalf("got %q want %q", got, c.want) + } + }) + } +} + +func TestAddonsHealthCRDListForbidden(t *testing.T) { + t.Parallel() + srv, cs := newCRDTestServer(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"kind":"Status","status":"Failure","code":403,"reason":"Forbidden"}`, http.StatusForbidden) + }) + _ = srv + + d := &KubeVirtDriver{k8sClient: cs} + got, err := d.AddonsHealth(context.Background()) + if err != nil { + t.Fatalf("AddonsHealth: %v", err) + } + if len(got.Addons) != 0 { + t.Fatalf("forbidden CRD list must yield empty addons, got %+v", got.Addons) + } +} + +func TestAddonsHealthDedupMultipleCRDsSameName(t *testing.T) { + t.Parallel() + srv, cs := newCRDTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/apis/apiextensions.k8s.io/v1/customresourcedefinitions": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items": [ + ` + crdWithLabel("virtualmachines.kubevirt.io", "kubevirt", true) + `, + ` + crdWithLabel("virtualmachineinstances.kubevirt.io", "kubevirt", true) + ` + ]}`)) + default: + http.NotFound(w, r) + } + }) + _ = srv + + d := &KubeVirtDriver{k8sClient: cs} + got, err := d.AddonsHealth(context.Background()) + if err != nil { + t.Fatalf("AddonsHealth: %v", err) + } + count := 0 + for _, a := range got.Addons { + if a.Name == "kubevirt" { + count++ + } + } + if count != 1 { + t.Fatalf("multiple CRDs with same app.kubernetes.io/name must dedup, got %d entries", count) + } +} diff --git a/internal/infra/hypervisor/cluster_metrics_test.go b/internal/infra/hypervisor/cluster_metrics_test.go new file mode 100644 index 0000000..eca7bee --- /dev/null +++ b/internal/infra/hypervisor/cluster_metrics_test.go @@ -0,0 +1,119 @@ +package hypervisor + +import ( + "context" + "testing" + + k8sv1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func newNode(name string, ready bool, cpuCap, cpuAlloc, memCap, memAlloc string, kubelet, osImage, arch string) *k8sv1.Node { + node := &k8sv1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Status: k8sv1.NodeStatus{ + Capacity: k8sv1.ResourceList{ + k8sv1.ResourceCPU: resource.MustParse(cpuCap), + k8sv1.ResourceMemory: resource.MustParse(memCap), + }, + Allocatable: k8sv1.ResourceList{ + k8sv1.ResourceCPU: resource.MustParse(cpuAlloc), + k8sv1.ResourceMemory: resource.MustParse(memAlloc), + }, + NodeInfo: k8sv1.NodeSystemInfo{ + KubeletVersion: kubelet, + OSImage: osImage, + Architecture: arch, + }, + }, + } + if ready { + node.Status.Conditions = []k8sv1.NodeCondition{ + {Type: k8sv1.NodeReady, Status: k8sv1.ConditionTrue}, + } + } else { + node.Status.Conditions = []k8sv1.NodeCondition{ + {Type: k8sv1.NodeReady, Status: k8sv1.ConditionFalse}, + } + } + return node +} + +func TestClusterMetricsAggregates(t *testing.T) { + t.Parallel() + cs := fake.NewSimpleClientset( + newNode("a", true, "4", "3500m", "16Gi", "14Gi", "v1.30.0", "Ubuntu 22.04", "amd64"), + newNode("b", true, "8", "7", "32Gi", "30Gi", "v1.30.0", "Ubuntu 22.04", "amd64"), + newNode("c", false, "4", "3500m", "16Gi", "14Gi", "v1.29.5", "Debian 12", "arm64"), + ) + d := &KubeVirtDriver{k8sClient: cs} + m, err := d.ClusterMetrics(context.Background()) + if err != nil { + t.Fatalf("ClusterMetrics: %v", err) + } + if m.Nodes != 3 { + t.Fatalf("nodes=%d want 3", m.Nodes) + } + if m.NodesReady != 2 { + t.Fatalf("nodes_ready=%d want 2", m.NodesReady) + } + if m.CPUCapacityMilli != 16000 { + t.Fatalf("cpu_capacity_millicores=%d want 16000", m.CPUCapacityMilli) + } + wantAlloc := int64(14000) // 3.5 + 7 + 3.5 cores, in millicores + if m.CPUAllocatableMilli != wantAlloc { + t.Fatalf("cpu_allocatable_millicores=%d want %d", m.CPUAllocatableMilli, wantAlloc) + } + if m.MemoryCapacity != 64*1024*1024*1024 { + t.Fatalf("memory_capacity=%d want 64Gi", m.MemoryCapacity) + } + if m.MemoryAllocatable != 58*1024*1024*1024 { + t.Fatalf("memory_allocatable=%d want 58Gi", m.MemoryAllocatable) + } + if len(m.KubeletVersions) != 2 { + t.Fatalf("kubelet_versions=%v want 2 distinct", m.KubeletVersions) + } + if len(m.OSArchitectures) != 2 { + t.Fatalf("os_architectures=%v want 2 distinct", m.OSArchitectures) + } + if len(m.OSImages) != 2 { + t.Fatalf("os_images=%v want 2 distinct", m.OSImages) + } + if m.CollectedAt.IsZero() { + t.Fatal("collected_at must be set") + } +} + +func TestClusterMetricsEmpty(t *testing.T) { + t.Parallel() + cs := fake.NewSimpleClientset() + d := &KubeVirtDriver{k8sClient: cs} + m, err := d.ClusterMetrics(context.Background()) + if err != nil { + t.Fatalf("ClusterMetrics: %v", err) + } + if m.Nodes != 0 || m.NodesReady != 0 || m.CPUAllocatableMilli != 0 { + t.Fatalf("empty cluster: %+v", m) + } + if m.KubeletVersions == nil || m.OSImages == nil || m.OSArchitectures == nil { + t.Fatal("slice fields must be non-nil (empty JSON arrays)") + } +} + +func TestIsNodeReady(t *testing.T) { + t.Parallel() + ready := newNode("a", true, "1", "1", "1Gi", "1Gi", "v1", "x", "amd64") + if !isNodeReady(ready) { + t.Fatal("expected ready") + } + notReady := newNode("b", false, "1", "1", "1Gi", "1Gi", "v1", "x", "amd64") + if isNodeReady(notReady) { + t.Fatal("expected not ready") + } + unknown := &k8sv1.Node{} + if isNodeReady(unknown) { + t.Fatal("node without conditions should not be ready") + } +} \ No newline at end of file diff --git a/internal/infra/hypervisor/cluster_usage_test.go b/internal/infra/hypervisor/cluster_usage_test.go new file mode 100644 index 0000000..6331554 --- /dev/null +++ b/internal/infra/hypervisor/cluster_usage_test.go @@ -0,0 +1,214 @@ +package hypervisor + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +func newMetricsServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, kubernetes.Interface) { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + cfg := &rest.Config{Host: srv.URL} + cs, err := kubernetes.NewForConfig(cfg) + if err != nil { + t.Fatalf("build client: %v", err) + } + return srv, cs +} + +func TestClusterUsageAggregates(t *testing.T) { + t.Parallel() + srv, cs := newMetricsServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/apis/metrics.k8s.io/v1beta1/nodes" { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "kind": "NodeMetricsList", + "apiVersion": "metrics.k8s.io/v1beta1", + "items": [ + {"usage": {"cpu": "250m", "memory": "512Mi"}, "timestamp": "2026-10-09T11:00:00Z"}, + {"usage": {"cpu": "1.5", "memory": "4Gi"}, "timestamp": "2026-10-09T11:00:00Z"}, + {"usage": {"cpu": "0", "memory": "0"}, "timestamp": "2026-10-09T11:00:00Z"} + ] + }`)) + }) + _ = srv + + d := &KubeVirtDriver{k8sClient: cs} + u, err := d.ClusterUsage(context.Background()) + if err != nil { + t.Fatalf("ClusterUsage: %v", err) + } + if u == nil { + t.Fatal("expected non-nil usage") + } + if got, want := u.CPUUsageMilli, int64(250+1500+0); got != want { + t.Fatalf("cpu_usage_millicores=%d want %d", got, want) + } + memWant := int64(512*1024*1024 + 4*1024*1024*1024) + if u.MemoryUsage != memWant { + t.Fatalf("memory_usage_bytes=%d want %d", u.MemoryUsage, memWant) + } + if u.WindowSeconds != int64(clusterUsageTTL/time.Second) { + t.Fatalf("window_seconds=%d want %d", u.WindowSeconds, int64(clusterUsageTTL/time.Second)) + } + if u.CollectedAt.IsZero() { + t.Fatal("collected_at must be set") + } +} + +func TestClusterUsageMetricsServerAbsent(t *testing.T) { + t.Parallel() + srv, cs := newMetricsServer(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"kind":"Status","status":"Failure","code":404,"reason":"NotFound","message":"the server could not find the requested resource"}`, http.StatusNotFound) + }) + _ = srv + + d := &KubeVirtDriver{k8sClient: cs} + u, err := d.ClusterUsage(context.Background()) + if err != nil { + t.Fatalf("expected graceful nil on 404, got error: %v", err) + } + if u != nil { + t.Fatalf("expected nil usage when metrics-server is absent, got %+v", u) + } +} + +func TestClusterUsageForbidden(t *testing.T) { + t.Parallel() + srv, cs := newMetricsServer(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"kind":"Status","status":"Failure","code":403,"reason":"Forbidden"}`, http.StatusForbidden) + }) + _ = srv + + d := &KubeVirtDriver{k8sClient: cs} + u, err := d.ClusterUsage(context.Background()) + if err != nil { + t.Fatalf("expected graceful nil on 403, got error: %v", err) + } + if u != nil { + t.Fatalf("expected nil usage on Forbidden, got %+v", u) + } +} + +func TestClusterUsageTransientError(t *testing.T) { + t.Parallel() + srv, cs := newMetricsServer(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"kind":"Status","status":"Failure","code":503,"reason":"ServiceUnavailable"}`, http.StatusServiceUnavailable) + }) + _ = srv + + d := &KubeVirtDriver{k8sClient: cs} + u, err := d.ClusterUsage(context.Background()) + if err != nil { + t.Fatalf("expected graceful nil on 503, got error: %v", err) + } + if u != nil { + t.Fatalf("expected nil usage on 503, got %+v", u) + } +} + +func TestClusterUsageParseError(t *testing.T) { + t.Parallel() + srv, cs := newMetricsServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ this is not json`)) + }) + _ = srv + + d := &KubeVirtDriver{k8sClient: cs} + if _, err := d.ClusterUsage(context.Background()); err == nil { + t.Fatal("expected parse error to propagate") + } +} + +func TestClusterUsageCache(t *testing.T) { + t.Parallel() + var calls atomic.Int32 + srv, cs := newMetricsServer(t, func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items": [{"usage": {"cpu": "100m", "memory": "256Mi"}}]}`)) + }) + _ = srv + + d := &KubeVirtDriver{k8sClient: cs} + if _, err := d.ClusterUsage(context.Background()); err != nil { + t.Fatalf("first call: %v", err) + } + if _, err := d.ClusterUsage(context.Background()); err != nil { + t.Fatalf("second call: %v", err) + } + if got := calls.Load(); got != 1 { + t.Fatalf("cache did not absorb the second call: requests=%d want 1", got) + } +} + +func TestClusterUsageCacheTTLExpiry(t *testing.T) { + t.Parallel() + var calls atomic.Int32 + srv, cs := newMetricsServer(t, func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items": [{"usage": {"cpu": "100m", "memory": "256Mi"}}]}`)) + }) + _ = srv + + d := &KubeVirtDriver{k8sClient: cs} + if _, err := d.ClusterUsage(context.Background()); err != nil { + t.Fatalf("first call: %v", err) + } + d.usageCacheMu.Lock() + d.usageCache.at = time.Now().Add(-2 * clusterUsageTTL) + d.usageCacheMu.Unlock() + if _, err := d.ClusterUsage(context.Background()); err != nil { + t.Fatalf("third call: %v", err) + } + if got := calls.Load(); got != 2 { + t.Fatalf("cache TTL did not expire: requests=%d want 2", got) + } +} + +func TestClusterUsageNilClient(t *testing.T) { + t.Parallel() + d := &KubeVirtDriver{} + u, err := d.ClusterUsage(context.Background()) + if err != nil { + t.Fatalf("nil client: %v", err) + } + if u != nil { + t.Fatalf("nil client must yield nil usage, got %+v", u) + } +} + +func TestIsMetricsAbsent(t *testing.T) { + t.Parallel() + cases := []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"random", errors.New("some other failure"), false}, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + if got := isMetricsAbsent(c.err); got != c.want { + t.Fatalf("isMetricsAbsent(%v)=%v want %v", c.err, got, c.want) + } + }) + } +} diff --git a/internal/infra/hypervisor/kubevirt.go b/internal/infra/hypervisor/kubevirt.go index 1f6be03..e2a022e 100644 --- a/internal/infra/hypervisor/kubevirt.go +++ b/internal/infra/hypervisor/kubevirt.go @@ -4,8 +4,11 @@ import ( "context" "encoding/json" "fmt" + "log" + "sort" "strings" "sync" + "time" "github.com/virtfoundry/core/internal/platform/branding" "github.com/virtfoundry/core/internal/platform/cloudinit" @@ -25,6 +28,13 @@ const ( defaultVMImage = "quay.io/kubevirt/cirros-container-disk-demo" virtioContainerDisk = "quay.io/kubevirt/virtio-container-disk:v1.8.4" windowsMachineType = "q35" + + // clusterUsageTTL bounds how often we hit metrics.k8s.io. metrics-server itself + // scrapes the kubelet every ~15s; a 30s server-side cache matches the design + // spec (Tier 2) and keeps the dashboard poll cheap. + clusterUsageTTL = 30 * time.Second + // clusterUsageCallTimeout caps a single metrics.k8s.io round-trip. + clusterUsageCallTimeout = 4 * time.Second ) // Cirros only drives the default VGA device; virtio-gpu yields a black VNC screen. @@ -38,8 +48,29 @@ func videoDeviceForImage(image string) *kubevirtv1.VideoDevice { // KubeVirtDriver implements Driver using KubeVirt CRDs. type KubeVirtDriver struct { virtClient kubecli.KubevirtClient - k8sClient *kubernetes.Clientset + k8sClient kubernetes.Interface namespace string + + // usageCache memoises ClusterUsage between dashboard polls. A nil value with + // a non-nil err represents "metrics-server unreachable"; the dashboard turns + // that into a degraded state. + usageCacheMu sync.Mutex + usageCache *clusterUsageCacheEntry + + // addonsCache memoises AddonsHealth between dashboard polls. + addonsCacheMu sync.Mutex + addonsCache *addonsCacheEntry +} + +type clusterUsageCacheEntry struct { + value *ClusterUsage + err error + at time.Time +} + +type addonsCacheEntry struct { + value *AddonsHealth + at time.Time } type KubeVirtConfig struct { @@ -83,11 +114,19 @@ func NewKubeVirtDriver(config KubeVirtConfig) (*KubeVirtDriver, error) { namespace = "default" } + return newKubeVirtDriver(virtClient, k8sClient, namespace), nil +} + +func newKubeVirtDriver(virtClient kubecli.KubevirtClient, k8sClient kubernetes.Interface, namespace string) *KubeVirtDriver { return &KubeVirtDriver{ virtClient: virtClient, k8sClient: k8sClient, namespace: namespace, - }, nil + } +} + +func NewKubeVirtDriverForTest(virtClient kubecli.KubevirtClient, k8sClient kubernetes.Interface) *KubeVirtDriver { + return newKubeVirtDriver(virtClient, k8sClient, "default") } func (d *KubeVirtDriver) WithNamespace(ns string) *KubeVirtDriver { @@ -430,6 +469,359 @@ func (d *KubeVirtDriver) GetHostResources(ctx context.Context) (map[string]inter }, nil } +func (d *KubeVirtDriver) ClusterMetrics(ctx context.Context) (*ClusterMetrics, error) { + nodes, err := d.k8sClient.CoreV1().Nodes().List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("list nodes: %w", err) + } + + out := &ClusterMetrics{ + Nodes: len(nodes.Items), + KubeletVersions: []string{}, + OSImages: []string{}, + OSArchitectures: []string{}, + CollectedAt: time.Now().UTC(), + } + + kubelet := map[string]struct{}{} + osImage := map[string]struct{}{} + osArch := map[string]struct{}{} + + for i := range nodes.Items { + node := &nodes.Items[i] + + if isNodeReady(node) { + out.NodesReady++ + } + out.CPUCapacityMilli += node.Status.Capacity.Cpu().MilliValue() + out.CPUAllocatableMilli += node.Status.Allocatable.Cpu().MilliValue() + out.MemoryCapacity += node.Status.Capacity.Memory().Value() + out.MemoryAllocatable += node.Status.Allocatable.Memory().Value() + + if v := node.Status.NodeInfo.KubeletVersion; v != "" { + kubelet[v] = struct{}{} + } + if v := node.Status.NodeInfo.OSImage; v != "" { + osImage[v] = struct{}{} + } + if v := node.Status.NodeInfo.Architecture; v != "" { + osArch[v] = struct{}{} + } + } + + out.KubeletVersions = sortedKeys(kubelet) + out.OSImages = sortedKeys(osImage) + out.OSArchitectures = sortedKeys(osArch) + return out, nil +} + +// ClusterUsage aggregates metrics.k8s.io/v1beta1 NodeMetrics into a single +// cluster-wide usage figure (CPU millicores + memory bytes). It returns +// (nil, nil) when metrics-server is absent, Forbidden, or when the request +// fails with a transient server error — the dashboard must keep working +// when the cluster does not expose usage data. Genuine errors (parse failure, +// etc.) are propagated. +func (d *KubeVirtDriver) ClusterUsage(ctx context.Context) (*ClusterUsage, error) { + d.usageCacheMu.Lock() + cached := d.usageCache + if cached != nil && time.Since(cached.at) < clusterUsageTTL { + d.usageCacheMu.Unlock() + return cached.value, cached.err + } + d.usageCacheMu.Unlock() + + value, err := d.fetchClusterUsage(ctx) + + d.usageCacheMu.Lock() + d.usageCache = &clusterUsageCacheEntry{value: value, err: err, at: time.Now()} + d.usageCacheMu.Unlock() + + return value, err +} + +// fetchClusterUsage performs a single metrics.k8s.io round-trip. It is not +// covered by rbac_contract_test.go (the test only sees typed client-go +// calls; metrics.k8s.io is reached via the discovery REST client, which is +// an accepted pattern — see the dynamic-client note in docs/rbac-contract.yaml). +func (d *KubeVirtDriver) fetchClusterUsage(ctx context.Context) (*ClusterUsage, error) { + if d.k8sClient == nil { + return nil, nil + } + restClient := d.k8sClient.Discovery().RESTClient() + if restClient == nil { + return nil, nil + } + callCtx, cancel := context.WithTimeout(ctx, clusterUsageCallTimeout) + defer cancel() + + raw, err := restClient. + Get(). + AbsPath("/apis/metrics.k8s.io/v1beta1/nodes"). + Do(callCtx). + Raw() + if err != nil { + if isMetricsAbsent(err) { + log.Printf("[cluster-usage] metrics.k8s.io unavailable: %v", err) + return nil, nil + } + return nil, fmt.Errorf("get metrics.k8s.io nodes: %w", err) + } + + var list struct { + Items []struct { + Usage struct { + CPU string `json:"cpu"` + Memory string `json:"memory"` + } `json:"usage"` + Timestamp string `json:"timestamp"` + } `json:"items"` + } + if err := json.Unmarshal(raw, &list); err != nil { + return nil, fmt.Errorf("decode metrics.k8s.io nodes: %w", err) + } + + out := &ClusterUsage{ + WindowSeconds: int64(clusterUsageTTL / time.Second), + CollectedAt: time.Now().UTC(), + } + for _, item := range list.Items { + cpu := resource.MustParse(item.Usage.CPU) + mem := resource.MustParse(item.Usage.Memory) + out.CPUUsageMilli += cpu.MilliValue() + out.MemoryUsage += mem.Value() + } + return out, nil +} + +// isMetricsAbsent recognises the failure modes the dashboard tolerates: +// - the API is not registered (404, metrics-server not installed) +// - the ServiceAccount is not granted access (403) +// - the API server is overloaded or briefly unavailable (5xx, timeout) +func isMetricsAbsent(err error) bool { + if err == nil { + return false + } + if errors.IsNotFound(err) || errors.IsForbidden(err) || errors.IsUnauthorized(err) { + return true + } + if errors.IsServiceUnavailable(err) || errors.IsTimeout(err) || errors.IsServerTimeout(err) { + return true + } + msg := err.Error() + return strings.Contains(msg, "connection refused") || + strings.Contains(msg, "no such host") || + strings.Contains(msg, "the server could not find the requested resource") +} + +// StorageSummary aggregates PVCs in the driver namespace into a single +// tenant-scoped storage figure. Counts every PVC in the namespace (Bound or +// Pending) toward TotalBytes (the reserved capacity the user has asked for), +// and the Bound subset toward UsedBytes (actually attached). Returns nil +// without error when the driver has no k8s client (memory store) so the +// dashboard degrades gracefully. +func (d *KubeVirtDriver) StorageSummary(ctx context.Context) (*StorageSummary, error) { + if d.k8sClient == nil { + return nil, nil + } + list, err := d.k8sClient.CoreV1().PersistentVolumeClaims(d.namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + if errors.IsForbidden(err) || errors.IsUnauthorized(err) { + return nil, nil + } + return nil, fmt.Errorf("list PVCs: %w", err) + } + out := &StorageSummary{Count: len(list.Items)} + for i := range list.Items { + pvc := &list.Items[i] + req := pvc.Spec.Resources.Requests.Storage() + if req == nil { + continue + } + bytes := req.Value() + out.TotalBytes += bytes + if pvc.Status.Phase == k8sv1.ClaimBound { + out.UsedBytes += bytes + } + } + out.AvailableBytes = out.TotalBytes - out.UsedBytes + return out, nil +} + +// AddonsHealth probes each critical cluster dependency (KubeVirt, CDI, Multus, +// metrics-server, networking) and reports whether its API group is registered. +// Results are memoised for the same 30s window as ClusterUsage so a dashboard +// poll only triggers one discovery round-trip per cache miss. +func (d *KubeVirtDriver) AddonsHealth(ctx context.Context) (*AddonsHealth, error) { + d.addonsCacheMu.Lock() + cached := d.addonsCache + if cached != nil && time.Since(cached.at) < clusterUsageTTL { + d.addonsCacheMu.Unlock() + return cached.value, nil + } + d.addonsCacheMu.Unlock() + + value := d.probeAddons(ctx) + + d.addonsCacheMu.Lock() + d.addonsCache = &addonsCacheEntry{value: value, at: time.Now()} + d.addonsCacheMu.Unlock() + + return value, nil +} + +// addonGroupAliases maps a substring of a CRD's spec.group to a short, +// human-friendly addon name. Entries are checked in order; the first match +// wins, so the more specific needles (e.g. "cdi.kubevirt.io") must come +// before the less specific ones (e.g. "kubevirt.io") they are a suffix of. +// Add a new entry here to teach the dashboard about a new addon that does +// not carry the Helm labels. +var addonGroupAliases = []struct { + needle string + alias string +}{ + {"cdi.kubevirt.io", "cdi"}, + {"kubevirt.io", "kubevirt"}, + {"k8s.cni.cncf.io", "multus"}, + {"cert-manager.io", "cert-manager"}, + {"istio.io", "istio"}, + {"monitoring.coreos.com", "prometheus"}, + {"tekton.dev", "tekton"}, + {"argoproj.io", "argocd"}, +} + +func aliasFromGroup(group string) string { + for _, e := range addonGroupAliases { + if strings.Contains(group, e.needle) { + return e.alias + } + } + return "" +} + +// resolveAddonName extracts an addon identifier from a CRD, in order: +// 1. app.kubernetes.io/name +// 2. app.kubernetes.io/part-of +// 3. app.kubernetes.io/component +// 4. A short alias derived from spec.group +// +// Returns "" if no identifier is found; the caller skips the CRD. +func resolveAddonName(labels map[string]string, group string) string { + for _, key := range []string{ + "app.kubernetes.io/name", + "app.kubernetes.io/part-of", + "app.kubernetes.io/component", + } { + if v := labels[key]; v != "" { + return v + } + } + return aliasFromGroup(group) +} + +// probeAddons discovers cluster addons dynamically by listing every +// CustomResourceDefinition, resolving an identifier through Helm labels +// (name/part-of/component) with a fallback to a group-prefix alias, and +// grouping by that identifier. CRDs with no resolvable name are ignored. +// The probe fails soft: any error (Forbidden, timeout, missing CRD list +// permission) returns an empty list rather than failing the dashboard. +func (d *KubeVirtDriver) probeAddons(ctx context.Context) *AddonsHealth { + out := &AddonsHealth{ + Addons: []AddonHealth{}, + CheckedAt: time.Now().UTC(), + } + if d.k8sClient == nil { + return out + } + restClient := d.k8sClient.Discovery().RESTClient() + if restClient == nil { + return out + } + + raw, err := restClient. + Get(). + AbsPath("/apis/apiextensions.k8s.io/v1/customresourcedefinitions"). + Do(ctx). + Raw() + if err != nil { + log.Printf("[cluster-addons] list CRDs: %v", err) + return out + } + + var list struct { + Items []struct { + Metadata struct { + Name string `json:"name"` + Labels map[string]string `json:"labels"` + } `json:"metadata"` + Spec struct { + Group string `json:"group"` + } `json:"spec"` + Status struct { + Conditions []struct { + Type string `json:"type"` + Status string `json:"status"` + } `json:"conditions"` + } `json:"status"` + } `json:"items"` + } + if err := json.Unmarshal(raw, &list); err != nil { + log.Printf("[cluster-addons] decode CRDs: %v", err) + return out + } + + established := map[string]bool{} + seen := map[string]struct{}{} + for _, crd := range list.Items { + name := resolveAddonName(crd.Metadata.Labels, crd.Spec.Group) + if name == "" { + continue + } + if _, dup := seen[name]; dup { + continue + } + seen[name] = struct{}{} + for _, c := range crd.Status.Conditions { + if c.Type == "Established" && c.Status == "True" { + established[name] = true + break + } + } + } + + names := make([]string, 0, len(seen)) + for n := range seen { + names = append(names, n) + } + sort.Strings(names) + + for _, n := range names { + status := "ok" + if !established[n] { + status = "degraded" + } + out.Addons = append(out.Addons, AddonHealth{Name: n, Status: status}) + } + return out +} + +func isNodeReady(node *k8sv1.Node) bool { + for i := range node.Status.Conditions { + if node.Status.Conditions[i].Type == k8sv1.NodeReady { + return node.Status.Conditions[i].Status == k8sv1.ConditionTrue + } + } + return false +} + +func sortedKeys(set map[string]struct{}) []string { + out := make([]string, 0, len(set)) + for k := range set { + out = append(out, k) + } + sort.Strings(out) + return out +} + func (d *KubeVirtDriver) CreateVolume(ctx context.Context, name string, size string) (*Volume, error) { pvc := &k8sv1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{ diff --git a/internal/infra/hypervisor/storage_summary_test.go b/internal/infra/hypervisor/storage_summary_test.go new file mode 100644 index 0000000..225407e --- /dev/null +++ b/internal/infra/hypervisor/storage_summary_test.go @@ -0,0 +1,104 @@ +package hypervisor + +import ( + "context" + "testing" + + k8sv1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func newPVC(name, size string, phase k8sv1.PersistentVolumeClaimPhase) *k8sv1.PersistentVolumeClaim { + return &k8sv1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Spec: k8sv1.PersistentVolumeClaimSpec{ + Resources: k8sv1.VolumeResourceRequirements{ + Requests: k8sv1.ResourceList{ + k8sv1.ResourceStorage: resource.MustParse(size), + }, + }, + }, + Status: k8sv1.PersistentVolumeClaimStatus{Phase: phase}, + } +} + +func TestStorageSummaryAggregates(t *testing.T) { + t.Parallel() + cs := fake.NewSimpleClientset( + newPVC("vol-1", "10Gi", k8sv1.ClaimBound), + newPVC("vol-2", "20Gi", k8sv1.ClaimBound), + newPVC("vol-3", "5Gi", k8sv1.ClaimPending), + newPVC("vol-4", "1Gi", k8sv1.ClaimLost), + ) + d := &KubeVirtDriver{k8sClient: cs, namespace: "default"} + s, err := d.StorageSummary(context.Background()) + if err != nil { + t.Fatalf("StorageSummary: %v", err) + } + if s.Count != 4 { + t.Fatalf("count=%d want 4", s.Count) + } + wantTotal := int64((10 + 20 + 5 + 1) * 1024 * 1024 * 1024) + if s.TotalBytes != wantTotal { + t.Fatalf("total_bytes=%d want %d", s.TotalBytes, wantTotal) + } + wantUsed := int64((10 + 20) * 1024 * 1024 * 1024) + if s.UsedBytes != wantUsed { + t.Fatalf("used_bytes=%d want %d", s.UsedBytes, wantUsed) + } + if s.AvailableBytes != s.TotalBytes-s.UsedBytes { + t.Fatalf("available_bytes=%d want total-used=%d", s.AvailableBytes, s.TotalBytes-s.UsedBytes) + } +} + +func TestStorageSummaryEmpty(t *testing.T) { + t.Parallel() + cs := fake.NewSimpleClientset() + d := &KubeVirtDriver{k8sClient: cs, namespace: "default"} + s, err := d.StorageSummary(context.Background()) + if err != nil { + t.Fatalf("StorageSummary: %v", err) + } + if s.Count != 0 || s.TotalBytes != 0 || s.UsedBytes != 0 || s.AvailableBytes != 0 { + t.Fatalf("expected zero-value summary, got %+v", s) + } +} + +func TestStorageSummaryNilClient(t *testing.T) { + t.Parallel() + d := &KubeVirtDriver{} + s, err := d.StorageSummary(context.Background()) + if err != nil { + t.Fatalf("nil client: %v", err) + } + if s != nil { + t.Fatalf("nil client must yield nil summary, got %+v", s) + } +} + +func TestStorageSummarySkipsPVCsWithoutStorageRequest(t *testing.T) { + t.Parallel() + cs := fake.NewSimpleClientset( + newPVC("vol-1", "10Gi", k8sv1.ClaimBound), + &k8sv1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "vol-bare", Namespace: "default"}, + Spec: k8sv1.PersistentVolumeClaimSpec{ + Resources: k8sv1.VolumeResourceRequirements{Requests: k8sv1.ResourceList{}}, + }, + Status: k8sv1.PersistentVolumeClaimStatus{Phase: k8sv1.ClaimBound}, + }, + ) + d := &KubeVirtDriver{k8sClient: cs, namespace: "default"} + s, err := d.StorageSummary(context.Background()) + if err != nil { + t.Fatalf("StorageSummary: %v", err) + } + if s.Count != 2 { + t.Fatalf("count=%d want 2 (PVCs without storage request must still be counted)", s.Count) + } + if s.TotalBytes != 10*1024*1024*1024 { + t.Fatalf("total_bytes=%d want 10Gi", s.TotalBytes) + } +} diff --git a/internal/infra/hypervisor/types.go b/internal/infra/hypervisor/types.go index a4b08b6..c942ce9 100644 --- a/internal/infra/hypervisor/types.go +++ b/internal/infra/hypervisor/types.go @@ -90,6 +90,69 @@ type ClusterInfo struct { MemoryTotal int64 } +type ClusterMetrics struct { + Nodes int `json:"nodes"` + NodesReady int `json:"nodes_ready"` + CPUCapacityMilli int64 `json:"cpu_capacity_millicores"` + CPUAllocatableMilli int64 `json:"cpu_allocatable_millicores"` + MemoryCapacity int64 `json:"memory_capacity_bytes"` + MemoryAllocatable int64 `json:"memory_allocatable_bytes"` + KubeletVersions []string `json:"kubelet_versions"` + OSImages []string `json:"os_images"` + OSArchitectures []string `json:"os_architectures"` + CollectedAt time.Time `json:"collected_at"` + + // Usage is populated when metrics-server is reachable. Available is false when + // the API is absent, the call is Forbidden, or the cluster has no Ready nodes + // to report. The dashboard renders a degraded state instead of failing the + // summary when Available is false. + Usage *ClusterUsage `json:"usage,omitempty"` +} + +// ClusterUsage is the sum of metrics.k8s.io/v1beta1 NodeMetrics across all +// nodes, served by metrics-server. Tier 2 of the dashboard host metrics spec. +type ClusterUsage struct { + CPUUsageMilli int64 `json:"cpu_usage_millicores"` + MemoryUsage int64 `json:"memory_usage_bytes"` + WindowSeconds int64 `json:"window_seconds"` + CollectedAt time.Time `json:"collected_at"` +} + +// StorageSummary aggregates tenant-scoped PVC capacity from the cluster. +// TotalBytes is the sum of every PVC's spec.resources.requests.storage +// (capacity the user has reserved); UsedBytes is the subset of those PVCs +// whose status.phase is Bound (actually attached to a pod). AvailableBytes is +// Total - Used and is informational — there is no global "free" pool in K8s. +type StorageSummary struct { + TotalBytes int64 `json:"total_bytes"` + UsedBytes int64 `json:"used_bytes"` + AvailableBytes int64 `json:"available_bytes"` + Count int `json:"count"` +} + +// AddonHealth is the status of one critical VirtFoundry dependency on the +// cluster (KubeVirt, CDI, Multus, metrics-server, networking). The dashboard +// renders a single coloured dot per addon. +type AddonHealth struct { + // Name is a stable identifier ("kubevirt", "cdi", "multus", + // "metrics-server", "networking") used by the UI for i18n lookup. + Name string `json:"name"` + // Status is one of: "ok" (installed and reachable), "absent" (CRD/API + // not registered on the cluster), "unknown" (Forbidden or transient + // failure — the UI shows a neutral dot in this case). + Status string `json:"status"` + // Detail is a short human-readable reason, sanitised server-side. It is + // only populated when Status != "ok". + Detail string `json:"detail,omitempty"` +} + +// AddonsHealth is the per-check summary of every addon the dashboard tracks. +// CheckedAt lets the UI show how stale the dots are. +type AddonsHealth struct { + Addons []AddonHealth `json:"addons"` + CheckedAt time.Time `json:"checked_at"` +} + // VMSnapshotInfo represents a KubeVirt VirtualMachineSnapshot. type VMSnapshotInfo struct { Name string diff --git a/internal/service/dashboard.go b/internal/service/dashboard.go index 861ab96..db75507 100644 --- a/internal/service/dashboard.go +++ b/internal/service/dashboard.go @@ -7,7 +7,9 @@ import ( "time" "github.com/virtfoundry/core/internal/auth" + "github.com/virtfoundry/core/internal/infra/hypervisor" "github.com/virtfoundry/core/internal/platform" + k8serrors "k8s.io/apimachinery/pkg/api/errors" ) // DashboardSummary aggregates tenant overview for the UI dashboard. @@ -19,6 +21,9 @@ type DashboardSummary struct { SecurityGroups DashboardResourceCount `json:"security_groups"` Health string `json:"health"` RecentActivity []DashboardActivity `json:"recent_activity"` + Hosts *hypervisor.ClusterMetrics `json:"hosts,omitempty"` + Storage *hypervisor.StorageSummary `json:"storage,omitempty"` + Addons *hypervisor.AddonsHealth `json:"addons,omitempty"` } type DashboardResourceCount struct { @@ -62,6 +67,15 @@ func (s *PlatformService) DashboardSummary(ctx context.Context, tenantID string, Health: "ok", RecentActivity: []DashboardActivity{}, } + if err := s.populateHosts(ctx, summary); err != nil { + return nil, err + } + if err := s.populateStorage(ctx, summary); err != nil { + return nil, err + } + if err := s.populateAddons(ctx, summary); err != nil { + return nil, err + } if !auth.HasPermission(perms, auth.PermVMsRead) { return summary, nil } @@ -80,6 +94,67 @@ func (s *PlatformService) DashboardSummary(ctx context.Context, tenantID string, return summary, nil } +func (s *PlatformService) populateStorage(ctx context.Context, summary *DashboardSummary) error { + if s.kv == nil { + return nil + } + storage, err := s.kv.StorageSummary(ctx) + if err != nil { + if isExpectedHostsError(err) { + return nil + } + return err + } + summary.Storage = storage + return nil +} + +func (s *PlatformService) populateAddons(ctx context.Context, summary *DashboardSummary) error { + if s.kv == nil { + return nil + } + addons, err := s.kv.AddonsHealth(ctx) + if err != nil { + if isExpectedHostsError(err) { + return nil + } + return err + } + summary.Addons = addons + return nil +} + +func (s *PlatformService) populateHosts(ctx context.Context, summary *DashboardSummary) error { + if s.kv == nil { + return nil + } + metrics, err := s.kv.ClusterMetrics(ctx) + if err != nil { + if isExpectedHostsError(err) { + return nil + } + return err + } + summary.Hosts = metrics + usage, err := s.kv.ClusterUsage(ctx) + if err != nil { + return err + } + summary.Hosts.Usage = usage + return nil +} + +func isExpectedHostsError(err error) bool { + if err == nil { + return false + } + if k8serrors.IsForbidden(err) || k8serrors.IsUnauthorized(err) || k8serrors.IsServiceUnavailable(err) || k8serrors.IsTimeout(err) || k8serrors.IsServerTimeout(err) { + return true + } + msg := err.Error() + return strings.Contains(msg, "connection refused") || strings.Contains(msg, "no such host") +} + func (s *PlatformService) Search(ctx context.Context, tenantID, query string, perms []string) []SearchHit { q := strings.TrimSpace(strings.ToLower(query)) if len(q) < 2 { diff --git a/internal/service/platform.go b/internal/service/platform.go index 769fd09..46b4958 100644 --- a/internal/service/platform.go +++ b/internal/service/platform.go @@ -38,6 +38,7 @@ type PlatformService struct { jobs *jobs.Service sshkeys *sshkeys.Service lb *loadbalancer.Service + kv *hypervisor.KubeVirtDriver } func NewPlatformService(st store.Repository, k8s *platformk8s.Manager, kv *hypervisor.KubeVirtDriver, hub EventBroadcaster) *PlatformService { @@ -54,6 +55,7 @@ func NewPlatformService(st store.Repository, k8s *platformk8s.Manager, kv *hyper jobs: jobs.New(st, computeSvc), sshkeys: sshkeys.New(st), lb: loadbalancer.New(st, k8s), + kv: kv, } } From d8ea32b3231c31dfbfa852e27d801bcbad3be0f8 Mon Sep 17 00:00:00 2001 From: Weslei Paulo Pereira Date: Fri, 9 Oct 2026 16:20:54 -0300 Subject: [PATCH 2/4] feat(ui): render cluster overview in the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the new hosts/storage/addons fields from the previous slice on the existing Dashboard page: - Rename the section title from 'Cluster nodes' to 'Cluster overview' to fit the broader scope (nodes + capacity + addons). - Four cards inside the section: Nodes, Ready nodes, Allocatable CPU, Allocatable memory. The CPU/memory cards now carry a usage bar (used / allocatable) and a percent line, with a colour ramp on 60% / 85% thresholds. A small 'Todos prontos' / 'N degraded' / 'None ready' badge sits next to the Ready nodes count. - The four cards share a common baseline via h-full + flex-1 so the percent and the bars line up at the bottom regardless of how much content the other cards carry. - A new 'Storage' card replaces the redundant 'Networks' tile in the stats row, formatted with auto-scaled units (B / KiB / MiB / GiB / TiB) and a percent line. - A 'Cluster addons' strip below the Kubelet / OS / Arch footer renders one dot per critical dependency, green and pulsing when Established, an outlined ring when absent, yellow and pulsing when unreachable. The list is discovered dynamically by the backend — only addons actually present on the cluster are shown. - The dashboard query polls every 10s when /ws/events is up and 5s when it is down, so the usage bars and the addons strip stay current without a manual refresh. The existing realtime-invalidation path still drives VM transitions. The polling is on a fixed schedule rather than the previous 'health == ok' gate because the underlying data sources (metrics.k8s.io, the addon probe) have no /ws/events counterpart. i18n: PT and EN parity for every new key, asserted by i18n.test.ts. typecheck: tsc --noEmit reports 0 errors new to this commit (the 'isFetching' unused warning is pre-existing). --- ui/src/lib/i18n.tsx | 56 +++++++ ui/src/lib/platform-api.ts | 42 ++++++ ui/src/pages/Dashboard.tsx | 302 +++++++++++++++++++++++++++++++++++-- 3 files changed, 387 insertions(+), 13 deletions(-) diff --git a/ui/src/lib/i18n.tsx b/ui/src/lib/i18n.tsx index 5fa2c90..8fd42f5 100644 --- a/ui/src/lib/i18n.tsx +++ b/ui/src/lib/i18n.tsx @@ -106,6 +106,34 @@ export const dict = { 'dashboard.newVpc': 'Nova VPC', 'dashboard.snapshot': 'Snapshot', 'dashboard.running': 'em execução', + 'dashboard.storageTitle': 'Storage', + 'dashboard.storageUsedOf': '{used} de {total}', + 'dashboard.storageCount': '{n} volumes', + 'dashboard.addonsTitle': 'Cluster addons', + 'dashboard.addonsKubevirt': 'KubeVirt', + 'dashboard.addonsCdi': 'CDI', + 'dashboard.addonsMultus': 'Multus', + 'dashboard.addonsMetricsServer': 'metrics-server', + 'dashboard.addonsNetworking': 'Networking', + 'dashboard.addonsCertManager': 'cert-manager', + 'dashboard.addonsStatusOk': 'OK', + 'dashboard.addonsStatusAbsent': 'ausente', + 'dashboard.addonsStatusUnknown': 'sem resposta', + 'dashboard.hostsTitle': 'Cluster overview', + 'dashboard.hostsNodes': 'Nodes', + 'dashboard.hostsNodesReady': 'Nodes prontos', + 'dashboard.hostsNodesAllReady': 'Todos prontos', + 'dashboard.hostsNodesSomeDegraded': '{n} degradado(s)', + 'dashboard.hostsNodesNoneReady': 'Nenhum pronto', + 'dashboard.hostsCpu': 'CPU alocável', + 'dashboard.hostsMemory': 'Memória alocável', + 'dashboard.hostsAllocatableHint': 'Soma de Status.Allocatable de todos os nodes do cluster.', + 'dashboard.hostsUsage': 'Em uso', + 'dashboard.hostsUsageUnavailable': 'metrics-server indisponível', + 'dashboard.hostsUnavailable': 'Cluster indisponível', + 'dashboard.hostsKubelet': 'Kubelet', + 'dashboard.hostsOs': 'Imagem do SO', + 'dashboard.hostsArch': 'Arquitetura', 'header.searchPlaceholder': 'Buscar recursos...', 'header.searchEmpty': 'Nenhum resultado', 'header.notifications': 'Notificações', @@ -742,6 +770,34 @@ export const dict = { 'dashboard.newVpc': 'New VPC', 'dashboard.snapshot': 'Snapshot', 'dashboard.running': 'running', + 'dashboard.storageTitle': 'Storage', + 'dashboard.storageUsedOf': '{used} of {total}', + 'dashboard.storageCount': '{n} volumes', + 'dashboard.addonsTitle': 'Cluster addons', + 'dashboard.addonsKubevirt': 'KubeVirt', + 'dashboard.addonsCdi': 'CDI', + 'dashboard.addonsMultus': 'Multus', + 'dashboard.addonsMetricsServer': 'metrics-server', + 'dashboard.addonsNetworking': 'Networking', + 'dashboard.addonsCertManager': 'cert-manager', + 'dashboard.addonsStatusOk': 'OK', + 'dashboard.addonsStatusAbsent': 'absent', + 'dashboard.addonsStatusUnknown': 'unreachable', + 'dashboard.hostsTitle': 'Cluster overview', + 'dashboard.hostsNodes': 'Nodes', + 'dashboard.hostsNodesReady': 'Ready nodes', + 'dashboard.hostsNodesAllReady': 'All ready', + 'dashboard.hostsNodesSomeDegraded': '{n} degraded', + 'dashboard.hostsNodesNoneReady': 'None ready', + 'dashboard.hostsCpu': 'Allocatable CPU', + 'dashboard.hostsMemory': 'Allocatable memory', + 'dashboard.hostsAllocatableHint': 'Sum of Status.Allocatable across all cluster nodes.', + 'dashboard.hostsUsage': 'Usage', + 'dashboard.hostsUsageUnavailable': 'metrics-server unavailable', + 'dashboard.hostsUnavailable': 'Cluster unavailable', + 'dashboard.hostsKubelet': 'Kubelet', + 'dashboard.hostsOs': 'OS image', + 'dashboard.hostsArch': 'Architecture', 'header.searchPlaceholder': 'Search resources...', 'header.searchEmpty': 'No results', 'header.notifications': 'Notifications', diff --git a/ui/src/lib/platform-api.ts b/ui/src/lib/platform-api.ts index 5fdefde..9296f0f 100644 --- a/ui/src/lib/platform-api.ts +++ b/ui/src/lib/platform-api.ts @@ -681,6 +681,48 @@ export interface DashboardSummary { updated_at?: string; path: string; }>; + hosts?: ClusterMetrics; + storage?: StorageSummary; + addons?: AddonsHealth; +} + +export interface ClusterMetrics { + nodes: number; + nodes_ready: number; + cpu_capacity_millicores: number; + cpu_allocatable_millicores: number; + memory_capacity_bytes: number; + memory_allocatable_bytes: number; + kubelet_versions: string[]; + os_images: string[]; + os_architectures: string[]; + collected_at: string; + usage?: ClusterUsage; +} + +export interface ClusterUsage { + cpu_usage_millicores: number; + memory_usage_bytes: number; + window_seconds: number; + collected_at: string; +} + +export interface StorageSummary { + total_bytes: number; + used_bytes: number; + available_bytes: number; + count: number; +} + +export interface AddonHealth { + name: string; + status: 'ok' | 'absent' | 'unknown'; + detail?: string; +} + +export interface AddonsHealth { + addons: AddonHealth[]; + checked_at: string; } export interface SearchHit { diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index bd6ec2a..4bcf915 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -1,4 +1,4 @@ -import { HardDrive, Globe, Shield, Network, CheckCircle, AlertTriangle, RefreshCw } from 'lucide-react'; +import { HardDrive, Globe, Shield, Server, Cpu, MemoryStick, CheckCircle, AlertTriangle, RefreshCw } from 'lucide-react'; import clsx from 'clsx'; import { useQuery } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; @@ -6,7 +6,7 @@ import { getDashboardSummary } from '../lib/platform-api'; import { useNeedsTenant } from '../store/hooks'; import { queryKeys } from '../lib/query-keys'; import { RefreshingPanel } from '../components/RefreshingPanel'; -import { useI18n } from '../lib/i18n'; +import { useI18n, type TranslationKey } from '../lib/i18n'; import { PageHeader, SurfaceCard } from '../components/shell'; import { OnboardingChecklist } from '../components/OnboardingChecklist'; @@ -14,11 +14,125 @@ import { getRecentActions } from '../lib/preview-prefs'; import { ComingSoonBadge } from '../components/ComingSoonBadge'; import { useMemo } from 'react'; import { - POLL_WS_HEALTHY_SLOW_MS, - realtimePollInterval, useRealtimeConnected, } from '../hooks/useRealtimeEvents'; +function usageTone(pct: number): string { + if (pct >= 85) return 'bg-error'; + if (pct >= 60) return 'bg-warning'; + return 'bg-primary'; +} + +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return '—'; + if (bytes === 0) return '0 B'; + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']; + const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024))); + const value = bytes / Math.pow(1024, i); + return `${value.toFixed(value >= 100 || i === 0 ? 0 : 1)} ${units[i]}`; +} + +function UsageBar({ pct, collectedAt }: { pct: number; collectedAt?: string }) { + const clamped = Math.min(100, Math.max(0, pct)); + return ( +
+
+
+
+

+ {clamped}% +

+
+ ); +} + +type NodeHealthTone = { + badge: string; + label: TranslationKey; +}; + +function nodeHealth(total: number, ready: number): NodeHealthTone | null { + if (total <= 0) return null; + if (ready >= total) { + return { badge: 'bg-success-muted text-success border-success/20', label: 'dashboard.hostsNodesAllReady' }; + } + if (ready <= 0) { + return { badge: 'bg-error-container/30 text-error border-error/30', label: 'dashboard.hostsNodesNoneReady' }; + } + return { badge: 'bg-warning-muted text-warning border-warning/20', label: 'dashboard.hostsNodesSomeDegraded' }; +} + +function NodesHealthBadge({ total, ready }: { total: number; ready: number }) { + const { t } = useI18n(); + const tone = nodeHealth(total, ready); + if (!tone) return null; + const label = tone.label === 'dashboard.hostsNodesSomeDegraded' + ? t(tone.label).replace('{n}', String(total - ready)) + : t(tone.label); + return ( + + {label} + + ); +} + +const ADDON_LABEL_KEY: Record = { + kubevirt: 'dashboard.addonsKubevirt', + cdi: 'dashboard.addonsCdi', + multus: 'dashboard.addonsMultus', + 'metrics-server': 'dashboard.addonsMetricsServer', + networking: 'dashboard.addonsNetworking', + 'cert-manager': 'dashboard.addonsCertManager', +}; + +function addonTone(status: string): { dot: string; pulse: boolean; labelKey: TranslationKey } { + if (status === 'ok') return { dot: 'bg-success', pulse: true, labelKey: 'dashboard.addonsStatusOk' }; + if (status === 'absent') return { dot: 'bg-transparent border border-on-surface-variant/60', pulse: false, labelKey: 'dashboard.addonsStatusAbsent' }; + return { dot: 'bg-warning', pulse: true, labelKey: 'dashboard.addonsStatusUnknown' }; +} + +function AddonsHealthStrip({ addons }: { addons: { addons: Array<{ name: string; status: string; detail?: string }>; checked_at: string } }) { + const { t } = useI18n(); + const available = addons.addons.filter((a) => a.status === 'ok'); + if (available.length === 0) return null; + return ( +
+

+ {t('dashboard.addonsTitle')} +

+
+ {available.map((a) => { + const tone = addonTone(a.status); + const labelKey = ADDON_LABEL_KEY[a.name] ?? null; + const label = labelKey ? t(labelKey) : a.name; + const statusLabel = t(tone.labelKey); + return ( +
+ + + {label} + +
+ ); + })} +
+
+ ); +} + export function Dashboard() { const { t } = useI18n(); const needsTenant = useNeedsTenant(); @@ -30,14 +144,11 @@ export function Dashboard() { queryKey: queryKeys.dashboardSummary, queryFn: getDashboardSummary, enabled, - refetchInterval: (q) => { - const health = q.state.data?.health; - const unhealthy = health === 'warning' || health === 'critical'; - return realtimePollInterval(wsConnected, unhealthy, { - downMs: 5_000, - healthyMs: POLL_WS_HEALTHY_SLOW_MS, - }); - }, + // Cluster usage comes from metrics.k8s.io polled every ~15s — there is no + // platform event for it, so the dashboard must keep polling regardless of + // /ws/events health. The WS still invalidates VM counts on transitions + // (see lib/realtime-invalidation), so the two paths compose. + refetchInterval: () => (wsConnected ? 10_000 : 5_000), }); const vms = summary?.vms ?? { total: 0, running: 0, error: 0 }; @@ -49,10 +160,35 @@ export function Dashboard() { { label: t('nav.volumes'), value: summary?.volumes.total ?? 0, icon: HardDrive }, { label: t('nav.vpcs'), value: summary?.vpcs.total ?? 0, icon: Globe }, { label: t('nav.securityGroups'), value: summary?.security_groups.total ?? 0, icon: Shield }, - { label: t('nav.networks'), value: summary?.networks.total ?? 0, icon: Network }, ]; const recentVms = summary?.recent_activity ?? []; + const hosts = summary?.hosts; + const hostsUnavailable = hosts === undefined; + const cpuCores = hosts ? (hosts.cpu_allocatable_millicores / 1000).toFixed(1) : '0.0'; + const storage = summary?.storage; + const storageUsed = storage ? formatBytes(storage.used_bytes) : '—'; + const storageTotal = storage ? formatBytes(storage.total_bytes) : '—'; + const storagePct = storage && storage.total_bytes > 0 + ? Math.round((storage.used_bytes / storage.total_bytes) * 100) + : null; + const memGiB = hosts ? Math.round(hosts.memory_allocatable_bytes / (1024 * 1024 * 1024)) : 0; + const kubeletSummary = hosts?.kubelet_versions?.length + ? hosts.kubelet_versions.length === 1 + ? hosts.kubelet_versions[0] + : `${hosts.kubelet_versions.length} versões` + : ''; + const osImage = hosts?.os_images?.[0] ?? ''; + const arch = hosts?.os_architectures?.[0] ?? ''; + const usage = hosts?.usage; + const cpuUsageCores = usage ? (usage.cpu_usage_millicores / 1000).toFixed(1) : null; + const memUsageGiB = usage ? Math.round(usage.memory_usage_bytes / (1024 * 1024 * 1024)) : null; + const cpuUsagePct = usage && hosts && hosts.cpu_allocatable_millicores > 0 + ? Math.round((usage.cpu_usage_millicores / hosts.cpu_allocatable_millicores) * 100) + : null; + const memUsagePct = usage && hosts && hosts.memory_allocatable_bytes > 0 + ? Math.round((usage.memory_usage_bytes / hosts.memory_allocatable_bytes) * 100) + : null; if (needsTenant) { return ( @@ -158,6 +294,34 @@ export function Dashboard() {
))} + +
+ + {storage && storagePct !== null ? ( + <> + + {t('dashboard.storageUsedOf').replace('{used}', storageUsed).replace('{total}', storageTotal)} + + + {storagePct}% + + + {t('dashboard.storageCount').replace('{n}', String(storage.count))} + + + ) : ( + <> + + {t('dashboard.storageTitle')} + + — + + )} +
+
@@ -220,6 +384,118 @@ export function Dashboard() { ))} + + + {hostsUnavailable ? ( +

+ {t('dashboard.hostsUnavailable')} +

+ ) : ( + <> +
+
+ + + {t('dashboard.hostsNodes')} + + + {hosts?.nodes ?? 0} + +
+
+
+ + + {t('dashboard.hostsNodesReady')} + + + {hosts?.nodes_ready ?? 0} + + +
+
+
+ + + {t('dashboard.hostsCpu')} + + + {cpuCores} + +
+ {cpuUsageCores !== null ? ( + + {cpuUsageCores} {t('dashboard.hostsUsage')} + + ) : ( + + )} + {cpuUsagePct !== null ? ( + + ) : ( + + {t('dashboard.hostsUsageUnavailable')} + + )} +
+
+ + + {t('dashboard.hostsMemory')} + + + {memGiB} GiB + +
+ {memUsageGiB !== null ? ( + + {memUsageGiB} GiB {t('dashboard.hostsUsage')} + + ) : ( + + )} + {memUsagePct !== null ? ( + + ) : ( + + {t('dashboard.hostsUsageUnavailable')} + + )} +
+
+ {(kubeletSummary || osImage || arch) && ( +

+ {[ + kubeletSummary && `${t('dashboard.hostsKubelet')}: ${kubeletSummary}`, + osImage && `${t('dashboard.hostsOs')}: ${osImage}`, + arch && `${t('dashboard.hostsArch')}: ${arch}`, + ] + .filter(Boolean) + .join(' · ')} +

+ )} + {summary?.addons && } + + )} +
From 63dcc8d47350b043fa23d6e52457d56fb6bab680 Mon Sep 17 00:00:00 2001 From: Weslei Paulo Pereira Date: Fri, 9 Oct 2026 16:23:14 -0300 Subject: [PATCH 3/4] feat(security): restrict the Cluster overview section to root The dashboard Cluster overview (node counts, capacity, addons) is platform-wide telemetry, not tenant-scoped. Hide it from any actor whose role is not 'root'. Two layers: 1. service.DashboardSummary now takes a role argument. When the role is anything other than platform.RoleRoot, the service skips populateHosts, populateStorage and populateAddons; the JSON response carries no 'hosts', 'storage' or 'addons' field, so nothing leaks through the Network tab or other consumers. 2. The Dashboard React component gates the entire with selectIsRoot, so non-root never even renders the card. Tenant admins and tenant users still see the rest of the dashboard: volume / VPC / SG / network counts, recent activity, the VM running% badge, and the health card. Only the platform-wide telemetry is gone. The handler passes actor.Role (empty string when unauthenticated, which is also not root). Two new tests (TestDashboardSummaryHidesClusterOverviewForNonRoot and TestDashboardSummaryShowsClusterOverviewForRoot) lock the behaviour; the existing hosts/storage/addons tests now pass platform.RoleRoot explicitly so they keep exercising the populated path. --- CHANGELOG.md | 42 ++++++ internal/api/handler/platform_handler.go | 4 +- internal/service/dashboard.go | 20 +-- internal/service/dashboard_test.go | 176 ++++++++++++++++++++++- ui/src/pages/Dashboard.tsx | 5 + 5 files changed, 235 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1977e67..df2fdb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,48 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). Versioning: [Se ## [Unreleased] +### Added + +- **Dashboard:** the `/dashboard` page now surfaces Kubernetes host telemetry (node count, ready nodes, allocatable CPU and memory, distinct kubelet versions and OS image) sourced from `core/v1/nodes` via the `KubeVirtDriver`. The new `GET /api/v1/dashboard/summary` field `hosts` is additive; older clients keep working. The section renders a disabled state when the cluster is unreachable (Forbidden, RBAC, no kubeconfig) so the endpoint never 500s. Tier 1 only — no new dependency. + +### Changed + +- **UI:** the dashboard host section is now titled "Cluster nodes" (was "Cluster host"), and the OS label is "OS image" / "Imagem do SO" (was "OS" / "SO"). The data is `Node.Status.NodeInfo.OSImage` from `core/v1/nodes` — i.e. the OS of the cluster nodes that run the workloads, **not** the OS of the developer's machine. In `kind` the value is the kind node image (Debian trixie); in kubeadm on bare metal it is the host OS (e.g. AlmaLinux); in AKS/EKS it is the managed node image (Ubuntu / Amazon Linux). No backend changes, no contract change. +- **UI:** dashboard capacity labels now read "Allocatable CPU (cluster)" / "Allocatable memory (cluster)" (PT: "CPU alocável (cluster)" / "Memória alocável (cluster)"). The values are the **sum of `Status.Allocatable` across all nodes** — they grow with the node count, not with a single host. + +### Changed + +- **UI:** the "(cluster)" suffix on the CPU and Memory capacity labels was making the cards overflow and break into a second line when the sidebar is expanded. Reverted the labels to "Allocatable CPU" / "Allocatable memory" and moved the aggregation hint to a `title` tooltip on the card. The section title "Cluster nodes" already conveys the scope. +- **UI:** the "Ready nodes" card now shows a small status badge in the top-right corner that summarises cluster node health at a glance: green "All ready" when every node is Ready, yellow "N degraded" when some are not, red "None ready" when zero are. The badge reuses the same colour tokens as the dashboard "Health" card. No new card, no new data — it just lifts `nodes` / `nodes_ready` from the existing payload. + +### Added + +- **Dashboard:** the stats row now includes a **Storage** card that aggregates tenant-scoped PVCs: `used / total` (auto-scaled to B/KiB/MiB/GiB/TiB), the percentage of used capacity, and the PVC count. New `GET /api/v1/dashboard/summary` field `storage` (additive). Backend reuses the existing PVC list path in `KubeVirtDriver`; empty store, memory mode, and Forbidden degrade gracefully. + +### Added + +- **Dashboard:** the Cluster nodes section now includes a **Cluster addons** strip with one coloured dot per critical dependency: KubeVirt, CDI, Multus, metrics-server, networking. Statuses: green (installed and reachable), grey (absent — CRD/API not registered), yellow (unreachable — Forbidden or transient failure). New `GET /api/v1/dashboard/summary` field `addons` (additive) with a 30s in-memory cache. Discovery calls are not AST-scanned by `rbac_contract_test.go` and so do not require a contract entry. Memory mode and nil client render every dot as yellow. + +### Changed + +- **API:** the addons probe is now **dynamic** — `KubeVirtDriver.probeAddons` lists every `CustomResourceDefinition` and groups by the `app.kubernetes.io/name` label (the standard Helm/Kustomize convention). The hard-coded list (kubevirt, cdi, multus, metrics-server, networking, cert-manager) is gone; any chart that follows the convention appears automatically. Status is `ok` when at least one CRD with the same name has the `Established=True` condition, `degraded` otherwise. CRDs without the label are ignored. The UI keeps the i18n map for the well-known names and falls back to the raw `app.kubernetes.io/name` value for everything else. + +### Changed + +- **API:** `probeAddons` now resolves the addon name through three Helm labels (`app.kubernetes.io/name`, then `part-of`, then `component`) and falls back to a substring match against a small alias table (`kubevirt.io` → `kubevirt`, `cdi.kubevirt.io` → `cdi`, `k8s.cni.cncf.io` → `multus`, `cert-manager.io` → `cert-manager`, `istio.io` → `istio`, `monitoring.coreos.com` → `prometheus`, `tekton.dev` → `tekton`, `argoproj.io` → `argocd`). Add-ons installed without Helm labels still surface, and a CRD whose group has no alias is silently skipped (no false positives). + +### Changed + +- **Security:** the dashboard **Cluster overview** section (nodes, ready, CPU/memory capacity and usage, addons strip) is now visible to **root only**. The handler passes `actor.Role` to the service, the service omits the `hosts`, `storage` and `addons` fields from `DashboardSummary` for any non-root actor, and the React side hides the card when `selectIsRoot` is false. Tenant admins and tenant users keep the rest of the dashboard (volumes, VPCs, SGs, networks, recent VMs, health badge) but the platform-wide telemetry is gone — both from the rendered page and from the JSON response. + +### Added (backend — UI integration pending) + +- **API:** `GET /api/v1/dashboard/summary` now includes a new optional `hosts.usage` field with cluster-wide `cpu_usage_millicores` and `memory_usage_bytes` aggregated from `metrics.k8s.io/v1beta1/nodes` (served by `metrics-server`). Backend reads via the discovery REST client, caches the result for 30s, and tolerates the API being absent (404), Forbidden (403), or transiently unavailable (5xx, timeout) — the field is omitted and the dashboard renders a degraded state. The chart still has to grant `metrics.k8s.io/nodes` get/list in the API ClusterRole (entry listed under `dynamicClient` in `docs/rbac-contract.yaml`); `metrics-server` is a documented prerequisite. The UI is unchanged in this slice; Tier 1 cards keep their current look. + +### Added (UI — Tier 2 cards) + +- **UI:** the Cluster nodes section now renders usage bars in the CPU and Memory cards whenever the backend reports `hosts.usage`. Each bar shows `used / allocatable` and a percentage with a colour ramp: `< 60%` primary, `60–85%` warning, `≥ 85%` error. When `metrics-server` is absent the cards show "metrics-server unavailable" / "metrics-server indisponível" instead of the bar — the endpoint still answers, the dashboard stays usable. PT/EN i18n parity checked by `i18n.test.ts`. + ## [0.11.3] - 2026-10-07 ### Fixed diff --git a/internal/api/handler/platform_handler.go b/internal/api/handler/platform_handler.go index b5bb858..366753a 100644 --- a/internal/api/handler/platform_handler.go +++ b/internal/api/handler/platform_handler.go @@ -1073,10 +1073,12 @@ func (h *PlatformHandler) DashboardSummary(w http.ResponseWriter, r *http.Reques return } perms := []string{auth.PermAll} + var role platform.Role if actor := middleware.GetActor(r.Context()); actor != nil { perms = actor.Permissions + role = actor.Role } - summary, err := h.svc.DashboardSummary(r.Context(), tid, perms) + summary, err := h.svc.DashboardSummary(r.Context(), tid, perms, role) if err != nil { respondError(w, err) return diff --git a/internal/service/dashboard.go b/internal/service/dashboard.go index db75507..8f9045f 100644 --- a/internal/service/dashboard.go +++ b/internal/service/dashboard.go @@ -58,7 +58,7 @@ type NotificationItem struct { CreatedAt string `json:"created_at,omitempty"` } -func (s *PlatformService) DashboardSummary(ctx context.Context, tenantID string, perms []string) (*DashboardSummary, error) { +func (s *PlatformService) DashboardSummary(ctx context.Context, tenantID string, perms []string, role platform.Role) (*DashboardSummary, error) { summary := &DashboardSummary{ Volumes: DashboardResourceCount{Total: len(s.ListVolumes(tenantID))}, VPCs: DashboardResourceCount{Total: len(s.ListVPCs(tenantID))}, @@ -67,14 +67,16 @@ func (s *PlatformService) DashboardSummary(ctx context.Context, tenantID string, Health: "ok", RecentActivity: []DashboardActivity{}, } - if err := s.populateHosts(ctx, summary); err != nil { - return nil, err - } - if err := s.populateStorage(ctx, summary); err != nil { - return nil, err - } - if err := s.populateAddons(ctx, summary); err != nil { - return nil, err + if role == platform.RoleRoot { + if err := s.populateHosts(ctx, summary); err != nil { + return nil, err + } + if err := s.populateStorage(ctx, summary); err != nil { + return nil, err + } + if err := s.populateAddons(ctx, summary); err != nil { + return nil, err + } } if !auth.HasPermission(perms, auth.PermVMsRead) { return summary, nil diff --git a/internal/service/dashboard_test.go b/internal/service/dashboard_test.go index 414d6a6..bcb04f9 100644 --- a/internal/service/dashboard_test.go +++ b/internal/service/dashboard_test.go @@ -2,12 +2,23 @@ package service import ( "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" "testing" "time" "github.com/virtfoundry/core/internal/auth" + "github.com/virtfoundry/core/internal/infra/hypervisor" "github.com/virtfoundry/core/internal/platform" "github.com/virtfoundry/core/internal/platform/store" + k8sv1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/rest" ) func TestCountVMStates(t *testing.T) { @@ -73,7 +84,7 @@ func seedDashboardVMs(t *testing.T) (*PlatformService, string) { func TestDashboardSummaryHidesVMNamesWithoutVMsRead(t *testing.T) { svc, tenantID := seedDashboardVMs(t) - summary, err := svc.DashboardSummary(context.Background(), tenantID, []string{auth.PermVolumesRead}) + summary, err := svc.DashboardSummary(context.Background(), tenantID, []string{auth.PermVolumesRead}, "") if err != nil { t.Fatalf("DashboardSummary: %v", err) } @@ -89,7 +100,7 @@ func TestDashboardSummaryHidesVMNamesWithoutVMsRead(t *testing.T) { func TestDashboardSummaryIncludesVMsWithVMsRead(t *testing.T) { svc, tenantID := seedDashboardVMs(t) - summary, err := svc.DashboardSummary(context.Background(), tenantID, []string{auth.PermVMsRead}) + summary, err := svc.DashboardSummary(context.Background(), tenantID, []string{auth.PermVMsRead}, "") if err != nil { t.Fatalf("DashboardSummary: %v", err) } @@ -121,3 +132,164 @@ func TestNotificationsHidesVMNamesWithoutVMsRead(t *testing.T) { t.Fatal("expected secret-vm notification with vms:read") } } + +func TestDashboardSummaryHostsNilWithoutDriver(t *testing.T) { + svc, tenantID := seedDashboardVMs(t) + summary, err := svc.DashboardSummary(context.Background(), tenantID, []string{auth.PermVMsRead}, "") + if err != nil { + t.Fatalf("DashboardSummary: %v", err) + } + if summary.Hosts != nil { + t.Fatalf("expected hosts=nil when driver is nil, got %+v", summary.Hosts) + } +} + +func TestDashboardSummaryHostsPopulatedWithDriver(t *testing.T) { + st := store.NewMemory() + tenantID := store.NewID() + st.SaveTenant(&platform.Tenant{ + ID: tenantID, Name: "acme", Slug: "acme", Namespace: "vf-acme", + State: "active", CreatedAt: store.Now(), + }) + cs := fake.NewSimpleClientset( + &k8sv1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "n1"}, + Status: k8sv1.NodeStatus{ + Capacity: k8sv1.ResourceList{ + k8sv1.ResourceCPU: resource.MustParse("4"), + k8sv1.ResourceMemory: resource.MustParse("16Gi"), + }, + Allocatable: k8sv1.ResourceList{ + k8sv1.ResourceCPU: resource.MustParse("3500m"), + k8sv1.ResourceMemory: resource.MustParse("14Gi"), + }, + NodeInfo: k8sv1.NodeSystemInfo{ + KubeletVersion: "v1.30.0", + OSImage: "Ubuntu 22.04", + Architecture: "amd64", + }, + Conditions: []k8sv1.NodeCondition{ + {Type: k8sv1.NodeReady, Status: k8sv1.ConditionTrue}, + }, + }, + }, + ) + kv := hypervisor.NewKubeVirtDriverForTest(nil, cs) + svc := NewPlatformService(st, nil, kv, nil) + summary, err := svc.DashboardSummary(context.Background(), tenantID, []string{auth.PermVolumesRead}, platform.RoleRoot) + if err != nil { + t.Fatalf("DashboardSummary: %v", err) + } + if summary.Hosts == nil { + t.Fatal("expected hosts populated when driver returns metrics") + } + if summary.Hosts.Nodes != 1 || summary.Hosts.NodesReady != 1 { + t.Fatalf("nodes=%d ready=%d want 1/1", summary.Hosts.Nodes, summary.Hosts.NodesReady) + } + if summary.Hosts.CPUAllocatableMilli != 3500 { + t.Fatalf("cpu_allocatable_millicores=%d want 3500", summary.Hosts.CPUAllocatableMilli) + } + if summary.Hosts.Usage != nil { + t.Fatalf("expected usage=nil with fake clientset (no RESTClient), got %+v", summary.Hosts.Usage) + } +} + +func TestDashboardSummaryUsagePopulated(t *testing.T) { + st := store.NewMemory() + tenantID := store.NewID() + st.SaveTenant(&platform.Tenant{ + ID: tenantID, Name: "acme", Slug: "acme", Namespace: "vf-acme", + State: "active", CreatedAt: store.Now(), + }) + node := &k8sv1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "n1"}, + Status: k8sv1.NodeStatus{ + Allocatable: k8sv1.ResourceList{ + k8sv1.ResourceCPU: resource.MustParse("3500m"), + k8sv1.ResourceMemory: resource.MustParse("14Gi"), + }, + Conditions: []k8sv1.NodeCondition{ + {Type: k8sv1.NodeReady, Status: k8sv1.ConditionTrue}, + }, + }, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v1/nodes": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items": [` + nodeJSON(node) + `]}`)) + case r.URL.Path == "/apis/metrics.k8s.io/v1beta1/nodes": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items": [{"usage": {"cpu": "1234m", "memory": "7Gi"}}]}`)) + case strings.HasPrefix(r.URL.Path, "/api/v1/namespaces/default/persistentvolumeclaims"): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items": []}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + + cfg := &rest.Config{Host: srv.URL} + cs, err := kubernetes.NewForConfig(cfg) + if err != nil { + t.Fatalf("build client: %v", err) + } + cs.Discovery() // warm-up: not strictly required, documents intent + + kv := hypervisor.NewKubeVirtDriverForTest(nil, cs) + svc := NewPlatformService(st, nil, kv, nil) + summary, err := svc.DashboardSummary(context.Background(), tenantID, []string{auth.PermVolumesRead}, platform.RoleRoot) + if err != nil { + t.Fatalf("DashboardSummary: %v", err) + } + if summary.Hosts == nil { + t.Fatal("expected hosts populated") + } + if summary.Hosts.Usage == nil { + t.Fatal("expected usage populated when metrics.k8s.io is reachable") + } + if summary.Hosts.Usage.CPUUsageMilli != 1234 { + t.Fatalf("cpu_usage_millicores=%d want 1234", summary.Hosts.Usage.CPUUsageMilli) + } + if summary.Hosts.Usage.MemoryUsage != 7*1024*1024*1024 { + t.Fatalf("memory_usage_bytes=%d want 7Gi", summary.Hosts.Usage.MemoryUsage) + } +} + +func nodeJSON(n *k8sv1.Node) string { + n.TypeMeta = metav1.TypeMeta{Kind: "Node", APIVersion: "v1"} + b, _ := json.Marshal(n) + return string(b) +} + +func TestDashboardSummaryHidesClusterOverviewForNonRoot(t *testing.T) { + svc, tenantID := seedDashboardVMs(t) + summary, err := svc.DashboardSummary(context.Background(), tenantID, []string{auth.PermVMsRead}, platform.RoleTenantAdmin) + if err != nil { + t.Fatalf("DashboardSummary: %v", err) + } + if summary.Hosts != nil { + t.Fatalf("expected hosts=nil for non-root, got %+v", summary.Hosts) + } + if summary.Storage != nil { + t.Fatalf("expected storage=nil for non-root, got %+v", summary.Storage) + } + if summary.Addons != nil { + t.Fatalf("expected addons=nil for non-root, got %+v", summary.Addons) + } + if summary.VMs.Total == 0 { + t.Fatal("non-root with vms:read must still see VM counts") + } +} + +func TestDashboardSummaryShowsClusterOverviewForRoot(t *testing.T) { + svc, tenantID := seedDashboardVMs(t) + summary, err := svc.DashboardSummary(context.Background(), tenantID, []string{auth.PermVMsRead}, platform.RoleRoot) + if err != nil { + t.Fatalf("DashboardSummary: %v", err) + } + if summary.Hosts != nil { + t.Fatal("driver is nil in seedDashboardVMs so hosts stays nil, that's fine") + } +} diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index 4bcf915..d1894fe 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -4,6 +4,8 @@ import { useQuery } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import { getDashboardSummary } from '../lib/platform-api'; import { useNeedsTenant } from '../store/hooks'; +import { useAppSelector } from '../store/hooks'; +import { selectIsRoot } from '../store/authSlice'; import { queryKeys } from '../lib/query-keys'; import { RefreshingPanel } from '../components/RefreshingPanel'; import { useI18n, type TranslationKey } from '../lib/i18n'; @@ -136,6 +138,7 @@ function AddonsHealthStrip({ addons }: { addons: { addons: Array<{ name: string; export function Dashboard() { const { t } = useI18n(); const needsTenant = useNeedsTenant(); + const isRoot = useAppSelector(selectIsRoot); const enabled = !needsTenant; const wsConnected = useRealtimeConnected(); const localRecent = useMemo(() => getRecentActions(), []); @@ -385,6 +388,7 @@ export function Dashboard() {
+ {isRoot && ( {hostsUnavailable ? (

@@ -496,6 +500,7 @@ export function Dashboard() { )} + )}

From 79c6ed197323846e6ee0040ddfae1f190a8f1e77 Mon Sep 17 00:00:00 2001 From: Weslei Paulo Pereira Date: Sun, 11 Oct 2026 15:45:16 -0300 Subject: [PATCH 4/4] fix(dashboard): address cluster overview review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - populateHosts: cluster usage is optional — an unexpected ClusterUsage error is logged and leaves hosts.usage nil instead of failing the whole dashboard summary (500) for root. - fetchClusterUsage: parse node quantities with resource.ParseQuantity and skip the item on error; resource.MustParse could panic on a malformed metrics.k8s.io payload. - probeAddons: bound the CRD list with a 4s timeout (same bound as the usage call) and request a PartialObjectMetadataList so only names and labels cross the wire, not full OpenAPI schemas. The API group is derived from the CRD object name (.); Established is not part of ObjectMeta, so a discovered addon is now ok by presence. The apiextensions.k8s.io/customresourcedefinitions get/list grant is listed under dynamicClient in docs/rbac-contract.yaml. - StorageSummary: rename used_bytes to bound_bytes and reword the UI label to "allocated" — the figures are spec.requests sums, i.e. allocated capacity, not real guest consumption. - WithNamespace: build a fresh driver instead of copying the struct — the copy cloned the new cache mutexes (go vet copylocks) while sharing the cache entries. Chart grants land in virtfoundry/helm-charts#141 (rules first). --- CHANGELOG.md | 6 +- docs/rbac-contract.yaml | 10 ++ .../infra/hypervisor/addons_health_test.go | 73 ++++++++------ .../infra/hypervisor/cluster_usage_test.go | 28 ++++++ internal/infra/hypervisor/kubevirt.go | 97 ++++++++++++------- .../infra/hypervisor/storage_summary_test.go | 12 +-- internal/infra/hypervisor/types.go | 10 +- internal/service/dashboard.go | 7 +- internal/service/dashboard_test.go | 59 +++++++++++ ui/src/lib/i18n.tsx | 4 +- ui/src/lib/platform-api.ts | 2 +- ui/src/pages/Dashboard.tsx | 6 +- 12 files changed, 231 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df2fdb5..ea95e68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,15 +22,15 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). Versioning: [Se ### Added -- **Dashboard:** the stats row now includes a **Storage** card that aggregates tenant-scoped PVCs: `used / total` (auto-scaled to B/KiB/MiB/GiB/TiB), the percentage of used capacity, and the PVC count. New `GET /api/v1/dashboard/summary` field `storage` (additive). Backend reuses the existing PVC list path in `KubeVirtDriver`; empty store, memory mode, and Forbidden degrade gracefully. +- **Dashboard:** the stats row now includes a **Storage** card that aggregates tenant-scoped PVCs: `bound / total` (auto-scaled to B/KiB/MiB/GiB/TiB), the percentage of allocated capacity, and the PVC count. New `GET /api/v1/dashboard/summary` field `storage` (additive). Both figures are `spec.resources.requests.storage` sums — allocated capacity, never real guest consumption. Backend reuses the existing PVC list path in `KubeVirtDriver`; empty store, memory mode, and Forbidden degrade gracefully. ### Added -- **Dashboard:** the Cluster nodes section now includes a **Cluster addons** strip with one coloured dot per critical dependency: KubeVirt, CDI, Multus, metrics-server, networking. Statuses: green (installed and reachable), grey (absent — CRD/API not registered), yellow (unreachable — Forbidden or transient failure). New `GET /api/v1/dashboard/summary` field `addons` (additive) with a 30s in-memory cache. Discovery calls are not AST-scanned by `rbac_contract_test.go` and so do not require a contract entry. Memory mode and nil client render every dot as yellow. +- **Dashboard:** the Cluster nodes section now includes a **Cluster addons** strip with one coloured dot per critical dependency: KubeVirt, CDI, Multus, metrics-server, networking. Statuses: green (installed and reachable), grey (absent — CRD/API not registered), yellow (unreachable — Forbidden or transient failure). New `GET /api/v1/dashboard/summary` field `addons` (additive) with a 30s in-memory cache. The probe lists CRDs metadata-only (`PartialObjectMetadataList` Accept header) with a 4s call timeout, and derives the API group from the CRD object name — the full OpenAPI schemas never cross the wire. Discovery calls are not AST-scanned by `rbac_contract_test.go`; the required `apiextensions.k8s.io/customresourcedefinitions` get/list grant is listed under `dynamicClient` in `docs/rbac-contract.yaml` for the chart. Memory mode and nil client render every dot as yellow. ### Changed -- **API:** the addons probe is now **dynamic** — `KubeVirtDriver.probeAddons` lists every `CustomResourceDefinition` and groups by the `app.kubernetes.io/name` label (the standard Helm/Kustomize convention). The hard-coded list (kubevirt, cdi, multus, metrics-server, networking, cert-manager) is gone; any chart that follows the convention appears automatically. Status is `ok` when at least one CRD with the same name has the `Established=True` condition, `degraded` otherwise. CRDs without the label are ignored. The UI keeps the i18n map for the well-known names and falls back to the raw `app.kubernetes.io/name` value for everything else. +- **API:** the addons probe is now **dynamic** — `KubeVirtDriver.probeAddons` lists every `CustomResourceDefinition` and groups by the `app.kubernetes.io/name` label (the standard Helm/Kustomize convention). The hard-coded list (kubevirt, cdi, multus, metrics-server, networking, cert-manager) is gone; any chart that follows the convention appears automatically. Because the list is fetched metadata-only, the `Established` condition is no longer observable — status is `ok` by presence. CRDs without the label are ignored. The UI keeps the i18n map for the well-known names and falls back to the raw `app.kubernetes.io/name` value for everything else. ### Changed diff --git a/docs/rbac-contract.yaml b/docs/rbac-contract.yaml index a15fdb8..2805c5d 100644 --- a/docs/rbac-contract.yaml +++ b/docs/rbac-contract.yaml @@ -74,4 +74,14 @@ dynamicClient: (internal/infra/hypervisor/kubevirt.go). Reachable only when metrics-server is installed (documented prerequisite in helm-charts). The dashboard degrades gracefully when this API is absent, Forbidden, or transiently unavailable. + - apiGroup: apiextensions.k8s.io + resource: customresourcedefinitions + verbs: [get, list] + note: >- + Dashboard cluster-addons strip. Source: KubeVirtDriver.probeAddons + (internal/infra/hypervisor/kubevirt.go). The list is requested as a + PartialObjectMetadataList (Accept: application/json;as=PartialObjectMetadataList; + g=meta.k8s.io;v=v1), so only CRD names and labels cross the wire — never the + OpenAPI schemas. Without this grant the strip renders empty; the dashboard + degrades gracefully on Forbidden. diff --git a/internal/infra/hypervisor/addons_health_test.go b/internal/infra/hypervisor/addons_health_test.go index 5bdbfc5..7ca9876 100644 --- a/internal/infra/hypervisor/addons_health_test.go +++ b/internal/infra/hypervisor/addons_health_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "net/http/httptest" + "strings" "testing" "k8s.io/client-go/kubernetes" @@ -22,30 +23,19 @@ func newCRDTestServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, return srv, cs } -func crdWithLabel(name, labelValue string, established bool) string { - cond := `{"type":"Established","status":"False"}` - if established { - cond = `{"type":"Established","status":"True"}` - } +// crdWithLabel mirrors a PartialObjectMetadata item: metadata only, no +// spec/status — the probe requests the list with the meta.k8s.io transform. +func crdWithLabel(name, labelValue string) string { return `{ "metadata": { "name": "` + name + `", "labels": {"app.kubernetes.io/name": "` + labelValue + `"} - }, - "status": {"conditions": [` + cond + `]} + } }` } -func crdWithGroup(name, group string, established bool) string { - cond := `{"type":"Established","status":"False"}` - if established { - cond = `{"type":"Established","status":"True"}` - } - return `{ - "metadata": {"name": "` + name + `"}, - "spec": {"group": "` + group + `"}, - "status": {"conditions": [` + cond + `]} - }` +func crdBare(name string) string { + return `{"metadata": {"name": "` + name + `"}}` } func TestAddonsHealthDiscoversFromCRDs(t *testing.T) { @@ -53,15 +43,19 @@ func TestAddonsHealthDiscoversFromCRDs(t *testing.T) { srv, cs := newCRDTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/apis/apiextensions.k8s.io/v1/customresourcedefinitions": + if got := r.Header.Get("Accept"); !strings.Contains(got, "as=PartialObjectMetadataList") { + t.Errorf("Accept=%q must request PartialObjectMetadataList (metadata only, no schemas)", got) + } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"items": [ - ` + crdWithLabel("virtualmachines.kubevirt.io", "kubevirt", true) + `, - ` + crdWithLabel("datavolumes.cdi.kubevirt.io", "cdi", true) + `, - ` + crdWithLabel("networkattachmentdefinitions.k8s.cni.cncf.io", "multus", true) + `, - ` + crdWithLabel("certificates.cert-manager.io", "cert-manager", true) + `, - ` + crdWithLabel("foo.example.com", "failing-addon", false) + `, - ` + crdWithGroup("networking.k8s.io", "networking.k8s.io", true) + `, - ` + crdWithGroup("unknown.example.com", "not-in-aliases.example.com", true) + ` + ` + crdWithLabel("virtualmachines.kubevirt.io", "kubevirt") + `, + ` + crdWithLabel("datavolumes.cdi.kubevirt.io", "cdi") + `, + ` + crdWithLabel("networkattachmentdefinitions.k8s.cni.cncf.io", "multus") + `, + ` + crdWithLabel("certificates.cert-manager.io", "cert-manager") + `, + ` + crdWithLabel("foos.example.com", "some-addon") + `, + ` + crdBare("istiooperators.install.istio.io") + `, + ` + crdBare("networkpolicies.networking.k8s.io") + `, + ` + crdBare("widgets.unknown.example.com") + ` ]}`)) default: http.NotFound(w, r) @@ -83,7 +77,8 @@ func TestAddonsHealthDiscoversFromCRDs(t *testing.T) { "cdi": "ok", "multus": "ok", "cert-manager": "ok", - "failing-addon": "degraded", + "some-addon": "ok", + "istio": "ok", } for name, status := range want { if got := byName[name]; got != status { @@ -130,6 +125,30 @@ func TestAliasFromGroup(t *testing.T) { } } +func TestGroupFromCRDName(t *testing.T) { + t.Parallel() + cases := []struct { + name string + want string + }{ + {"virtualmachines.kubevirt.io", "kubevirt.io"}, + {"datavolumes.cdi.kubevirt.io", "cdi.kubevirt.io"}, + {"networkpolicies.networking.k8s.io", "networking.k8s.io"}, + {"nodot", ""}, + {"trailingdot.", ""}, + {"", ""}, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + if got := groupFromCRDName(c.name); got != c.want { + t.Fatalf("groupFromCRDName(%q)=%q want %q", c.name, got, c.want) + } + }) + } +} + func TestResolveAddonName(t *testing.T) { t.Parallel() cases := []struct { @@ -179,8 +198,8 @@ func TestAddonsHealthDedupMultipleCRDsSameName(t *testing.T) { case "/apis/apiextensions.k8s.io/v1/customresourcedefinitions": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"items": [ - ` + crdWithLabel("virtualmachines.kubevirt.io", "kubevirt", true) + `, - ` + crdWithLabel("virtualmachineinstances.kubevirt.io", "kubevirt", true) + ` + ` + crdWithLabel("virtualmachines.kubevirt.io", "kubevirt") + `, + ` + crdWithLabel("virtualmachineinstances.kubevirt.io", "kubevirt") + ` ]}`)) default: http.NotFound(w, r) diff --git a/internal/infra/hypervisor/cluster_usage_test.go b/internal/infra/hypervisor/cluster_usage_test.go index 6331554..91fc2d9 100644 --- a/internal/infra/hypervisor/cluster_usage_test.go +++ b/internal/infra/hypervisor/cluster_usage_test.go @@ -119,6 +119,34 @@ func TestClusterUsageTransientError(t *testing.T) { } } +func TestClusterUsageSkipsMalformedQuantities(t *testing.T) { + t.Parallel() + srv, cs := newMetricsServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items": [ + {"usage": {"cpu": "250m", "memory": "512Mi"}}, + {"usage": {"cpu": "not-a-quantity", "memory": "1Gi"}}, + {"usage": {"cpu": "100m", "memory": "bogus"}} + ]}`)) + }) + _ = srv + + d := &KubeVirtDriver{k8sClient: cs} + u, err := d.ClusterUsage(context.Background()) + if err != nil { + t.Fatalf("malformed quantities must be skipped, not fail the call: %v", err) + } + if u == nil { + t.Fatal("expected non-nil usage") + } + if got, want := u.CPUUsageMilli, int64(250); got != want { + t.Fatalf("cpu_usage_millicores=%d want %d (only the well-formed item)", got, want) + } + if got, want := u.MemoryUsage, int64(512*1024*1024); got != want { + t.Fatalf("memory_usage_bytes=%d want %d (only the well-formed item)", got, want) + } +} + func TestClusterUsageParseError(t *testing.T) { t.Parallel() srv, cs := newMetricsServer(t, func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/infra/hypervisor/kubevirt.go b/internal/infra/hypervisor/kubevirt.go index e2a022e..04c8889 100644 --- a/internal/infra/hypervisor/kubevirt.go +++ b/internal/infra/hypervisor/kubevirt.go @@ -35,6 +35,13 @@ const ( clusterUsageTTL = 30 * time.Second // clusterUsageCallTimeout caps a single metrics.k8s.io round-trip. clusterUsageCallTimeout = 4 * time.Second + // addonsProbeCallTimeout caps a single CRD list round-trip in probeAddons, + // same bound as the usage call. + addonsProbeCallTimeout = 4 * time.Second + // partialObjectMetadataListAccept asks the API server for CRD metadata only + // (no OpenAPI schemas), keeping the addons probe payload small. The plain + // JSON fallback keeps the probe working if the transform is ignored. + partialObjectMetadataListAccept = "application/json;as=PartialObjectMetadataList;g=meta.k8s.io;v=v1,application/json" ) // Cirros only drives the default VGA device; virtio-gpu yields a black VNC screen. @@ -133,9 +140,15 @@ func (d *KubeVirtDriver) WithNamespace(ns string) *KubeVirtDriver { if ns == "" { return d } - copy := *d - copy.namespace = ns - return © + // Build a fresh driver instead of copying the struct: copying would clone + // the cache mutexes (go vet copylocks) while sharing the cache entries. + // Cluster telemetry is only queried through the base driver, so the copy + // starts with empty caches. + return &KubeVirtDriver{ + virtClient: d.virtClient, + k8sClient: d.k8sClient, + namespace: ns, + } } func (d *KubeVirtDriver) VirtClient() kubecli.KubevirtClient { @@ -585,8 +598,18 @@ func (d *KubeVirtDriver) fetchClusterUsage(ctx context.Context) (*ClusterUsage, CollectedAt: time.Now().UTC(), } for _, item := range list.Items { - cpu := resource.MustParse(item.Usage.CPU) - mem := resource.MustParse(item.Usage.Memory) + // A malformed quantity from metrics.k8s.io must not panic the process + // (resource.MustParse) nor fail the whole call — skip the node instead. + cpu, err := resource.ParseQuantity(item.Usage.CPU) + if err != nil { + log.Printf("[cluster-usage] skipping node metrics: invalid cpu quantity %q: %v", item.Usage.CPU, err) + continue + } + mem, err := resource.ParseQuantity(item.Usage.Memory) + if err != nil { + log.Printf("[cluster-usage] skipping node metrics: invalid memory quantity %q: %v", item.Usage.Memory, err) + continue + } out.CPUUsageMilli += cpu.MilliValue() out.MemoryUsage += mem.Value() } @@ -616,9 +639,10 @@ func isMetricsAbsent(err error) bool { // StorageSummary aggregates PVCs in the driver namespace into a single // tenant-scoped storage figure. Counts every PVC in the namespace (Bound or // Pending) toward TotalBytes (the reserved capacity the user has asked for), -// and the Bound subset toward UsedBytes (actually attached). Returns nil -// without error when the driver has no k8s client (memory store) so the -// dashboard degrades gracefully. +// and the Bound subset toward BoundBytes. Note these are allocated capacity +// (spec.requests), not real consumption — the API cannot see guest filesystem +// usage. Returns nil without error when the driver has no k8s client (memory +// store) so the dashboard degrades gracefully. func (d *KubeVirtDriver) StorageSummary(ctx context.Context) (*StorageSummary, error) { if d.k8sClient == nil { return nil, nil @@ -640,10 +664,10 @@ func (d *KubeVirtDriver) StorageSummary(ctx context.Context) (*StorageSummary, e bytes := req.Value() out.TotalBytes += bytes if pvc.Status.Phase == k8sv1.ClaimBound { - out.UsedBytes += bytes + out.BoundBytes += bytes } } - out.AvailableBytes = out.TotalBytes - out.UsedBytes + out.AvailableBytes = out.TotalBytes - out.BoundBytes return out, nil } @@ -722,6 +746,15 @@ func resolveAddonName(labels map[string]string, group string) string { // CustomResourceDefinition, resolving an identifier through Helm labels // (name/part-of/component) with a fallback to a group-prefix alias, and // grouping by that identifier. CRDs with no resolvable name are ignored. +// +// The list is requested as a PartialObjectMetadataList: the full CRD payload +// carries every OpenAPI schema and can reach tens of MB on operator-heavy +// clusters, while the probe only needs names and labels. The API group is +// derived from the CRD object name (.), so spec.group is not +// needed. The trade-off: status.conditions is not part of ObjectMeta, so a +// CRD that exists but is not Established can no longer be told apart — a +// discovered addon is reported "ok" by presence. +// // The probe fails soft: any error (Forbidden, timeout, missing CRD list // permission) returns an empty list rather than failing the dashboard. func (d *KubeVirtDriver) probeAddons(ctx context.Context) *AddonsHealth { @@ -737,10 +770,14 @@ func (d *KubeVirtDriver) probeAddons(ctx context.Context) *AddonsHealth { return out } + callCtx, cancel := context.WithTimeout(ctx, addonsProbeCallTimeout) + defer cancel() + raw, err := restClient. Get(). AbsPath("/apis/apiextensions.k8s.io/v1/customresourcedefinitions"). - Do(ctx). + SetHeader("Accept", partialObjectMetadataListAccept). + Do(callCtx). Raw() if err != nil { log.Printf("[cluster-addons] list CRDs: %v", err) @@ -753,15 +790,6 @@ func (d *KubeVirtDriver) probeAddons(ctx context.Context) *AddonsHealth { Name string `json:"name"` Labels map[string]string `json:"labels"` } `json:"metadata"` - Spec struct { - Group string `json:"group"` - } `json:"spec"` - Status struct { - Conditions []struct { - Type string `json:"type"` - Status string `json:"status"` - } `json:"conditions"` - } `json:"status"` } `json:"items"` } if err := json.Unmarshal(raw, &list); err != nil { @@ -769,23 +797,13 @@ func (d *KubeVirtDriver) probeAddons(ctx context.Context) *AddonsHealth { return out } - established := map[string]bool{} seen := map[string]struct{}{} for _, crd := range list.Items { - name := resolveAddonName(crd.Metadata.Labels, crd.Spec.Group) + name := resolveAddonName(crd.Metadata.Labels, groupFromCRDName(crd.Metadata.Name)) if name == "" { continue } - if _, dup := seen[name]; dup { - continue - } seen[name] = struct{}{} - for _, c := range crd.Status.Conditions { - if c.Type == "Established" && c.Status == "True" { - established[name] = true - break - } - } } names := make([]string, 0, len(seen)) @@ -795,15 +813,22 @@ func (d *KubeVirtDriver) probeAddons(ctx context.Context) *AddonsHealth { sort.Strings(names) for _, n := range names { - status := "ok" - if !established[n] { - status = "degraded" - } - out.Addons = append(out.Addons, AddonHealth{Name: n, Status: status}) + out.Addons = append(out.Addons, AddonHealth{Name: n, Status: "ok"}) } return out } +// groupFromCRDName extracts spec.group from a CRD object name, which is +// always . (e.g. "virtualmachines.kubevirt.io" → "kubevirt.io"). +// Returns "" when the name has no dot, which cannot happen for a real CRD. +func groupFromCRDName(name string) string { + i := strings.IndexByte(name, '.') + if i < 0 || i == len(name)-1 { + return "" + } + return name[i+1:] +} + func isNodeReady(node *k8sv1.Node) bool { for i := range node.Status.Conditions { if node.Status.Conditions[i].Type == k8sv1.NodeReady { diff --git a/internal/infra/hypervisor/storage_summary_test.go b/internal/infra/hypervisor/storage_summary_test.go index 225407e..0e77a1d 100644 --- a/internal/infra/hypervisor/storage_summary_test.go +++ b/internal/infra/hypervisor/storage_summary_test.go @@ -44,12 +44,12 @@ func TestStorageSummaryAggregates(t *testing.T) { if s.TotalBytes != wantTotal { t.Fatalf("total_bytes=%d want %d", s.TotalBytes, wantTotal) } - wantUsed := int64((10 + 20) * 1024 * 1024 * 1024) - if s.UsedBytes != wantUsed { - t.Fatalf("used_bytes=%d want %d", s.UsedBytes, wantUsed) + wantBound := int64((10 + 20) * 1024 * 1024 * 1024) + if s.BoundBytes != wantBound { + t.Fatalf("bound_bytes=%d want %d", s.BoundBytes, wantBound) } - if s.AvailableBytes != s.TotalBytes-s.UsedBytes { - t.Fatalf("available_bytes=%d want total-used=%d", s.AvailableBytes, s.TotalBytes-s.UsedBytes) + if s.AvailableBytes != s.TotalBytes-s.BoundBytes { + t.Fatalf("available_bytes=%d want total-bound=%d", s.AvailableBytes, s.TotalBytes-s.BoundBytes) } } @@ -61,7 +61,7 @@ func TestStorageSummaryEmpty(t *testing.T) { if err != nil { t.Fatalf("StorageSummary: %v", err) } - if s.Count != 0 || s.TotalBytes != 0 || s.UsedBytes != 0 || s.AvailableBytes != 0 { + if s.Count != 0 || s.TotalBytes != 0 || s.BoundBytes != 0 || s.AvailableBytes != 0 { t.Fatalf("expected zero-value summary, got %+v", s) } } diff --git a/internal/infra/hypervisor/types.go b/internal/infra/hypervisor/types.go index c942ce9..c45f35a 100644 --- a/internal/infra/hypervisor/types.go +++ b/internal/infra/hypervisor/types.go @@ -120,12 +120,14 @@ type ClusterUsage struct { // StorageSummary aggregates tenant-scoped PVC capacity from the cluster. // TotalBytes is the sum of every PVC's spec.resources.requests.storage -// (capacity the user has reserved); UsedBytes is the subset of those PVCs -// whose status.phase is Bound (actually attached to a pod). AvailableBytes is -// Total - Used and is informational — there is no global "free" pool in K8s. +// (capacity the user has reserved); BoundBytes is the subset requested by +// PVCs whose status.phase is Bound. Both are allocated capacity, never real +// consumption — the API has no visibility into guest filesystem usage. +// AvailableBytes is Total - Bound (reserved but not yet bound) and is +// informational — there is no global "free" pool in K8s. type StorageSummary struct { TotalBytes int64 `json:"total_bytes"` - UsedBytes int64 `json:"used_bytes"` + BoundBytes int64 `json:"bound_bytes"` AvailableBytes int64 `json:"available_bytes"` Count int `json:"count"` } diff --git a/internal/service/dashboard.go b/internal/service/dashboard.go index 8f9045f..b6e7a02 100644 --- a/internal/service/dashboard.go +++ b/internal/service/dashboard.go @@ -2,6 +2,7 @@ package service import ( "context" + "log" "sort" "strings" "time" @@ -138,9 +139,13 @@ func (s *PlatformService) populateHosts(ctx context.Context, summary *DashboardS return err } summary.Hosts = metrics + // Usage is optional: any ClusterUsage error not absorbed by isMetricsAbsent + // (decode failure, unexpected transport error) must not 500 the dashboard — + // log it and leave Hosts.Usage nil so the UI renders "usage unavailable". usage, err := s.kv.ClusterUsage(ctx) if err != nil { - return err + log.Printf("[dashboard] cluster usage unavailable: %v", err) + return nil } summary.Hosts.Usage = usage return nil diff --git a/internal/service/dashboard_test.go b/internal/service/dashboard_test.go index bcb04f9..431e0a3 100644 --- a/internal/service/dashboard_test.go +++ b/internal/service/dashboard_test.go @@ -257,6 +257,65 @@ func TestDashboardSummaryUsagePopulated(t *testing.T) { } } +func TestDashboardSummaryUsageErrorDoesNotFail(t *testing.T) { + // metrics.k8s.io answers with an undecodable body — not a Status error, so + // isMetricsAbsent does not absorb it. Usage is optional: the dashboard must + // still succeed with hosts populated and hosts.usage left nil. + st := store.NewMemory() + tenantID := store.NewID() + st.SaveTenant(&platform.Tenant{ + ID: tenantID, Name: "acme", Slug: "acme", Namespace: "vf-acme", + State: "active", CreatedAt: store.Now(), + }) + node := &k8sv1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "n1"}, + Status: k8sv1.NodeStatus{ + Allocatable: k8sv1.ResourceList{ + k8sv1.ResourceCPU: resource.MustParse("3500m"), + k8sv1.ResourceMemory: resource.MustParse("14Gi"), + }, + Conditions: []k8sv1.NodeCondition{ + {Type: k8sv1.NodeReady, Status: k8sv1.ConditionTrue}, + }, + }, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v1/nodes": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items": [` + nodeJSON(node) + `]}`)) + case r.URL.Path == "/apis/metrics.k8s.io/v1beta1/nodes": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ this is not json`)) + case strings.HasPrefix(r.URL.Path, "/api/v1/namespaces/default/persistentvolumeclaims"): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items": []}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + + cfg := &rest.Config{Host: srv.URL} + cs, err := kubernetes.NewForConfig(cfg) + if err != nil { + t.Fatalf("build client: %v", err) + } + + kv := hypervisor.NewKubeVirtDriverForTest(nil, cs) + svc := NewPlatformService(st, nil, kv, nil) + summary, err := svc.DashboardSummary(context.Background(), tenantID, []string{auth.PermVolumesRead}, platform.RoleRoot) + if err != nil { + t.Fatalf("usage failure must not fail DashboardSummary: %v", err) + } + if summary.Hosts == nil { + t.Fatal("expected hosts populated even when usage fails") + } + if summary.Hosts.Usage != nil { + t.Fatalf("expected usage=nil on unexpected metrics error, got %+v", summary.Hosts.Usage) + } +} + func nodeJSON(n *k8sv1.Node) string { n.TypeMeta = metav1.TypeMeta{Kind: "Node", APIVersion: "v1"} b, _ := json.Marshal(n) diff --git a/ui/src/lib/i18n.tsx b/ui/src/lib/i18n.tsx index 8fd42f5..1dd7a0d 100644 --- a/ui/src/lib/i18n.tsx +++ b/ui/src/lib/i18n.tsx @@ -107,7 +107,7 @@ export const dict = { 'dashboard.snapshot': 'Snapshot', 'dashboard.running': 'em execução', 'dashboard.storageTitle': 'Storage', - 'dashboard.storageUsedOf': '{used} de {total}', + 'dashboard.storageBoundOf': '{bound} de {total} alocados', 'dashboard.storageCount': '{n} volumes', 'dashboard.addonsTitle': 'Cluster addons', 'dashboard.addonsKubevirt': 'KubeVirt', @@ -771,7 +771,7 @@ export const dict = { 'dashboard.snapshot': 'Snapshot', 'dashboard.running': 'running', 'dashboard.storageTitle': 'Storage', - 'dashboard.storageUsedOf': '{used} of {total}', + 'dashboard.storageBoundOf': '{bound} of {total} allocated', 'dashboard.storageCount': '{n} volumes', 'dashboard.addonsTitle': 'Cluster addons', 'dashboard.addonsKubevirt': 'KubeVirt', diff --git a/ui/src/lib/platform-api.ts b/ui/src/lib/platform-api.ts index 9296f0f..4e69744 100644 --- a/ui/src/lib/platform-api.ts +++ b/ui/src/lib/platform-api.ts @@ -709,7 +709,7 @@ export interface ClusterUsage { export interface StorageSummary { total_bytes: number; - used_bytes: number; + bound_bytes: number; available_bytes: number; count: number; } diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index d1894fe..65f95ad 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -170,10 +170,10 @@ export function Dashboard() { const hostsUnavailable = hosts === undefined; const cpuCores = hosts ? (hosts.cpu_allocatable_millicores / 1000).toFixed(1) : '0.0'; const storage = summary?.storage; - const storageUsed = storage ? formatBytes(storage.used_bytes) : '—'; + const storageBound = storage ? formatBytes(storage.bound_bytes) : '—'; const storageTotal = storage ? formatBytes(storage.total_bytes) : '—'; const storagePct = storage && storage.total_bytes > 0 - ? Math.round((storage.used_bytes / storage.total_bytes) * 100) + ? Math.round((storage.bound_bytes / storage.total_bytes) * 100) : null; const memGiB = hosts ? Math.round(hosts.memory_allocatable_bytes / (1024 * 1024 * 1024)) : 0; const kubeletSummary = hosts?.kubelet_versions?.length @@ -306,7 +306,7 @@ export function Dashboard() { {storage && storagePct !== null ? ( <> - {t('dashboard.storageUsedOf').replace('{used}', storageUsed).replace('{total}', storageTotal)} + {t('dashboard.storageBoundOf').replace('{bound}', storageBound).replace('{total}', storageTotal)} {storagePct}%