From 7440208163053d07e6696ffbfde58e072970a9bb Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 3 Jun 2026 15:57:51 +0100 Subject: [PATCH] Project Kubernetes agent versions onto node rows --- .../subsystems/frontend-primitives.md | 6 +- .../internal/subsystems/unified-resources.md | 7 +++ .../kubernetes/KubernetesPageSurface.tsx | 2 + .../KubernetesPageSurface.contract.test.tsx | 44 +++++++++++++ .../platformPage/agentVersion.test.ts | 40 +++++++++++- .../src/features/platformPage/agentVersion.ts | 28 ++++++++- frontend-modern/src/types/resource.ts | 1 + internal/api/resources_test.go | 63 +++++++++++++++++++ internal/unifiedresources/adapters.go | 1 + internal/unifiedresources/adapters_test.go | 33 ++++++++++ 10 files changed, 221 insertions(+), 4 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index f304ec5bb..e5c7fc82d 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -241,7 +241,11 @@ must not grow this notice unless a concrete guest or monitored system row actually carries a Pulse agent identity. On vSphere specifically, stale-agent copy belongs to correlated in-guest VM agents and must not describe ESXi hosts as Pulse-agent update targets just because phase-1 host resources use the -canonical `agent` resource type. +canonical `agent` resource type. Kubernetes is cluster-agent-backed: canonical +`k8s-node` rows may be pure Kubernetes API rows rather than merged `agent` +rows, so the unified-resource owner must project the cluster agent identity and +cluster-scoped agent version onto those node rows before the shared stale-agent +collector can decide whether the node inventory is gated by an older agent. Kubernetes policy inventory follows that same primitive boundary while the unified-resource owner supplies NetworkPolicy policy type and rule-count columns, PodDisruptionBudget budget and observed health columns, ResourceQuota diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 16c7932e4..c4f7ff5ba 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -491,6 +491,13 @@ AI-only summary payloads, or page-local heuristics. Resource consumers must also use the API-owned agent update target when comparing resource-carried agent versions; the running app build version is not a resource freshness contract. + Kubernetes node rows are cluster-agent-backed for this purpose: even when a + canonical `k8s-node` row is a pure Kubernetes API projection with no merged + `agent` facet, `internal/unifiedresources/adapters.go` must carry the + cluster `AgentID` and cluster-scoped `AgentVersion` on the row's + Kubernetes facet so platform consumers can scope stale-agent notices and + update-command links from typed resource evidence instead of rebuilding + ownership from the parent cluster row. `internal/unifiedresources/top_level_systems.go` Explicit linked-host correlation is canonical here: when Kubernetes node ingest has a resolved backing host agent, the registry must merge that node diff --git a/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx b/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx index ac2b7a27c..4cb5c1094 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx @@ -121,6 +121,8 @@ export function KubernetesPageSurface() { missingLabel="Kubernetes nodes, workloads, services, storage, configuration, and events" actionHref={outdatedAgentUpdatePath()} actionLabel="Open agent upgrade commands" + subjectSingular="node" + subjectPlural="nodes" /> diff --git a/frontend-modern/src/features/kubernetes/__tests__/KubernetesPageSurface.contract.test.tsx b/frontend-modern/src/features/kubernetes/__tests__/KubernetesPageSurface.contract.test.tsx index d8c30dfbd..bfc48f80b 100644 --- a/frontend-modern/src/features/kubernetes/__tests__/KubernetesPageSurface.contract.test.tsx +++ b/frontend-modern/src/features/kubernetes/__tests__/KubernetesPageSurface.contract.test.tsx @@ -240,6 +240,50 @@ describe('KubernetesPageSurface contract', () => { ); }); + it('surfaces stale cluster-backed Kubernetes node rows without agent metadata', () => { + mockVersionInfo.mockReturnValue({ + version: 'v6.0.0-rc.6', + agentUpdateTargetVersion: 'v6.0.0-rc.6', + }); + setResources([ + makeResource({ + id: 'cluster-1', + name: 'prod-cluster', + type: 'k8s-cluster', + kubernetes: { + agentId: 'agent-k8s-cluster', + agentVersion: 'v5.1.34', + clusterId: 'cluster-1', + clusterName: 'prod-cluster', + }, + }), + makeResource({ + id: 'k8s-node:worker-1', + name: 'worker-1', + type: 'k8s-node', + agent: undefined, + kubernetes: { + agentId: 'agent-k8s-cluster', + agentVersion: 'v5.1.34', + clusterId: 'cluster-1', + clusterName: 'prod-cluster', + nodeName: 'worker-1', + }, + }), + ]); + + renderSurface(); + + const notice = screen.getByTestId('platform-outdated-agent-notice'); + expect(notice).toHaveTextContent('worker-1 is running an older Pulse agent (v5.1.34).'); + expect(notice).toHaveTextContent('for this node'); + expect(notice).toHaveTextContent('Kubernetes nodes, workloads, services, storage'); + expect(screen.getByRole('link', { name: 'Open agent upgrade commands' })).toHaveAttribute( + 'href', + '/settings/infrastructure?agentUpdates=1&agents=agent%3Aagent-k8s-cluster', + ); + }); + it('groups workload API tables under the Workloads tab', () => { mockPathname.mockReturnValue('/kubernetes/workloads'); setResources([ diff --git a/frontend-modern/src/features/platformPage/agentVersion.test.ts b/frontend-modern/src/features/platformPage/agentVersion.test.ts index 8f9d73210..e1376968d 100644 --- a/frontend-modern/src/features/platformPage/agentVersion.test.ts +++ b/frontend-modern/src/features/platformPage/agentVersion.test.ts @@ -85,6 +85,29 @@ describe('hostAgentVersion', () => { ).toBe('v6.0.0-rc.5'); }); + it('reads the Kubernetes cluster-agent version projected onto node rows', () => { + expect( + hostAgentVersion( + host({ + type: 'k8s-node', + kubernetes: { agentVersion: 'v6.0.0-rc.5' } as Resource['kubernetes'], + }), + ), + ).toBe('v6.0.0-rc.5'); + }); + + it('prefers Kubernetes cluster-agent version for Kubernetes platform rows', () => { + expect( + hostAgentVersion( + host({ + type: 'k8s-node', + agent: { agentVersion: 'v6.0.0-rc.6' } as Resource['agent'], + kubernetes: { agentVersion: 'v6.0.0-rc.5' } as Resource['kubernetes'], + }), + ), + ).toBe('v6.0.0-rc.5'); + }); + it('returns undefined when no version is reported', () => { expect(hostAgentVersion(host({}))).toBeUndefined(); }); @@ -95,7 +118,22 @@ describe('hostAgentVersion', () => { ).toBe('agent:agent-delly'); expect( hostAgentConnectionID( - host({ kubernetes: { agentId: 'agent:agent-k8s' } as Resource['kubernetes'] }), + host({ + type: 'k8s-node', + kubernetes: { agentId: 'agent:agent-k8s' } as Resource['kubernetes'], + }), + ), + ).toBe('agent:agent-k8s'); + }); + + it('prefers Kubernetes cluster-agent IDs for Kubernetes platform rows', () => { + expect( + hostAgentConnectionID( + host({ + type: 'k8s-node', + agent: { agentId: 'agent-host' } as Resource['agent'], + kubernetes: { agentId: 'agent-k8s' } as Resource['kubernetes'], + }), ), ).toBe('agent:agent-k8s'); }); diff --git a/frontend-modern/src/features/platformPage/agentVersion.ts b/frontend-modern/src/features/platformPage/agentVersion.ts index 0107e4960..92dbf8520 100644 --- a/frontend-modern/src/features/platformPage/agentVersion.ts +++ b/frontend-modern/src/features/platformPage/agentVersion.ts @@ -22,13 +22,37 @@ import { // second case by comparing each host's reported agent version against the // server that is rendering the page. +const isKubernetesPlatformRow = (host: Resource): boolean => + host.type === 'k8s-node' || + host.type === 'k8s-cluster' || + host.platformType === 'kubernetes' || + host.sources?.includes('kubernetes') === true; + // The agent version reported for a host. Docker hosts carry it on the docker -// meta; plain host agents carry it on the agent meta. +// meta; Kubernetes platform rows carry the cluster agent version on the +// kubernetes meta; plain host agents carry it on the agent meta. export function hostAgentVersion(host: Resource): string | undefined { - return host.docker?.agentVersion?.trim() || host.agent?.agentVersion?.trim() || undefined; + const kubernetesAgentVersion = host.kubernetes?.agentVersion?.trim(); + if (kubernetesAgentVersion && isKubernetesPlatformRow(host)) { + return kubernetesAgentVersion; + } + return ( + host.docker?.agentVersion?.trim() || + host.agent?.agentVersion?.trim() || + kubernetesAgentVersion || + undefined + ); } export function hostAgentConnectionID(host: Resource): string | undefined { + if (isKubernetesPlatformRow(host)) { + const kubernetesAgentId = host.kubernetes?.agentId?.trim(); + if (kubernetesAgentId) { + return kubernetesAgentId.startsWith('agent:') + ? kubernetesAgentId + : `agent:${kubernetesAgentId}`; + } + } const agentId = host.agent?.agentId?.trim() || host.kubernetes?.agentId?.trim() || diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index df912a079..d60631f70 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -903,6 +903,7 @@ export interface ResourceKubernetesPodContainerStatus { export interface ResourceKubernetesMeta { clusterId?: string; agentId?: string; + agentVersion?: string; clusterName?: string; resourceUid?: string; resourceKind?: string; diff --git a/internal/api/resources_test.go b/internal/api/resources_test.go index 474fcb557..b84ebca3f 100644 --- a/internal/api/resources_test.go +++ b/internal/api/resources_test.go @@ -1759,6 +1759,69 @@ func TestResourceListIncludesKubernetesPods(t *testing.T) { } } +func TestResourceListProjectsKubernetesClusterAgentVersionOntoNode(t *testing.T) { + now := time.Now().UTC() + snapshot := models.StateSnapshot{ + KubernetesClusters: []models.KubernetesCluster{ + { + ID: "cluster-1", + AgentID: "agent-1", + Name: "prod-k8s", + Context: "prod", + Status: "online", + LastSeen: now, + Version: "1.31.2", + AgentVersion: "5.1.34", + Nodes: []models.KubernetesNode{ + { + UID: "node-1", + Name: "worker-1", + Ready: true, + OSImage: "Ubuntu 24.04", + }, + }, + }, + }, + } + + cfg := &config.Config{DataPath: t.TempDir()} + h := NewResourceHandlers(cfg) + h.SetStateProvider(resourceStateProvider{snapshot: snapshot}) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/resources?type=k8s-node", nil) + h.HandleListResources(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + + var resp ResourcesResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(resp.Data) != 1 { + t.Fatalf("expected 1 kubernetes node resource, got %d", len(resp.Data)) + } + + resource := resp.Data[0] + if resource.Type != unified.ResourceTypeK8sNode { + t.Fatalf("resource type = %q, want %q", resource.Type, unified.ResourceTypeK8sNode) + } + if resource.Agent != nil { + t.Fatalf("expected pure k8s-node row without agent metadata, got %+v", resource.Agent) + } + if resource.Kubernetes == nil { + t.Fatalf("expected kubernetes metadata") + } + if resource.Kubernetes.AgentID != "agent-1" { + t.Fatalf("kubernetes agentID = %q, want agent-1", resource.Kubernetes.AgentID) + } + if resource.Kubernetes.AgentVersion != "5.1.34" { + t.Fatalf("kubernetes agentVersion = %q, want 5.1.34", resource.Kubernetes.AgentVersion) + } +} + func TestResourceListFiltersCanonicalKubernetesNamespace(t *testing.T) { now := time.Now().UTC() snapshot := models.StateSnapshot{ diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go index 226137dce..9f84f092c 100644 --- a/internal/unifiedresources/adapters.go +++ b/internal/unifiedresources/adapters.go @@ -2421,6 +2421,7 @@ func resourceFromKubernetesNode(cluster models.KubernetesCluster, node models.Ku ClusterID: cluster.ID, ClusterName: clusterName, AgentID: cluster.AgentID, + AgentVersion: cluster.AgentVersion, Context: cluster.Context, Server: cluster.Server, Version: cluster.Version, diff --git a/internal/unifiedresources/adapters_test.go b/internal/unifiedresources/adapters_test.go index 3a7657252..580f3111b 100644 --- a/internal/unifiedresources/adapters_test.go +++ b/internal/unifiedresources/adapters_test.go @@ -715,6 +715,39 @@ func TestResourceFromHostSMARTDiskFallsBackToFilesystemSize(t *testing.T) { } } +func TestResourceFromKubernetesNodeProjectsClusterAgentVersion(t *testing.T) { + cluster := models.KubernetesCluster{ + ID: "cluster-1", + AgentID: "agent-k8s-1", + Name: "prod", + DisplayName: "Production", + LastSeen: time.Now().UTC(), + AgentVersion: "5.1.34", + } + node := models.KubernetesNode{ + UID: "node-1", + Name: "worker-1", + Ready: true, + } + + resource, _ := resourceFromKubernetesNode(cluster, node, nil, nil) + if resource.Type != ResourceTypeK8sNode { + t.Fatalf("resource type = %q, want %q", resource.Type, ResourceTypeK8sNode) + } + if resource.Agent != nil { + t.Fatalf("expected pure k8s-node adapter row without agent facet, got %+v", resource.Agent) + } + if resource.Kubernetes == nil { + t.Fatalf("expected kubernetes facet") + } + if resource.Kubernetes.AgentID != "agent-k8s-1" { + t.Fatalf("agent id = %q, want agent-k8s-1", resource.Kubernetes.AgentID) + } + if resource.Kubernetes.AgentVersion != "5.1.34" { + t.Fatalf("agent version = %q, want 5.1.34", resource.Kubernetes.AgentVersion) + } +} + func TestResourceFromKubernetesDeployment_PopulatesMetricsUnderMockMode(t *testing.T) { mockruntime.SetEnabled(true) t.Cleanup(func() { mockruntime.SetEnabled(false) })