Use metadata-only Kubernetes config inventory

This commit is contained in:
rcourtman 2026-05-24 12:36:50 +01:00
parent 120dd5353a
commit 49c9ca7656
22 changed files with 324 additions and 157 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Resource> & Pick<Resource, 'id' | 'type'>): 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(() => (
<KubernetesInventoryTable
resources={[
makeResource({
id: 'api-config',
type: 'k8s-configmap',
kubernetes: {
namespace: 'apps',
resourceKind: 'ConfigMap',
dataKeys: ['app.yaml'],
metadataOnly: true,
},
}),
makeResource({
id: 'api-secret',
type: 'k8s-secret',
kubernetes: {
namespace: 'apps',
resourceKind: 'Secret',
dataKeys: ['token'],
metadataOnly: true,
},
}),
]}
variant="config"
emptyIcon={<span />}
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();
});
});

View file

@ -953,6 +953,7 @@ export interface ResourceKubernetesMeta {
dataKeys?: string[];
binaryDataKeys?: string[];
immutable?: boolean;
metadataOnly?: boolean;
secretType?: string;
automountServiceAccountToken?: boolean;
secretCount?: number;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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