From 49c9ca7656b997e7aa71782ba67ffb8a71d3c1e0 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 24 May 2026 12:36:50 +0100 Subject: [PATCH] Use metadata-only Kubernetes config inventory --- .../subsystems/frontend-primitives.md | 6 +- .../v6/internal/subsystems/monitoring.md | 15 +- .../v6/internal/subsystems/registry.json | 22 +++ .../internal/subsystems/storage-recovery.md | 5 +- .../internal/subsystems/unified-resources.md | 13 +- .../kubernetes/KubernetesInventoryTable.tsx | 17 +- .../KubernetesInventoryTable.test.tsx | 69 +++++++ frontend-modern/src/types/resource.ts | 1 + internal/kubernetesagent/agent.go | 173 +++++++++--------- .../kubernetesagent/agent_inventory_test.go | 55 +++++- internal/mock/demo_scenarios.go | 4 +- internal/mock/demo_scenarios_test.go | 9 +- internal/models/models.go | 18 +- internal/monitoring/kubernetes_agents.go | 18 +- internal/monitoring/kubernetes_agents_test.go | 8 +- .../unifiedresources/adapter_coverage_test.go | 8 +- internal/unifiedresources/adapters.go | 2 + .../canonical_ids_types_test.go | 8 +- internal/unifiedresources/types.go | 1 + pkg/agents/kubernetes/report.go | 26 ++- .../canonical_completion_guard_test.py | 2 + .../release_control/subsystem_lookup_test.py | 1 + 22 files changed, 324 insertions(+), 157 deletions(-) create mode 100644 frontend-modern/src/features/kubernetes/__tests__/KubernetesInventoryTable.test.tsx diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index d50551dc7..73158123d 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -173,7 +173,11 @@ alignment classes, or ad hoc report-category coercion. Docker / Podman native subsections now include engine storage usage, Swarm node inventory, and metadata-only Swarm secret/config inventory where the documented Docker APIs report those resources; Podman-only libpod pod inventory must not be -represented until the collector has a libpod-native source. +represented until the collector has a libpod-native source. Kubernetes config +tables must preserve the same trust boundary for metadata-only ConfigMap and +Secret rows: the shared table wording may indicate metadata-only inventory, but +must not imply payload fields were read when the agent used Kubernetes +metadata-only API responses. 1. `frontend-modern/src/components/Settings/APIAccessPanel.tsx` shared with `security-privacy`: the API Access settings intro is both a security/privacy token-management trust surface and a canonical settings-shell presentation boundary. The panel may own shell placement and local action layout, but diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index d555d10c5..90f63b095 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -63,6 +63,8 @@ truth for live infrastructure data. 39. `internal/models/converters.go` 40. `internal/models/deepcopy.go` 41. `internal/mock/generator.go` +42. `internal/kubernetesagent/agent.go` +43. `pkg/agents/kubernetes/report.go` ## Shared Boundaries @@ -187,10 +189,15 @@ truth for live infrastructure data. PersistentVolumeClaims, StorageClasses, ConfigMaps, Secrets, ServiceAccounts, ResourceQuotas, LimitRanges, PodDisruptionBudgets, HorizontalPodAutoscalers, and Events as bounded best-effort inventory. - Secret values must not be collected; only metadata, type, and key names are - allowed. Monitoring must preserve those - objects as native cluster inventory instead of flattening them into pods, - deployments, or generic storage/configuration rows. + ConfigMap and Secret payload values must not be collected for inventory. + Current agents must prefer the Kubernetes metadata-only API path for + ConfigMap and Secret inventory and mark those rows as metadata-only; older + agent reports may still carry key names, but Secret values remain outside + the report contract. Mock/demo Kubernetes ConfigMap and Secret inventory + must mirror the current metadata-only trust boundary rather than seeding + payload key names. Monitoring must preserve those objects as native cluster + inventory instead of flattening them into pods, deployments, or generic + storage/configuration rows. ## Forbidden Paths diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index f54585154..462031e90 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -3586,6 +3586,7 @@ "internal/dockeragent/collect.go", "internal/dockeragent/docker_client.go", "internal/dockeragent/swarm.go", + "internal/kubernetesagent/agent.go", "internal/mock/demo_scenarios.go", "internal/mock/fixture_graph.go", "internal/mock/generator.go", @@ -3595,6 +3596,7 @@ "internal/models/models.go", "internal/models/models_frontend.go", "pkg/agents/docker/report.go", + "pkg/agents/kubernetes/report.go", "pkg/proxmox/ceph.go", "pkg/proxmox/zfs.go" ], @@ -3763,6 +3765,23 @@ "internal/dockeragent/swarm_test.go" ] }, + { + "id": "kubernetes-native-agent-runtime", + "label": "kubernetes native agent inventory proof", + "match_prefixes": [], + "match_files": [ + "internal/kubernetesagent/agent.go", + "pkg/agents/kubernetes/report.go" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "internal/kubernetesagent/agent_inventory_test.go", + "internal/monitoring/kubernetes_agents_test.go", + "internal/unifiedresources/adapter_coverage_test.go", + "internal/unifiedresources/kubernetes_registry_test.go" + ] + }, { "id": "docker-runtime-report-model", "label": "docker runtime report model proof", @@ -5047,6 +5066,7 @@ "frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx", "frontend-modern/src/features/infrastructure/useInfrastructurePageRouteState.ts", "frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts", + "frontend-modern/src/features/kubernetes/KubernetesInventoryTable.tsx", "frontend-modern/src/hooks/useUnifiedResources.ts", "frontend-modern/src/routing/resourceLinks.ts", "frontend-modern/src/types/resource.ts", @@ -5174,6 +5194,7 @@ "frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx", "frontend-modern/src/features/infrastructure/useInfrastructurePageRouteState.ts", "frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts", + "frontend-modern/src/features/kubernetes/KubernetesInventoryTable.tsx", "frontend-modern/src/hooks/useUnifiedResources.ts", "frontend-modern/src/routing/resourceLinks.ts", "frontend-modern/src/types/resource.ts", @@ -5201,6 +5222,7 @@ "frontend-modern/src/features/docker/__tests__/DockerPageSurface.test.tsx", "frontend-modern/src/features/infrastructure/__tests__/infrastructurePageModel.test.ts", "frontend-modern/src/features/infrastructure/__tests__/InfrastructurePageSurface.guardrails.test.ts", + "frontend-modern/src/features/kubernetes/__tests__/KubernetesInventoryTable.test.tsx", "frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts", "frontend-modern/src/routing/__tests__/resourceLinks.test.ts", "frontend-modern/src/stores/__tests__/websocket-unified.test.ts", diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index a00760cf6..7dd40c63f 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -164,7 +164,10 @@ inventory objects that flow through the shared resource decoder, such as ConfigMaps, Secrets, ServiceAccounts, ResourceQuotas, LimitRanges, PodDisruptionBudgets, and HorizontalPodAutoscalers, remain platform configuration, policy, or autoscaling evidence only and must not become -storage/recovery ownership, restore scope, or secret material. +storage/recovery ownership, restore scope, or secret material. If those +ConfigMap or Secret rows are marked `metadataOnly`, storage and recovery may +surface that trust state as context only; they must not infer payload +availability, key names, or restore inputs from the row. Docker engine `/system/df` storage-usage buckets are host-level runtime capacity evidence for the Docker page and unified-resource Docker host facet; they are not storage/recovery resources, recovery-point sources, or restore diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 22e487900..d33997292 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -123,6 +123,7 @@ cross-source deduplication. 99. `frontend-modern/src/features/agents/AgentsPageSurface.tsx` 100. `frontend-modern/src/features/agents/AgentsMachinesTable.tsx` 101. `internal/platformsupport/manifest_generated.go` +102. `frontend-modern/src/features/kubernetes/KubernetesInventoryTable.tsx` ## Shared Boundaries @@ -255,11 +256,13 @@ reintroduce false Swarm capability surfaces. Secrets, ServiceAccounts, ResourceQuotas, LimitRanges, PodDisruptionBudgets, HorizontalPodAutoscalers, and Events must preserve their API identity and source metadata through clone, merge, REST, - websocket, and frontend decode paths. `k8s-secret` resources must expose - metadata, type, and key names only; secret values are outside the unified - resource contract. Because Secret names and key names can disclose intent, - `k8s-secret` policy metadata must classify them as `restricted` with - `local-only` routing. + websocket, and frontend decode paths. `k8s-configmap` and `k8s-secret` + resources must preserve the `metadataOnly` flag when the agent used the + Kubernetes metadata-only API path; current agents must not fetch ConfigMap + or Secret payload values merely to build inventory rows. Older reports may + carry key names, but Secret values are outside the unified resource contract. + Because Secret names and key names can disclose intent, `k8s-secret` policy + metadata must classify them as `restricted` with `local-only` routing. 2. Add typed accessors and views in `internal/unifiedresources/views.go` 3. Add source ingestion/adaptation in the adapter layer only Frontend resource platform contracts in diff --git a/frontend-modern/src/features/kubernetes/KubernetesInventoryTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesInventoryTable.tsx index 45e623863..019f3605a 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesInventoryTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesInventoryTable.tsx @@ -330,13 +330,17 @@ const KubernetesInventoryRow: Component<{ textValue( props.resource.kubernetes?.phase || (props.resource.type === 'k8s-configmap' - ? props.resource.kubernetes?.immutable - ? 'Immutable' - : 'Mutable' + ? props.resource.kubernetes?.metadataOnly + ? 'Metadata-only' + : props.resource.kubernetes?.immutable + ? 'Immutable' + : 'Mutable' : undefined) || (props.resource.type === 'k8s-secret' - ? props.resource.kubernetes?.secretType || - (props.resource.kubernetes?.immutable ? 'Immutable' : 'Mutable') + ? props.resource.kubernetes?.metadataOnly + ? 'Metadata-only' + : props.resource.kubernetes?.secretType || + (props.resource.kubernetes?.immutable ? 'Immutable' : 'Mutable') : undefined) || (props.resource.type === 'k8s-serviceaccount' ? props.resource.kubernetes?.automountServiceAccountToken === false @@ -346,7 +350,8 @@ const KubernetesInventoryRow: Component<{ ); const configDetail = () => textValue( - props.resource.kubernetes?.dataKeys?.join(', ') || + (props.resource.kubernetes?.metadataOnly ? 'Payload omitted' : undefined) || + props.resource.kubernetes?.dataKeys?.join(', ') || props.resource.kubernetes?.binaryDataKeys?.join(', ') || props.resource.kubernetes?.imagePullSecrets?.join(', ') || (typeof props.resource.kubernetes?.secretCount === 'number' diff --git a/frontend-modern/src/features/kubernetes/__tests__/KubernetesInventoryTable.test.tsx b/frontend-modern/src/features/kubernetes/__tests__/KubernetesInventoryTable.test.tsx new file mode 100644 index 000000000..f471277ff --- /dev/null +++ b/frontend-modern/src/features/kubernetes/__tests__/KubernetesInventoryTable.test.tsx @@ -0,0 +1,69 @@ +import { cleanup, render, screen } from '@solidjs/testing-library'; +import { afterEach, describe, expect, it } from 'vitest'; + +import type { Resource } from '@/types/resource'; +import { KubernetesInventoryTable } from '../KubernetesInventoryTable'; + +const makeResource = ({ + id, + type, + ...overrides +}: Partial & Pick): Resource => ({ + id, + name: id, + displayName: id, + platformId: 'cluster-1', + platformType: 'kubernetes', + sourceType: 'agent', + sources: ['kubernetes'], + status: 'online', + type, + lastSeen: 1_700_000_000_000, + ...overrides, +}); + +afterEach(() => { + cleanup(); +}); + +describe('KubernetesInventoryTable', () => { + it('marks metadata-only config and secret rows without implying payload fields were read', () => { + render(() => ( + } + emptyTitle="No config" + emptyDescription="No config" + showToolbar={false} + /> + )); + + expect(screen.getAllByText('Metadata-only')).toHaveLength(2); + expect(screen.getAllByText('Payload omitted')).toHaveLength(2); + expect(screen.queryByText('app.yaml')).not.toBeInTheDocument(); + expect(screen.queryByText('token')).not.toBeInTheDocument(); + expect(screen.queryByText('Mutable')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index 4dfbb5256..30e4cfe05 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -953,6 +953,7 @@ export interface ResourceKubernetesMeta { dataKeys?: string[]; binaryDataKeys?: string[]; immutable?: boolean; + metadataOnly?: boolean; secretType?: string; automountServiceAccountToken?: boolean; secretCount?: number; diff --git a/internal/kubernetesagent/agent.go b/internal/kubernetesagent/agent.go index 6b8fb9c4b..3143151e2 100644 --- a/internal/kubernetesagent/agent.go +++ b/internal/kubernetesagent/agent.go @@ -36,7 +36,9 @@ import ( k8sresource "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/kubernetes" + "k8s.io/client-go/metadata" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" ) @@ -76,8 +78,9 @@ type Agent struct { logger zerolog.Logger httpClient *http.Client - kubeClient kubernetes.Interface - restCfg *rest.Config + kubeClient kubernetes.Interface + metadataClient metadata.Interface + restCfg *rest.Config agentID string agentVersion string @@ -115,6 +118,11 @@ const ( maxRecoveryResponseBodyBytes int64 = 8 * 1024 * 1024 // 8 MB (recovery APIs can be large) ) +var ( + configMapsGVR = schema.GroupVersionResource{Version: "v1", Resource: "configmaps"} + secretsGVR = schema.GroupVersionResource{Version: "v1", Resource: "secrets"} +) + func New(cfg Config) (*Agent, error) { if cfg.Interval <= 0 { cfg.Interval = defaultInterval @@ -162,6 +170,10 @@ func New(cfg Config) (*Agent, error) { if err != nil { return nil, fmt.Errorf("create kubernetes client: %w", err) } + metadataClient, err := metadata.NewForConfig(restCfg) + if err != nil { + return nil, fmt.Errorf("create kubernetes metadata client: %w", err) + } agentVersion := strings.TrimSpace(cfg.AgentVersion) if agentVersion == "" { @@ -202,6 +214,7 @@ func New(cfg Config) (*Agent, error) { logger: *logger, httpClient: httpClient, kubeClient: kubeClient, + metadataClient: metadataClient, restCfg: restCfg, agentID: agentID, agentVersion: agentVersion, @@ -1986,6 +1999,46 @@ func (a *Agent) inventoryNamespaces() []string { return namespaces } +func (a *Agent) collectPartialObjectMetadata(ctx context.Context, resource schema.GroupVersionResource, actionResource string) ([]metav1.PartialObjectMetadata, error) { + if a.metadataClient == nil { + return nil, fmt.Errorf("kubernetes metadata client unavailable") + } + + items := make([]metav1.PartialObjectMetadata, 0) + for _, namespace := range a.inventoryNamespaces() { + opts := metav1.ListOptions{Limit: listPageSize} + for { + var list *metav1.PartialObjectMetadataList + action := "list " + actionResource + " metadata" + if namespace != metav1.NamespaceAll { + action = fmt.Sprintf("list %s metadata in namespace %q", actionResource, namespace) + } + err := a.runKubernetesCallWithRetry(ctx, action, func(callCtx context.Context) error { + var err error + list, err = a.metadataClient.Resource(resource).Namespace(namespace).List(callCtx, opts) + return err + }) + if err != nil { + return nil, err + } + for _, item := range list.Items { + if !a.namespaceAllowed(item.Namespace) { + continue + } + items = append(items, item) + if len(items) >= maxInventoryItems { + return items, nil + } + } + if list.Continue == "" { + break + } + opts.Continue = list.Continue + } + } + return items, nil +} + func copyKubernetesStringMap(src map[string]string) map[string]string { if len(src) == 0 { return nil @@ -2858,50 +2911,20 @@ func (a *Agent) collectStorageClasses(ctx context.Context) ([]agentsk8s.StorageC } func (a *Agent) collectConfigMaps(ctx context.Context) ([]agentsk8s.ConfigMap, error) { - items := make([]agentsk8s.ConfigMap, 0) - for _, namespace := range a.inventoryNamespaces() { - opts := metav1.ListOptions{Limit: listPageSize} - for { - var list *corev1.ConfigMapList - action := "list configmaps" - if namespace != metav1.NamespaceAll { - action = fmt.Sprintf("list configmaps in namespace %q", namespace) - } - err := a.runKubernetesCallWithRetry(ctx, action, func(callCtx context.Context) error { - var err error - list, err = a.kubeClient.CoreV1().ConfigMaps(namespace).List(callCtx, opts) - return err - }) - if err != nil { - return nil, err - } - for _, configMap := range list.Items { - if !a.namespaceAllowed(configMap.Namespace) { - continue - } - immutable := false - if configMap.Immutable != nil { - immutable = *configMap.Immutable - } - items = append(items, agentsk8s.ConfigMap{ - UID: string(configMap.UID), - Name: configMap.Name, - Namespace: configMap.Namespace, - DataKeys: sortedStringKeys(configMap.Data), - BinaryDataKeys: sortedStringKeys(configMap.BinaryData), - Immutable: immutable, - CreatedAt: configMap.CreationTimestamp.Time, - Labels: copyKubernetesStringMap(configMap.Labels), - }) - if len(items) >= maxInventoryItems { - return items, nil - } - } - if list.Continue == "" { - break - } - opts.Continue = list.Continue - } + metadataItems, err := a.collectPartialObjectMetadata(ctx, configMapsGVR, "configmaps") + if err != nil { + return nil, err + } + items := make([]agentsk8s.ConfigMap, 0, len(metadataItems)) + for _, configMap := range metadataItems { + items = append(items, agentsk8s.ConfigMap{ + UID: string(configMap.UID), + Name: configMap.Name, + Namespace: configMap.Namespace, + MetadataOnly: true, + CreatedAt: configMap.CreationTimestamp.Time, + Labels: copyKubernetesStringMap(configMap.Labels), + }) } return items, nil } @@ -2952,50 +2975,20 @@ func (a *Agent) collectServiceAccounts(ctx context.Context) ([]agentsk8s.Service } func (a *Agent) collectSecrets(ctx context.Context) ([]agentsk8s.Secret, error) { - items := make([]agentsk8s.Secret, 0) - for _, namespace := range a.inventoryNamespaces() { - opts := metav1.ListOptions{Limit: listPageSize} - for { - var list *corev1.SecretList - action := "list secrets" - if namespace != metav1.NamespaceAll { - action = fmt.Sprintf("list secrets in namespace %q", namespace) - } - err := a.runKubernetesCallWithRetry(ctx, action, func(callCtx context.Context) error { - var err error - list, err = a.kubeClient.CoreV1().Secrets(namespace).List(callCtx, opts) - return err - }) - if err != nil { - return nil, err - } - for _, secret := range list.Items { - if !a.namespaceAllowed(secret.Namespace) { - continue - } - immutable := false - if secret.Immutable != nil { - immutable = *secret.Immutable - } - items = append(items, agentsk8s.Secret{ - UID: string(secret.UID), - Name: secret.Name, - Namespace: secret.Namespace, - Type: string(secret.Type), - DataKeys: sortedStringKeys(secret.Data), - Immutable: immutable, - CreatedAt: secret.CreationTimestamp.Time, - Labels: copyKubernetesStringMap(secret.Labels), - }) - if len(items) >= maxInventoryItems { - return items, nil - } - } - if list.Continue == "" { - break - } - opts.Continue = list.Continue - } + metadataItems, err := a.collectPartialObjectMetadata(ctx, secretsGVR, "secrets") + if err != nil { + return nil, err + } + items := make([]agentsk8s.Secret, 0, len(metadataItems)) + for _, secret := range metadataItems { + items = append(items, agentsk8s.Secret{ + UID: string(secret.UID), + Name: secret.Name, + Namespace: secret.Namespace, + MetadataOnly: true, + CreatedAt: secret.CreationTimestamp.Time, + Labels: copyKubernetesStringMap(secret.Labels), + }) } return items, nil } diff --git a/internal/kubernetesagent/agent_inventory_test.go b/internal/kubernetesagent/agent_inventory_test.go index df2ff9703..1020ce93d 100644 --- a/internal/kubernetesagent/agent_inventory_test.go +++ b/internal/kubernetesagent/agent_inventory_test.go @@ -2,6 +2,7 @@ package kubernetesagent import ( "context" + "errors" "testing" autoscalingv2 "k8s.io/api/autoscaling/v2" @@ -9,20 +10,16 @@ import ( policyv1 "k8s.io/api/policy/v1" k8sresource "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/client-go/kubernetes/fake" + metadatafake "k8s.io/client-go/metadata/fake" + k8stesting "k8s.io/client-go/testing" ) func TestCollectNativePolicyConfigAndAutoscalingInventory(t *testing.T) { - immutable := true minAvailable := intstr.FromInt(1) clientset := fake.NewSimpleClientset( - &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{UID: "secret-uid", Namespace: "apps", Name: "api-secret"}, - Type: corev1.SecretTypeOpaque, - Immutable: &immutable, - Data: map[string][]byte{"token": []byte("super-secret")}, - }, &corev1.ResourceQuota{ ObjectMeta: metav1.ObjectMeta{UID: "quota-uid", Namespace: "apps", Name: "apps-quota"}, Spec: corev1.ResourceQuotaSpec{ @@ -69,13 +66,53 @@ func TestCollectNativePolicyConfigAndAutoscalingInventory(t *testing.T) { Status: autoscalingv2.HorizontalPodAutoscalerStatus{CurrentReplicas: 2, DesiredReplicas: 3}, }, ) - a := &Agent{kubeClient: clientset} + clientset.PrependReactor("list", "configmaps", func(k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("full configmap payload list should not be called") + }) + clientset.PrependReactor("list", "secrets", func(k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("full secret payload list should not be called") + }) + + metadataScheme := metadatafake.NewTestScheme() + metav1.AddMetaToScheme(metadataScheme) + metadataClient := metadatafake.NewSimpleMetadataClient( + metadataScheme, + &metav1.PartialObjectMetadata{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ConfigMap"}, + ObjectMeta: metav1.ObjectMeta{ + UID: "configmap-uid", + Namespace: "apps", + Name: "api-config", + CreationTimestamp: metav1.Now(), + Labels: map[string]string{"app": "api"}, + }, + }, + &metav1.PartialObjectMetadata{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Secret"}, + ObjectMeta: metav1.ObjectMeta{ + UID: "secret-uid", + Namespace: "apps", + Name: "api-secret", + CreationTimestamp: metav1.Now(), + Labels: map[string]string{"app": "api"}, + }, + }, + ) + a := &Agent{kubeClient: clientset, metadataClient: metadataClient} + + configMaps, err := a.collectConfigMaps(context.Background()) + if err != nil { + t.Fatalf("collectConfigMaps: %v", err) + } + if len(configMaps) != 1 || configMaps[0].Name != "api-config" || !configMaps[0].MetadataOnly || len(configMaps[0].DataKeys) != 0 || len(configMaps[0].BinaryDataKeys) != 0 { + t.Fatalf("unexpected configmaps: %+v", configMaps) + } secrets, err := a.collectSecrets(context.Background()) if err != nil { t.Fatalf("collectSecrets: %v", err) } - if len(secrets) != 1 || secrets[0].Type != string(corev1.SecretTypeOpaque) || len(secrets[0].DataKeys) != 1 || secrets[0].DataKeys[0] != "token" || !secrets[0].Immutable { + if len(secrets) != 1 || secrets[0].Name != "api-secret" || !secrets[0].MetadataOnly || secrets[0].Type != "" || len(secrets[0].DataKeys) != 0 || secrets[0].Immutable { t.Fatalf("unexpected secrets: %+v", secrets) } diff --git a/internal/mock/demo_scenarios.go b/internal/mock/demo_scenarios.go index 1dce746d9..0dab9bb14 100644 --- a/internal/mock/demo_scenarios.go +++ b/internal/mock/demo_scenarios.go @@ -646,10 +646,10 @@ func applyDemoKubernetesNativeInventory(cluster *models.KubernetesCluster, now t {UID: cluster.ID + "-netpol-default-deny", Name: "default-deny", Namespace: "services", PolicyTypes: []string{"Ingress", "Egress"}, IngressRuleCount: 1, EgressRuleCount: 1, CreatedAt: createdAt, Labels: labels("default-deny")}, } cluster.ConfigMaps = []models.KubernetesConfigMap{ - {UID: cluster.ID + "-cm-checkout-api", Name: "checkout-api-config", Namespace: "services", DataKeys: []string{"app.yaml", "feature-flags.yaml"}, CreatedAt: createdAt, Labels: labels("checkout-api")}, + {UID: cluster.ID + "-cm-checkout-api", Name: "checkout-api-config", Namespace: "services", MetadataOnly: true, CreatedAt: createdAt, Labels: labels("checkout-api")}, } cluster.Secrets = []models.KubernetesSecret{ - {UID: cluster.ID + "-secret-checkout-api", Name: "checkout-api-runtime", Namespace: "services", Type: "Opaque", DataKeys: []string{"database-url", "session-key"}, CreatedAt: createdAt, Labels: labels("checkout-api")}, + {UID: cluster.ID + "-secret-checkout-api", Name: "checkout-api-runtime", Namespace: "services", MetadataOnly: true, CreatedAt: createdAt, Labels: labels("checkout-api")}, } cluster.ServiceAccounts = []models.KubernetesServiceAccount{ {UID: cluster.ID + "-sa-checkout-api", Name: "checkout-api", Namespace: "services", SecretCount: 1, ImagePullSecrets: []string{"registry-pull"}, CreatedAt: createdAt, Labels: labels("checkout-api")}, diff --git a/internal/mock/demo_scenarios_test.go b/internal/mock/demo_scenarios_test.go index 4ab04da2c..4037d8864 100644 --- a/internal/mock/demo_scenarios_test.go +++ b/internal/mock/demo_scenarios_test.go @@ -372,7 +372,14 @@ func kubernetesNativeInventoryExists(graph FixtureGraph) bool { len(cluster.Events) == 0 { return false } - if cluster.Secrets[0].Type == "" || len(cluster.Secrets[0].DataKeys) == 0 { + if !cluster.ConfigMaps[0].MetadataOnly || + len(cluster.ConfigMaps[0].DataKeys) != 0 || + len(cluster.ConfigMaps[0].BinaryDataKeys) != 0 { + return false + } + if !cluster.Secrets[0].MetadataOnly || + cluster.Secrets[0].Type != "" || + len(cluster.Secrets[0].DataKeys) != 0 { return false } if cluster.HorizontalPodAutoscalers[0].TargetKind == "" || diff --git a/internal/models/models.go b/internal/models/models.go index 9be95281b..51834caea 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -1598,6 +1598,7 @@ type KubernetesConfigMap struct { DataKeys []string `json:"dataKeys,omitempty"` BinaryDataKeys []string `json:"binaryDataKeys,omitempty"` Immutable bool `json:"immutable,omitempty"` + MetadataOnly bool `json:"metadataOnly,omitempty"` CreatedAt time.Time `json:"createdAt,omitempty"` Labels map[string]string `json:"labels,omitempty"` } @@ -1616,14 +1617,15 @@ func (c KubernetesConfigMap) NormalizeCollections() KubernetesConfigMap { } type KubernetesSecret struct { - UID string `json:"uid"` - Name string `json:"name"` - Namespace string `json:"namespace"` - Type string `json:"type,omitempty"` - DataKeys []string `json:"dataKeys,omitempty"` - Immutable bool `json:"immutable,omitempty"` - CreatedAt time.Time `json:"createdAt,omitempty"` - Labels map[string]string `json:"labels,omitempty"` + UID string `json:"uid"` + Name string `json:"name"` + Namespace string `json:"namespace"` + Type string `json:"type,omitempty"` + DataKeys []string `json:"dataKeys,omitempty"` + Immutable bool `json:"immutable,omitempty"` + MetadataOnly bool `json:"metadataOnly,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` + Labels map[string]string `json:"labels,omitempty"` } func (s KubernetesSecret) NormalizeCollections() KubernetesSecret { diff --git a/internal/monitoring/kubernetes_agents.go b/internal/monitoring/kubernetes_agents.go index c268f917a..6aa31444d 100644 --- a/internal/monitoring/kubernetes_agents.go +++ b/internal/monitoring/kubernetes_agents.go @@ -639,6 +639,7 @@ func convertKubernetesConfigMaps(configMaps []agentsk8s.ConfigMap) []models.Kube DataKeys: append([]string(nil), configMap.DataKeys...), BinaryDataKeys: append([]string(nil), configMap.BinaryDataKeys...), Immutable: configMap.Immutable, + MetadataOnly: configMap.MetadataOnly, CreatedAt: configMap.CreatedAt, Labels: cloneStringMap(configMap.Labels), }.NormalizeCollections()) @@ -653,14 +654,15 @@ func convertKubernetesSecrets(secrets []agentsk8s.Secret) []models.KubernetesSec out := make([]models.KubernetesSecret, 0, len(secrets)) for _, secret := range secrets { out = append(out, models.KubernetesSecret{ - UID: strings.TrimSpace(secret.UID), - Name: strings.TrimSpace(secret.Name), - Namespace: strings.TrimSpace(secret.Namespace), - Type: strings.TrimSpace(secret.Type), - DataKeys: append([]string(nil), secret.DataKeys...), - Immutable: secret.Immutable, - CreatedAt: secret.CreatedAt, - Labels: cloneStringMap(secret.Labels), + UID: strings.TrimSpace(secret.UID), + Name: strings.TrimSpace(secret.Name), + Namespace: strings.TrimSpace(secret.Namespace), + Type: strings.TrimSpace(secret.Type), + DataKeys: append([]string(nil), secret.DataKeys...), + Immutable: secret.Immutable, + MetadataOnly: secret.MetadataOnly, + CreatedAt: secret.CreatedAt, + Labels: cloneStringMap(secret.Labels), }.NormalizeCollections()) } return out diff --git a/internal/monitoring/kubernetes_agents_test.go b/internal/monitoring/kubernetes_agents_test.go index 9b38df924..8a9971593 100644 --- a/internal/monitoring/kubernetes_agents_test.go +++ b/internal/monitoring/kubernetes_agents_test.go @@ -129,8 +129,8 @@ func TestApplyKubernetesReportPreservesNativeAPIInventory(t *testing.T) { UID: "pvc-1", Name: "checkout-data", Namespace: "services", Phase: " Bound ", StorageClass: "fast", RequestedBytes: 10_000, CapacityBytes: 10_000, AccessModes: []string{"ReadWriteOnce"}, VolumeName: "pv-checkout", CreatedAt: now, }}, StorageClasses: []agentsk8s.StorageClass{{UID: "sc-1", Name: "fast", Provisioner: "csi.example.test", ReclaimPolicy: "Delete", VolumeBindingMode: "WaitForFirstConsumer", AllowVolumeExpansion: &allowExpansion, ParameterKeys: []string{"type"}}}, - ConfigMaps: []agentsk8s.ConfigMap{{UID: "cm-1", Name: "checkout-config", Namespace: "services", DataKeys: []string{"app.yaml"}, BinaryDataKeys: []string{"logo.png"}, Immutable: true}}, - Secrets: []agentsk8s.Secret{{UID: "sec-1", Name: "checkout-secret", Namespace: "services", Type: "Opaque", DataKeys: []string{"token"}, Immutable: true}}, + ConfigMaps: []agentsk8s.ConfigMap{{UID: "cm-1", Name: "checkout-config", Namespace: "services", DataKeys: []string{"app.yaml"}, BinaryDataKeys: []string{"logo.png"}, Immutable: true, MetadataOnly: true}}, + Secrets: []agentsk8s.Secret{{UID: "sec-1", Name: "checkout-secret", Namespace: "services", Type: "Opaque", DataKeys: []string{"token"}, Immutable: true, MetadataOnly: true}}, ServiceAccounts: []agentsk8s.ServiceAccount{{UID: "sa-1", Name: "checkout", Namespace: "services", AutomountServiceAccountToken: &automountToken, SecretCount: 1, ImagePullSecrets: []string{"pull-secret"}}}, ResourceQuotas: []agentsk8s.ResourceQuota{{UID: "rq-1", Name: "services-quota", Namespace: "services", Hard: map[string]string{"pods": "10"}, Used: map[string]string{"pods": "3"}}}, LimitRanges: []agentsk8s.LimitRange{{UID: "lr-1", Name: "services-limits", Namespace: "services", LimitTypes: []string{"Container"}}}, @@ -187,10 +187,10 @@ func TestApplyKubernetesReportPreservesNativeAPIInventory(t *testing.T) { if len(cluster.StorageClasses) != 1 || cluster.StorageClasses[0].Provisioner != "csi.example.test" || cluster.StorageClasses[0].AllowVolumeExpansion == nil || !*cluster.StorageClasses[0].AllowVolumeExpansion { t.Fatalf("expected storage class inventory, got %+v", cluster.StorageClasses) } - if len(cluster.ConfigMaps) != 1 || cluster.ConfigMaps[0].DataKeys[0] != "app.yaml" || !cluster.ConfigMaps[0].Immutable { + if len(cluster.ConfigMaps) != 1 || cluster.ConfigMaps[0].DataKeys[0] != "app.yaml" || !cluster.ConfigMaps[0].Immutable || !cluster.ConfigMaps[0].MetadataOnly { t.Fatalf("expected configmap inventory, got %+v", cluster.ConfigMaps) } - if len(cluster.Secrets) != 1 || cluster.Secrets[0].Type != "Opaque" || cluster.Secrets[0].DataKeys[0] != "token" || !cluster.Secrets[0].Immutable { + if len(cluster.Secrets) != 1 || cluster.Secrets[0].Type != "Opaque" || cluster.Secrets[0].DataKeys[0] != "token" || !cluster.Secrets[0].Immutable || !cluster.Secrets[0].MetadataOnly { t.Fatalf("expected secret metadata inventory, got %+v", cluster.Secrets) } if len(cluster.ServiceAccounts) != 1 || cluster.ServiceAccounts[0].SecretCount != 1 || cluster.ServiceAccounts[0].AutomountServiceAccountToken == nil || !*cluster.ServiceAccounts[0].AutomountServiceAccountToken { diff --git a/internal/unifiedresources/adapter_coverage_test.go b/internal/unifiedresources/adapter_coverage_test.go index 69d3714a8..dd3d1cdda 100644 --- a/internal/unifiedresources/adapter_coverage_test.go +++ b/internal/unifiedresources/adapter_coverage_test.go @@ -511,13 +511,13 @@ func TestKubernetesNativeInventoryAdapters(t *testing.T) { t.Fatalf("unexpected storage class resource: %+v", storageClass) } - configMap, _ := resourceFromKubernetesConfigMap(cluster, models.KubernetesConfigMap{Name: "checkout-config", Namespace: "services", DataKeys: []string{"app.yaml"}, BinaryDataKeys: []string{"logo.png"}, Immutable: true}, nil) - if configMap.Type != ResourceTypeK8sConfigMap || configMap.Kubernetes == nil || configMap.Kubernetes.DataKeys[0] != "app.yaml" || !configMap.Kubernetes.Immutable { + configMap, _ := resourceFromKubernetesConfigMap(cluster, models.KubernetesConfigMap{Name: "checkout-config", Namespace: "services", DataKeys: []string{"app.yaml"}, BinaryDataKeys: []string{"logo.png"}, Immutable: true, MetadataOnly: true}, nil) + if configMap.Type != ResourceTypeK8sConfigMap || configMap.Kubernetes == nil || configMap.Kubernetes.DataKeys[0] != "app.yaml" || !configMap.Kubernetes.Immutable || !configMap.Kubernetes.MetadataOnly { t.Fatalf("unexpected configmap resource: %+v", configMap) } - secret, _ := resourceFromKubernetesSecret(cluster, models.KubernetesSecret{Name: "checkout-secret", Namespace: "services", Type: "Opaque", DataKeys: []string{"token"}, Immutable: true}, nil) - if secret.Type != ResourceTypeK8sSecret || secret.Kubernetes == nil || secret.Kubernetes.SecretType != "Opaque" || secret.Kubernetes.DataKeys[0] != "token" || !secret.Kubernetes.Immutable { + secret, _ := resourceFromKubernetesSecret(cluster, models.KubernetesSecret{Name: "checkout-secret", Namespace: "services", Type: "Opaque", DataKeys: []string{"token"}, Immutable: true, MetadataOnly: true}, nil) + if secret.Type != ResourceTypeK8sSecret || secret.Kubernetes == nil || secret.Kubernetes.SecretType != "Opaque" || secret.Kubernetes.DataKeys[0] != "token" || !secret.Kubernetes.Immutable || !secret.Kubernetes.MetadataOnly { t.Fatalf("unexpected secret resource: %+v", secret) } diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go index e799dcb73..a82c4ee48 100644 --- a/internal/unifiedresources/adapters.go +++ b/internal/unifiedresources/adapters.go @@ -2935,6 +2935,7 @@ func resourceFromKubernetesConfigMap(cluster models.KubernetesCluster, configMap data.DataKeys = append([]string(nil), configMap.DataKeys...) data.BinaryDataKeys = append([]string(nil), configMap.BinaryDataKeys...) data.Immutable = configMap.Immutable + data.MetadataOnly = configMap.MetadataOnly data.Labels = labels data.CreatedAt = zeroTimeToPtr(configMap.CreatedAt) resource := Resource{ @@ -2960,6 +2961,7 @@ func resourceFromKubernetesSecret(cluster models.KubernetesCluster, secret model data.SecretType = secret.Type data.DataKeys = append([]string(nil), secret.DataKeys...) data.Immutable = secret.Immutable + data.MetadataOnly = secret.MetadataOnly data.Labels = labels data.CreatedAt = zeroTimeToPtr(secret.CreatedAt) resource := Resource{ diff --git a/internal/unifiedresources/canonical_ids_types_test.go b/internal/unifiedresources/canonical_ids_types_test.go index 21d97d115..ba73d1707 100644 --- a/internal/unifiedresources/canonical_ids_types_test.go +++ b/internal/unifiedresources/canonical_ids_types_test.go @@ -121,13 +121,13 @@ func TestContractResourceType(t *testing.T) { want: ResourceTypeK8sStorageClass, }, { - name: "kubernetes configmap passthrough remains canonical", - resource: Resource{Type: ResourceTypeK8sConfigMap, Kubernetes: &K8sData{DataKeys: []string{"app.yaml"}}}, + name: "kubernetes metadata-only configmap passthrough remains canonical", + resource: Resource{Type: ResourceTypeK8sConfigMap, Kubernetes: &K8sData{MetadataOnly: true}}, want: ResourceTypeK8sConfigMap, }, { - name: "kubernetes secret passthrough remains canonical", - resource: Resource{Type: ResourceTypeK8sSecret, Kubernetes: &K8sData{SecretType: "Opaque", DataKeys: []string{"token"}}}, + name: "kubernetes metadata-only secret passthrough remains canonical", + resource: Resource{Type: ResourceTypeK8sSecret, Kubernetes: &K8sData{MetadataOnly: true}}, want: ResourceTypeK8sSecret, }, { diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index e2413698f..1048fbe2a 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -1579,6 +1579,7 @@ type K8sData struct { DataKeys []string `json:"dataKeys,omitempty"` BinaryDataKeys []string `json:"binaryDataKeys,omitempty"` Immutable bool `json:"immutable,omitempty"` + MetadataOnly bool `json:"metadataOnly,omitempty"` SecretType string `json:"secretType,omitempty"` AutomountServiceAccountToken *bool `json:"automountServiceAccountToken,omitempty"` SecretCount int `json:"secretCount,omitempty"` diff --git a/pkg/agents/kubernetes/report.go b/pkg/agents/kubernetes/report.go index 57ff56b44..a21cc36a2 100644 --- a/pkg/agents/kubernetes/report.go +++ b/pkg/agents/kubernetes/report.go @@ -328,7 +328,9 @@ type StorageClass struct { } // ConfigMap represents Kubernetes ConfigMap metadata at report time. -// Data values are intentionally omitted; only keys are reported. +// ConfigMap payload values are intentionally omitted. Current agents prefer +// Kubernetes metadata-only API responses and therefore do not need to receive +// ConfigMap data values at all; older reports may include key names only. type ConfigMap struct { UID string `json:"uid"` Name string `json:"name"` @@ -336,21 +338,25 @@ type ConfigMap struct { DataKeys []string `json:"dataKeys,omitempty"` BinaryDataKeys []string `json:"binaryDataKeys,omitempty"` Immutable bool `json:"immutable,omitempty"` + MetadataOnly bool `json:"metadataOnly,omitempty"` CreatedAt time.Time `json:"createdAt,omitempty"` Labels map[string]string `json:"labels,omitempty"` } // Secret represents Kubernetes Secret metadata at report time. -// Secret values are intentionally omitted; only key names and type metadata are reported. +// Secret payload values are intentionally omitted. Current agents use +// Kubernetes metadata-only API responses for Secret inventory; older reports +// may include type and key names, but never Secret values. type Secret struct { - UID string `json:"uid"` - Name string `json:"name"` - Namespace string `json:"namespace"` - Type string `json:"type,omitempty"` - DataKeys []string `json:"dataKeys,omitempty"` - Immutable bool `json:"immutable,omitempty"` - CreatedAt time.Time `json:"createdAt,omitempty"` - Labels map[string]string `json:"labels,omitempty"` + UID string `json:"uid"` + Name string `json:"name"` + Namespace string `json:"namespace"` + Type string `json:"type,omitempty"` + DataKeys []string `json:"dataKeys,omitempty"` + Immutable bool `json:"immutable,omitempty"` + MetadataOnly bool `json:"metadataOnly,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` + Labels map[string]string `json:"labels,omitempty"` } // ServiceAccount represents Kubernetes ServiceAccount metadata at report time. diff --git a/scripts/release_control/canonical_completion_guard_test.py b/scripts/release_control/canonical_completion_guard_test.py index 6e77138f9..cb684aa9d 100644 --- a/scripts/release_control/canonical_completion_guard_test.py +++ b/scripts/release_control/canonical_completion_guard_test.py @@ -220,6 +220,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase): "memory-source-runtime", "docker-collect-runtime", "docker-swarm-runtime", + "kubernetes-native-agent-runtime", "docker-runtime-report-model", "proxmox-zfs-runtime", "proxmox-ceph-runtime", @@ -771,6 +772,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase): "frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts", "frontend-modern/src/features/infrastructure/__tests__/InfrastructurePageSurface.guardrails.test.ts", "frontend-modern/src/features/infrastructure/__tests__/infrastructurePageModel.test.ts", + "frontend-modern/src/features/kubernetes/__tests__/KubernetesInventoryTable.test.tsx", "frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts", "frontend-modern/src/routing/__tests__/resourceLinks.test.ts", "frontend-modern/src/stores/__tests__/websocket-unified.test.ts", diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index c3c2b74d8..0a5408e4e 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -4810,6 +4810,7 @@ class SubsystemLookupTest(unittest.TestCase): "frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts", "frontend-modern/src/features/infrastructure/__tests__/InfrastructurePageSurface.guardrails.test.ts", "frontend-modern/src/features/infrastructure/__tests__/infrastructurePageModel.test.ts", + "frontend-modern/src/features/kubernetes/__tests__/KubernetesInventoryTable.test.tsx", "frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts", "frontend-modern/src/routing/__tests__/resourceLinks.test.ts", "frontend-modern/src/stores/__tests__/websocket-unified.test.ts",