Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `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. 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. 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

- **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
Expand Down
30 changes: 29 additions & 1 deletion docs/rbac-contract.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -57,3 +59,29 @@ 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.
- 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.

4 changes: 3 additions & 1 deletion internal/api/handler/platform_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
224 changes: 224 additions & 0 deletions internal/infra/hypervisor/addons_health_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package hypervisor

import (
"context"
"net/http"
"net/http/httptest"
"strings"
"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
}

// 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 + `"}
}
}`
}

func crdBare(name string) string {
return `{"metadata": {"name": "` + name + `"}}`
}

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":
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") + `,
` + 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)
}
})
_ = 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",
"some-addon": "ok",
"istio": "ok",
}
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 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 {
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") + `,
` + crdWithLabel("virtualmachineinstances.kubevirt.io", "kubevirt") + `
]}`))
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)
}
}
Loading
Loading