Project Kubernetes agent versions onto node rows

This commit is contained in:
rcourtman 2026-06-03 15:57:51 +01:00
parent e52df68d30
commit 7440208163
10 changed files with 221 additions and 4 deletions

View file

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

View file

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

View file

@ -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"
/>
<Show when={activeTab() === 'overview'}>
<KubernetesOverview model={model} />

View file

@ -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([

View file

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

View file

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

View file

@ -903,6 +903,7 @@ export interface ResourceKubernetesPodContainerStatus {
export interface ResourceKubernetesMeta {
clusterId?: string;
agentId?: string;
agentVersion?: string;
clusterName?: string;
resourceUid?: string;
resourceKind?: string;

View file

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

View file

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

View file

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