From 20d5470d2f5f011292961410c68dd11b7edebf5d Mon Sep 17 00:00:00 2001 From: Entlein Date: Thu, 23 Jul 2026 21:52:10 +0200 Subject: [PATCH 01/29] feat(migrate): converter + differential oracle for user-defined AP/NN -> ContainerProfile ConvertUserProfilesToContainerProfile produces the single user-defined ContainerProfile equivalent to a legacy user-authored ApplicationProfile + NetworkNeighborhood pair, reusing the existing projectUserProfiles merge onto an empty base. The differential oracle pins the migration contract at the enforcement level: for representative user-defined shapes (opens/exec argv wildcards, HTTP endpoints, egress/ingress + LabelSelector) the ProjectedContainerProfile from the legacy AP+NN overlay path equals the one from using the converted ContainerProfile as the base with no overlay. Behaviour-preserving by construction. Signed-off-by: entlein --- .../containerprofilecache/usercp.go | 61 ++++++++ .../usercp_diff_oracle_test.go | 143 ++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 pkg/objectcache/containerprofilecache/usercp.go create mode 100644 pkg/objectcache/containerprofilecache/usercp_diff_oracle_test.go diff --git a/pkg/objectcache/containerprofilecache/usercp.go b/pkg/objectcache/containerprofilecache/usercp.go new file mode 100644 index 000000000..51f52366a --- /dev/null +++ b/pkg/objectcache/containerprofilecache/usercp.go @@ -0,0 +1,61 @@ +package containerprofilecache + +import ( + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ConvertUserProfilesToContainerProfile builds the single user-defined +// ContainerProfile equivalent to a legacy user-authored ApplicationProfile + +// NetworkNeighborhood pair, for one container. +// +// The result's Spec is exactly what projectUserProfiles produces when overlaying +// the AP+NN onto an empty base, so projecting this CP yields a byte-identical +// ProjectedContainerProfile to the legacy AP+NN overlay path — the migration is +// behaviour-preserving by construction (see usercp_diff_oracle_test.go). This is +// the migration artifact: authoring one ContainerProfile replaces the AP+NN pair. +// +// Either userAP or userNN may be nil. Metadata (name/namespace/labels and the +// managed-by/status/completion provenance annotations) is carried from whichever +// source is present; Architectures (an AP spec-level field, not projected by +// Apply) is copied for artifact completeness. +func ConvertUserProfilesToContainerProfile(userAP *v1beta1.ApplicationProfile, userNN *v1beta1.NetworkNeighborhood, pod *corev1.Pod, containerName string) *v1beta1.ContainerProfile { + base := &v1beta1.ContainerProfile{} + if meta := userProfileMeta(userAP, userNN); meta != nil { + base.Name = meta.Name + base.Namespace = meta.Namespace + base.Annotations = copyStringMap(meta.Annotations) + base.Labels = copyStringMap(meta.Labels) + } + if userAP != nil { + base.Spec.Architectures = append([]string(nil), userAP.Spec.Architectures...) + } + + projected, _ := projectUserProfiles(base, userAP, userNN, pod, containerName) + return projected +} + +// userProfileMeta returns the ObjectMeta to carry onto the converted CP, +// preferring the ApplicationProfile (AP and NN share name/labels/annotations for +// a user-defined pair). +func userProfileMeta(userAP *v1beta1.ApplicationProfile, userNN *v1beta1.NetworkNeighborhood) *metav1.ObjectMeta { + if userAP != nil { + return &userAP.ObjectMeta + } + if userNN != nil { + return &userNN.ObjectMeta + } + return nil +} + +func copyStringMap(m map[string]string) map[string]string { + if len(m) == 0 { + return nil + } + out := make(map[string]string, len(m)) + for k, v := range m { + out[k] = v + } + return out +} diff --git a/pkg/objectcache/containerprofilecache/usercp_diff_oracle_test.go b/pkg/objectcache/containerprofilecache/usercp_diff_oracle_test.go new file mode 100644 index 000000000..af98d54a6 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/usercp_diff_oracle_test.go @@ -0,0 +1,143 @@ +package containerprofilecache + +import ( + "testing" + + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + dynamicpathdetector "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// The differential oracle for the user-defined AP/NN -> ContainerProfile +// migration. It pins the migration contract at the ENFORCEMENT artifact level: +// +// OLD path (what node-agent does today for a user-defined container): +// overlay the user AP + user NN onto the synthetic empty base CP, then Apply. +// NEW path (after the migration): +// the user authors ONE ContainerProfile (produced by the converter); node-agent +// uses it as the base with no overlay, then Apply. +// +// If these two ProjectedContainerProfiles are equal for every representative +// user-defined shape, the migration is behaviour-preserving. Apply is a pure +// function of cp.Spec (+ the SyncChecksum annotation), so equality here is exactly +// enforcement-equivalence. + +func udMeta(name string) metav1.ObjectMeta { + return metav1.ObjectMeta{ + Name: name, + Namespace: "demo", + Annotations: map[string]string{ + "kubescape.io/managed-by": "User", + "kubescape.io/status": "completed", + "kubescape.io/completion": "complete", + }, + Labels: map[string]string{ + "kubescape.io/workload-kind": "Deployment", + "kubescape.io/workload-name": name, + }, + } +} + +// oracleCases mirror the real user-defined component tests (27/28/32/33): +// opens with wildcard/ellipsis anchoring, execs with argv wildcards, HTTP +// endpoints, and egress/ingress with a LabelSelector. +func oracleCases() []struct { + name string + containerName string + ap *v1beta1.ApplicationProfile + nn *v1beta1.NetworkNeighborhood +} { + dyn := dynamicpathdetector.DynamicIdentifier + full := func() (*v1beta1.ApplicationProfile, *v1beta1.NetworkNeighborhood) { + ap := &v1beta1.ApplicationProfile{ + ObjectMeta: udMeta("curl-overlay"), + Spec: v1beta1.ApplicationProfileSpec{ + Architectures: []string{"amd64"}, + Containers: []v1beta1.ApplicationProfileContainer{{ + Name: "curl", + Capabilities: []string{"NET_BIND_SERVICE", "SYS_PTRACE"}, + Execs: []v1beta1.ExecCalls{ + {Path: "/usr/bin/curl", Args: []string{"curl", dyn}}, + {Path: "/bin/sh", Args: []string{"sh", "-c", "echo *"}}, + }, + Opens: []v1beta1.OpenCalls{ + {Path: "/etc/ssl/" + dyn, Flags: []string{"O_RDONLY"}}, + {Path: "/etc/ld.so.cache", Flags: []string{"O_RDONLY"}}, + {Path: "/var/log/*", Flags: []string{"O_RDONLY"}}, + }, + Syscalls: []string{"openat", "read", "connect"}, + Endpoints: []v1beta1.HTTPEndpoint{ + {Endpoint: ":8080/api/products", Methods: []string{"GET"}}, + }, + PolicyByRuleId: map[string]v1beta1.RulePolicy{ + "R0040": {AllowedProcesses: []string{"curl"}}, + }, + }}, + }, + } + nn := &v1beta1.NetworkNeighborhood{ + ObjectMeta: udMeta("curl-overlay"), + Spec: v1beta1.NetworkNeighborhoodSpec{ + LabelSelector: metav1.LabelSelector{MatchLabels: map[string]string{"app": "curl"}}, + Containers: []v1beta1.NetworkNeighborhoodContainer{{ + Name: "curl", + Egress: []v1beta1.NetworkNeighbor{ + {Identifier: "eg-dns", DNS: "fusioncore.ai.", Type: "external", + Ports: []v1beta1.NetworkPort{{Name: "TCP-80", Protocol: "TCP", Port: p80()}}}, + {Identifier: "eg-ip", IPAddress: "162.0.217.171", Type: "external", + Ports: []v1beta1.NetworkPort{{Name: "TCP-80", Protocol: "TCP", Port: p80()}}}, + }, + Ingress: []v1beta1.NetworkNeighbor{ + {Identifier: "in-1", DNSNames: []string{"a.svc.local"}}, + }, + }}, + }, + } + return ap, nn + } + apFull, nnFull := full() + apOnly, _ := full() + _, nnOnly := full() + + return []struct { + name string + containerName string + ap *v1beta1.ApplicationProfile + nn *v1beta1.NetworkNeighborhood + }{ + {"full_ap_and_nn", "curl", apFull, nnFull}, + {"ap_only", "curl", apOnly, nil}, + {"nn_only", "curl", nil, nnOnly}, + {"no_matching_container", "sidecar", apFull, nnFull}, + } +} + +func p80() *int32 { v := int32(80); return &v } + +func TestDiffOracle_UserDefinedCP_MatchesLegacyOverlay(t *testing.T) { + for _, tc := range oracleCases() { + tc := tc + t.Run(tc.name, func(t *testing.T) { + pod := podWith("curl") + + // OLD path: overlay user AP + NN onto the synthetic empty base. + emptyBase := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "base", Namespace: "demo"}, + } + legacyCP, _ := projectUserProfiles(emptyBase, tc.ap, tc.nn, pod, tc.containerName) + + // NEW path: the converted single ContainerProfile is the base, no overlay. + userCP := ConvertUserProfilesToContainerProfile(tc.ap, tc.nn, pod, tc.containerName) + newCP, _ := projectUserProfiles(userCP, nil, nil, pod, tc.containerName) + + // nil spec => full pass-through; equality here is spec-independent + // (equal Specs => equal Apply for ANY RuleProjectionSpec). + pLegacy := Apply(nil, legacyCP, nil) + pNew := Apply(nil, newCP, nil) + + assert.Equal(t, pLegacy, pNew, + "migrated user-defined ContainerProfile must enforce identically to the legacy AP+NN overlay") + }) + } +} From 3f9299b5f14fd470fec4af44cfa3ac82a54fc7ea Mon Sep 17 00:00:00 2001 From: Entlein Date: Fri, 24 Jul 2026 11:33:56 +0200 Subject: [PATCH 02/29] feat(migrate): node-agent reads a user-defined ContainerProfile as the authoritative base When the user-defined-profile pod label names a ContainerProfile carrying managed-by: User, the cache now uses it directly as the container's base profile (the migrated "new way"), instead of overlaying a legacy ApplicationProfile + NetworkNeighborhood pair. It falls back to the legacy AP+NN pair when no such CP exists, which still fires the existing deprecation signal. - add-time (tryPopulateEntry) and refresh (reconciler) both prefer the user CP, gated on the managed-by: User annotation so a learned CP at the same name is never mistaken for a user-defined one - UserCPRef/UserCPRV bookkeeping mirrors the legacy UserAPRV/UserNNRV RV tracking so the reconciler re-fetches and rebuilds only when the user CP changes Signed-off-by: entlein --- .../containerprofilecache.go | 93 +++++++++++++------ .../containerprofilecache_test.go | 46 ++++++++- .../containerprofilecache/reconciler.go | 45 ++++++++- .../containerprofilecache/usercp.go | 10 ++ 4 files changed, 160 insertions(+), 34 deletions(-) diff --git a/pkg/objectcache/containerprofilecache/containerprofilecache.go b/pkg/objectcache/containerprofilecache/containerprofilecache.go index 3c2535ab8..ef596aae5 100644 --- a/pkg/objectcache/containerprofilecache/containerprofilecache.go +++ b/pkg/objectcache/containerprofilecache/containerprofilecache.go @@ -67,6 +67,12 @@ type CachedContainerProfile struct { UserAPRef *namespacedName UserNNRef *namespacedName + // UserCPRef is set when the user-defined-profile label names a single + // user-authored ContainerProfile (the migrated "new way"), which is used + // as the authoritative base for the container. Mutually exclusive with the + // legacy UserAPRef/UserNNRef overlay. Used by the reconciler to re-fetch. + UserCPRef *namespacedName + // CPName is the storage name of the ContainerProfile. Populated at // addContainer time so the reconciler can re-fetch without re-querying // shared data (which may have been evicted from K8sObjectCache by then). @@ -83,6 +89,7 @@ type CachedContainerProfile struct { UserManagedNNRV string // user-managed NN (ug-) RV at last projection, "" if absent UserAPRV string // user-AP (label-referenced) resourceVersion at last projection, "" if no overlay UserNNRV string // user-NN (label-referenced) resourceVersion at last projection, "" if no overlay + UserCPRV string // user-defined ContainerProfile (label-referenced) RV at last load, "" if not used } // pendingContainer captures the minimum state needed to retry the initial @@ -392,41 +399,63 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( // transient failures are recovered. var userAP *v1beta1.ApplicationProfile var userNN *v1beta1.NetworkNeighborhood + var userDefinedCP *v1beta1.ContainerProfile overlayName, hasOverlay := container.K8s.PodLabels[helpersv1.UserDefinedProfileMetadataKey] if hasOverlay && overlayName != "" { - var userAPErr error - _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userAP, userAPErr = c.storageClient.GetApplicationProfile(rctx, ns, overlayName) - return userAPErr - }) - if userAPErr != nil { - logger.L().Debug("user-defined ApplicationProfile not available", - helpers.String("containerID", containerID), - helpers.String("namespace", ns), - helpers.String("name", overlayName), - helpers.Error(userAPErr)) - userAP = nil - } - var userNNErr error + // Migration (#862): the user-defined-profile label now names a single + // user-authored ContainerProfile ("new way") — the unified replacement + // for the legacy AP+NN pair. Prefer it: it is authoritative and needs no + // overlay merge. Fall back to the legacy AP+NN pair only when no such CP + // exists, in which case emitOverlayMetrics fires the deprecation signal. + var userCPErr error _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userNN, userNNErr = c.storageClient.GetNetworkNeighborhood(rctx, ns, overlayName) - return userNNErr + userDefinedCP, userCPErr = c.storageClient.GetContainerProfile(rctx, ns, overlayName) + return userCPErr }) - if userNNErr != nil { - logger.L().Debug("user-defined NetworkNeighborhood not available", - helpers.String("containerID", containerID), - helpers.String("namespace", ns), - helpers.String("name", overlayName), - helpers.Error(userNNErr)) - userNN = nil + if userCPErr != nil || !isUserDefinedContainerProfile(userDefinedCP) { + userDefinedCP = nil + var userAPErr error + _ = c.refreshRPC(ctx, func(rctx context.Context) error { + userAP, userAPErr = c.storageClient.GetApplicationProfile(rctx, ns, overlayName) + return userAPErr + }) + if userAPErr != nil { + logger.L().Debug("user-defined ApplicationProfile not available", + helpers.String("containerID", containerID), + helpers.String("namespace", ns), + helpers.String("name", overlayName), + helpers.Error(userAPErr)) + userAP = nil + } + var userNNErr error + _ = c.refreshRPC(ctx, func(rctx context.Context) error { + userNN, userNNErr = c.storageClient.GetNetworkNeighborhood(rctx, ns, overlayName) + return userNNErr + }) + if userNNErr != nil { + logger.L().Debug("user-defined NetworkNeighborhood not available", + helpers.String("containerID", containerID), + helpers.String("namespace", ns), + helpers.String("name", overlayName), + helpers.Error(userNNErr)) + userNN = nil + } } } // Need SOMETHING to cache. If we have nothing, stay pending and retry. - if cp == nil && userManagedAP == nil && userManagedNN == nil && userAP == nil && userNN == nil { + if cp == nil && userDefinedCP == nil && userManagedAP == nil && userManagedNN == nil && userAP == nil && userNN == nil { return false } + // A user-defined ContainerProfile is authoritative for this container: it is + // the migrated replacement for the AP+NN overlay, so it becomes the base + // (the ug- user-managed pass may still union on top). Learning is suppressed + // for user-defined containers, so no consolidated CP competes with it. + if userDefinedCP != nil { + cp = userDefinedCP + } + // When no consolidated CP is available, synthesize an empty CP named // after the workload so downstream state display is sensible. Projection // below merges user-managed + user-defined overlay onto this base. @@ -492,11 +521,17 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( // these refs to re-fetch on every tick; without them, a transient 404 // at add time would permanently lose the overlay. if hasOverlay && overlayName != "" { - if entry.UserAPRef == nil { - entry.UserAPRef = &namespacedName{Namespace: ns, Name: overlayName} - } - if entry.UserNNRef == nil { - entry.UserNNRef = &namespacedName{Namespace: ns, Name: overlayName} + if userDefinedCP != nil { + // New way: track the user-defined CP for re-fetch; no legacy refs. + entry.UserCPRef = &namespacedName{Namespace: ns, Name: overlayName} + entry.UserCPRV = userDefinedCP.ResourceVersion + } else { + if entry.UserAPRef == nil { + entry.UserAPRef = &namespacedName{Namespace: ns, Name: overlayName} + } + if entry.UserNNRef == nil { + entry.UserNNRef = &namespacedName{Namespace: ns, Name: overlayName} + } } } diff --git a/pkg/objectcache/containerprofilecache/containerprofilecache_test.go b/pkg/objectcache/containerprofilecache/containerprofilecache_test.go index 66dbbcaf4..cb6345b1e 100644 --- a/pkg/objectcache/containerprofilecache/containerprofilecache_test.go +++ b/pkg/objectcache/containerprofilecache/containerprofilecache_test.go @@ -27,6 +27,10 @@ import ( // pointer equality). type fakeProfileClient struct { cp *v1beta1.ContainerProfile + // userCP, when non-nil, is returned by GetContainerProfile for a name + // matching userCP.Name (the migrated user-defined ContainerProfile). Other + // names fall through to cp. Lets tests exercise the new-way overlay path. + userCP *v1beta1.ContainerProfile ap *v1beta1.ApplicationProfile // returned for Get by ap.Name match (or any if overlayOnly is empty) nn *v1beta1.NetworkNeighborhood cpErr error @@ -78,8 +82,11 @@ func (f *fakeProfileClient) GetNetworkNeighborhood(_ context.Context, _, name st } return f.nn, f.nnErr } -func (f *fakeProfileClient) GetContainerProfile(_ context.Context, _, _ string) (*v1beta1.ContainerProfile, error) { +func (f *fakeProfileClient) GetContainerProfile(_ context.Context, _, name string) (*v1beta1.ContainerProfile, error) { f.getCPCalls++ + if f.userCP != nil && name == f.userCP.Name { + return f.userCP, nil + } return f.cp, f.cpErr } func (f *fakeProfileClient) ListApplicationProfiles(_ context.Context, _ string, _ int64, _ string) (*v1beta1.ApplicationProfileList, error) { @@ -205,6 +212,43 @@ func TestOverlayPath_DeepCopies(t *testing.T) { assert.Equal(t, "u1", entry.UserAPRV) } +// TestOverlayPath_UserDefinedCP_NewWay verifies the migrated path: when the +// user-defined-profile label names a user-authored ContainerProfile +// (managed-by: User), it becomes the authoritative base — UserCPRef is set, the +// legacy UserAPRef/UserNNRef are NOT, and the projection reflects the CP. +func TestOverlayPath_UserDefinedCP_NewWay(t *testing.T) { + userCP := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "override", Namespace: "default", ResourceVersion: "uc1", + Annotations: map[string]string{ + helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, + helpersv1.StatusMetadataKey: helpersv1.Completed, + helpersv1.CompletionMetadataKey: helpersv1.Full, + }, + }, + Spec: v1beta1.ContainerProfileSpec{Capabilities: []string{"NET_BIND_SERVICE"}}, + } + // cp: nil (learning suppressed for user-defined); userCP served at "override". + client := &fakeProfileClient{cp: nil, cpErr: apierrors.NewNotFound(schema.GroupResource{}, "x"), userCP: userCP} + c, k8s := newTestCache(t, client) + + id := "container-udcp" + primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") + + ev := eventContainer(id) + ev.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "override"} + require.NoError(t, c.addContainer(ev, context.Background())) + + entry, ok := c.entries.Load(id) + require.True(t, ok) + assert.NotNil(t, entry.Projected, "user-defined CP path must produce a projected profile") + require.NotNil(t, entry.UserCPRef, "UserCPRef must be recorded for refresh") + assert.Equal(t, "override", entry.UserCPRef.Name) + assert.Equal(t, "uc1", entry.UserCPRV) + assert.Nil(t, entry.UserAPRef, "legacy AP ref must not be set on the new path") + assert.Nil(t, entry.UserNNRef, "legacy NN ref must not be set on the new path") +} + // TestDeleteContainer_LockAndCleanup verifies that deleteContainer removes // the entry and releases the per-container lock so a later Add re-uses a // fresh mutex. diff --git a/pkg/objectcache/containerprofilecache/reconciler.go b/pkg/objectcache/containerprofilecache/reconciler.go index 0af5c8ee4..34c842d99 100644 --- a/pkg/objectcache/containerprofilecache/reconciler.go +++ b/pkg/objectcache/containerprofilecache/reconciler.go @@ -403,6 +403,28 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri } } + // Re-fetch the user-defined ContainerProfile (migrated "new way") when the + // entry was built from one. It is the authoritative base; a transient fetch + // error keeps the entry as-is. + var userDefinedCP *v1beta1.ContainerProfile + if e.UserCPRef != nil { + var userCPErr error + _ = c.refreshRPC(ctx, func(rctx context.Context) error { + userDefinedCP, userCPErr = c.storageClient.GetContainerProfile(rctx, e.UserCPRef.Namespace, e.UserCPRef.Name) + return userCPErr + }) + if userCPErr != nil && e.UserCPRV != "" { + logger.L().Debug("refreshOneEntry: user-defined CP fetch failed; keeping cached entry", + helpers.String("containerID", id), + helpers.String("name", e.UserCPRef.Name), + helpers.Error(userCPErr)) + return + } + if userCPErr != nil { + userDefinedCP = nil + } + } + // Fast-skip when nothing changed. We match "absent" (nil) with empty RV: // this avoids spurious rebuilds when an optional source is still missing, // as long as it was also missing at the last build. Also skip when the @@ -413,6 +435,7 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri currentSpecHash = spec.Hash } if rvsMatchCP(cp, e.RV) && + rvsMatchCP(userDefinedCP, e.UserCPRV) && rvsMatchAP(userManagedAP, e.UserManagedAPRV) && rvsMatchNN(userManagedNN, e.UserManagedNNRV) && rvsMatchAP(userAP, e.UserAPRV) && @@ -421,7 +444,7 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri return } - c.rebuildEntryFromSources(id, e, cp, userManagedAP, userManagedNN, userAP, userNN) + c.rebuildEntryFromSources(id, e, cp, userDefinedCP, userManagedAP, userManagedNN, userAP, userNN) } // rvsMatchCP, rvsMatchAP, rvsMatchNN return true when either (a) the object is @@ -456,6 +479,7 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( id string, prev *CachedContainerProfile, cp *v1beta1.ContainerProfile, + userDefinedCP *v1beta1.ContainerProfile, userManagedAP *v1beta1.ApplicationProfile, userManagedNN *v1beta1.NetworkNeighborhood, userAP *v1beta1.ApplicationProfile, @@ -474,10 +498,17 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( podUID = string(pod.UID) } - // When the consolidated CP is absent but we still have user-managed / - // user-defined overlays to project, synthesize an empty base so - // downstream state display is sensible. + // A user-defined ContainerProfile ("new way") is the authoritative base, + // replacing the learned CP for this container. cp (the learned CP) stays + // separate so RV bookkeeping tracks each source independently. effectiveCP := cp + if userDefinedCP != nil { + effectiveCP = userDefinedCP + } + + // When neither a learned nor a user-defined CP is available but we still + // have user-managed overlays to project, synthesize an empty base so + // downstream state display is sensible. if effectiveCP == nil { syntheticName := prev.WorkloadName if syntheticName == "" { @@ -543,6 +574,12 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( UserManagedNNRV: rvOfNN(userManagedNN), UserAPRV: rvOfAP(userAP), UserNNRV: rvOfNN(userNN), + UserCPRV: rvOfCP(userDefinedCP), + } + if userDefinedCP != nil { + newEntry.UserCPRef = &namespacedName{Namespace: userDefinedCP.Namespace, Name: userDefinedCP.Name} + } else if prev.UserCPRef != nil { + newEntry.UserCPRef = prev.UserCPRef } if userAP != nil { newEntry.UserAPRef = &namespacedName{Namespace: userAP.Namespace, Name: userAP.Name} diff --git a/pkg/objectcache/containerprofilecache/usercp.go b/pkg/objectcache/containerprofilecache/usercp.go index 51f52366a..9b95b1629 100644 --- a/pkg/objectcache/containerprofilecache/usercp.go +++ b/pkg/objectcache/containerprofilecache/usercp.go @@ -1,11 +1,21 @@ package containerprofilecache import ( + helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// isUserDefinedContainerProfile reports whether a fetched ContainerProfile is a +// user-authored profile (the migrated "new way"), identified by the +// managed-by: User annotation — the same marker the legacy user-authored AP/NN +// carry. A learned ContainerProfile that happens to share the label-referenced +// name is deliberately NOT treated as user-defined. +func isUserDefinedContainerProfile(cp *v1beta1.ContainerProfile) bool { + return cp != nil && cp.Annotations[helpersv1.ManagedByMetadataKey] == helpersv1.ManagedByUserValue +} + // ConvertUserProfilesToContainerProfile builds the single user-defined // ContainerProfile equivalent to a legacy user-authored ApplicationProfile + // NetworkNeighborhood pair, for one container. From 1be5edeba65dc655e691d9ad837a54d0b55428d9 Mon Sep 17 00:00:00 2001 From: Entlein Date: Fri, 24 Jul 2026 11:41:46 +0200 Subject: [PATCH 03/29] test(migrate): port Test_28 to a single user-defined ContainerProfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test_28 now creates one ContainerProfile (managed-by: User) carrying the merged exec/syscall + egress/selector surfaces, instead of a separate user-authored ApplicationProfile + NetworkNeighborhood pair — exercising the migrated read-path end to end. Assertions unchanged. Signed-off-by: entlein --- tests/component_test.go | 84 ++++++++++++++++------------------------- 1 file changed, 33 insertions(+), 51 deletions(-) diff --git a/tests/component_test.go b/tests/component_test.go index 1069e7e45..c5c4cef7c 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -2764,31 +2764,12 @@ func Test_28_UserDefinedNetworkNeighborhood(t *testing.T) { // AP and NN MUST therefore share that single name. const overlayName = "curl-28-overlay" - ap := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: overlayName, - Namespace: ns.Name, - }, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{ - { - Name: "curl", - Execs: []v1beta1.ExecCalls{ - {Path: "/bin/sleep"}, - {Path: "/usr/bin/curl"}, - {Path: "/usr/bin/nslookup"}, - {Path: "/usr/bin/wget"}, - }, - Syscalls: []string{"socket", "connect", "sendto", "recvfrom", "read", "write", "close", "openat", "mmap", "mprotect", "munmap", "fcntl", "ioctl", "poll", "epoll_create1", "epoll_ctl", "epoll_wait", "bind", "listen", "accept4", "getsockopt", "setsockopt", "getsockname", "getpid", "fstat", "rt_sigaction", "rt_sigprocmask", "writev"}, - }, - }, - }, - } - _, err := storageClient.ApplicationProfiles(ns.Name).Create( - context.Background(), ap, metav1.CreateOptions{}) - require.NoError(t, err, "create AP") - - nn := &v1beta1.NetworkNeighborhood{ + // Migration (#862): the user authors ONE ContainerProfile (managed-by: + // User) instead of a separate ApplicationProfile + NetworkNeighborhood. + // The kubescape.io/user-defined-profile pod label names this CP; node-agent + // uses it directly as the authoritative base. Its Spec merges the former AP + // surfaces (execs, syscalls) with the former NN surfaces (egress, selector). + cp := &v1beta1.ContainerProfile{ ObjectMeta: metav1.ObjectMeta{ Name: overlayName, Namespace: ns.Name, @@ -2798,45 +2779,46 @@ func Test_28_UserDefinedNetworkNeighborhood(t *testing.T) { helpersv1.CompletionMetadataKey: helpersv1.Full, }, Labels: map[string]string{ - helpersv1.ApiGroupMetadataKey: "apps", - helpersv1.ApiVersionMetadataKey: "v1", - helpersv1.RelatedKindMetadataKey: "Deployment", - helpersv1.RelatedNameMetadataKey: "curl-28", - helpersv1.RelatedNamespaceMetadataKey: ns.Name, + helpersv1.ApiGroupMetadataKey: "apps", + helpersv1.ApiVersionMetadataKey: "v1", + helpersv1.RelatedKindMetadataKey: "Deployment", + helpersv1.RelatedNameMetadataKey: "curl-28", + helpersv1.RelatedNamespaceMetadataKey: ns.Name, }, }, - Spec: v1beta1.NetworkNeighborhoodSpec{ + Spec: v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/sleep"}, + {Path: "/usr/bin/curl"}, + {Path: "/usr/bin/nslookup"}, + {Path: "/usr/bin/wget"}, + }, + Syscalls: []string{"socket", "connect", "sendto", "recvfrom", "read", "write", "close", "openat", "mmap", "mprotect", "munmap", "fcntl", "ioctl", "poll", "epoll_create1", "epoll_ctl", "epoll_wait", "bind", "listen", "accept4", "getsockopt", "setsockopt", "getsockname", "getpid", "fstat", "rt_sigaction", "rt_sigprocmask", "writev"}, LabelSelector: metav1.LabelSelector{ MatchLabels: map[string]string{"app": "curl-28"}, }, - Containers: []v1beta1.NetworkNeighborhoodContainer{ + Egress: []v1beta1.NetworkNeighbor{ { - Name: "curl", - Egress: []v1beta1.NetworkNeighbor{ - { - Identifier: "fusioncore-egress", - Type: "external", - DNS: "fusioncore.ai.", - DNSNames: []string{"fusioncore.ai."}, - IPAddress: "162.0.217.171", - Ports: []v1beta1.NetworkPort{ - {Name: "TCP-80", Protocol: "TCP", Port: ptr.To(int32(80))}, - }, - }, + Identifier: "fusioncore-egress", + Type: "external", + DNS: "fusioncore.ai.", + DNSNames: []string{"fusioncore.ai."}, + IPAddress: "162.0.217.171", + Ports: []v1beta1.NetworkPort{ + {Name: "TCP-80", Protocol: "TCP", Port: ptr.To(int32(80))}, }, }, }, }, } - _, err = storageClient.NetworkNeighborhoods(ns.Name).Create( - context.Background(), nn, metav1.CreateOptions{}) - require.NoError(t, err, "create NN") + _, err := storageClient.ContainerProfiles(ns.Name).Create( + context.Background(), cp, metav1.CreateOptions{}) + require.NoError(t, err, "create user-defined ContainerProfile") require.Eventually(t, func() bool { - _, apErr := storageClient.ApplicationProfiles(ns.Name).Get(context.Background(), overlayName, v1.GetOptions{}) - _, nnErr := storageClient.NetworkNeighborhoods(ns.Name).Get(context.Background(), overlayName, v1.GetOptions{}) - return apErr == nil && nnErr == nil - }, 30*time.Second, 1*time.Second, "AP+NN must be in storage before pod deploy") + _, cpErr := storageClient.ContainerProfiles(ns.Name).Get(context.Background(), overlayName, v1.GetOptions{}) + return cpErr == nil + }, 30*time.Second, 1*time.Second, "user-defined CP must be in storage before pod deploy") wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-user-defined-deployment.yaml")) From d9108c1a0ef4c2cccc94f498c1e1d501e6d51453 Mon Sep 17 00:00:00 2001 From: Entlein Date: Fri, 24 Jul 2026 11:48:39 +0200 Subject: [PATCH 04/29] test(migrate): benchmark user-defined CP projection vs legacy AP+NN overlay The new path (converted CP as base, no overlay) is ~35% faster and allocates ~27% less than the legacy AP+NN overlay per projection, since it skips the two-object merge. Signed-off-by: entlein --- .../usercp_bench_test.go | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 pkg/objectcache/containerprofilecache/usercp_bench_test.go diff --git a/pkg/objectcache/containerprofilecache/usercp_bench_test.go b/pkg/objectcache/containerprofilecache/usercp_bench_test.go new file mode 100644 index 000000000..92a03d353 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/usercp_bench_test.go @@ -0,0 +1,42 @@ +package containerprofilecache + +import ( + "testing" + + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// BenchmarkProjection_Legacy vs BenchmarkProjection_UserDefinedCP measure the +// per-container projection cost of the two paths on the full user-defined shape +// (opens/exec wildcards, endpoints, egress/ingress + selector): +// +// Legacy: overlay user AP + user NN onto an empty base, then Apply. +// New: the converted ContainerProfile is the base — Apply with no overlay. +// +// The new path skips the two-object merge (projectUserProfiles fast-returns a +// DeepCopy when both user inputs are nil), so it does strictly less work per +// projection — which the reconciler runs on every changed tick. +func BenchmarkProjection_Legacy(b *testing.B) { + tc := oracleCases()[0] // full_ap_and_nn + pod := podWith("curl") + base := &v1beta1.ContainerProfile{ObjectMeta: metav1.ObjectMeta{Name: "base", Namespace: "demo"}} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + legacyCP, _ := projectUserProfiles(base, tc.ap, tc.nn, pod, tc.containerName) + _ = Apply(nil, legacyCP, nil) + } +} + +func BenchmarkProjection_UserDefinedCP(b *testing.B) { + tc := oracleCases()[0] // full_ap_and_nn + pod := podWith("curl") + userCP := ConvertUserProfilesToContainerProfile(tc.ap, tc.nn, pod, tc.containerName) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + newCP, _ := projectUserProfiles(userCP, nil, nil, pod, tc.containerName) + _ = Apply(nil, newCP, nil) + } +} From a251069f07dd0761d01b2821e43c951ec81e4513 Mon Sep 17 00:00:00 2001 From: Entlein Date: Fri, 24 Jul 2026 12:38:21 +0200 Subject: [PATCH 05/29] test(migrate): port Test_27/32/33 to user-defined ContainerProfiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test_27 (opens R0002, both the regex and the curl-wildcard profile sites), Test_32 (R0040 argv wildcards), and Test_33 (opens wildcard anchoring — the previously-fixed one) now each create a single user-defined ContainerProfile (managed-by: User) carrying the merged exec/open/syscall (+ egress/selector for 32) surfaces, replacing the legacy ApplicationProfile + NetworkNeighborhood pair. Assertions unchanged. Test_28 was ported in an earlier commit. Signed-off-by: entlein --- tests/component_test.go | 343 ++++++++++++++++++---------------------- 1 file changed, 154 insertions(+), 189 deletions(-) diff --git a/tests/component_test.go b/tests/component_test.go index c5c4cef7c..cb69dd402 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -1656,38 +1656,36 @@ func Test_27_ApplicationProfileOpens(t *testing.T) { t.Helper() ns := testutils.NewRandomNamespace() - profile := &v1beta1.ApplicationProfile{ + profile := &v1beta1.ContainerProfile{ ObjectMeta: metav1.ObjectMeta{ Name: profileName, Namespace: ns.Name, + Annotations: map[string]string{ + helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, + helpersv1.StatusMetadataKey: helpersv1.Completed, + helpersv1.CompletionMetadataKey: helpersv1.Full, + }, }, - Spec: v1beta1.ApplicationProfileSpec{ + Spec: v1beta1.ContainerProfileSpec{ Architectures: []string{"amd64"}, - Containers: []v1beta1.ApplicationProfileContainer{ - { - Name: "nginx", - Execs: []v1beta1.ExecCalls{ - {Path: "/bin/cat", Args: []string{"/bin/cat"}}, - }, - Opens: opens, - }, + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/cat", Args: []string{"/bin/cat"}}, }, + Opens: opens, }, } k8sClient := k8sinterface.NewKubernetesApi() storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - _, err := storageClient.ApplicationProfiles(ns.Name).Create( + _, err := storageClient.ContainerProfiles(ns.Name).Create( context.Background(), profile, metav1.CreateOptions{}) - require.NoError(t, err, "create user-defined profile %q in ns %s", profileName, ns.Name) + require.NoError(t, err, "create user-defined ContainerProfile %q in ns %s", profileName, ns.Name) - // Poll until the profile is retrievable from storage before deploying. - // Node-agent does a single fetch on container start with no retry. require.Eventually(t, func() bool { - _, apErr := storageClient.ApplicationProfiles(ns.Name).Get( + _, cpErr := storageClient.ContainerProfiles(ns.Name).Get( context.Background(), profileName, v1.GetOptions{}) - return apErr == nil - }, 30*time.Second, 1*time.Second, "AP must be retrievable from storage before deploying the pod") + return cpErr == nil + }, 30*time.Second, 1*time.Second, "CP must be retrievable from storage before deploying the pod") wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-user-profile-deployment.yaml")) @@ -1866,75 +1864,74 @@ func Test_27_ApplicationProfileOpens(t *testing.T) { wildcardProfileName := "fusioncore-profile-wildcards" // Create the profile matching known-application-profile-wildcards.yaml. - profile := &v1beta1.ApplicationProfile{ + profile := &v1beta1.ContainerProfile{ ObjectMeta: metav1.ObjectMeta{ Name: wildcardProfileName, Namespace: ns.Name, + Annotations: map[string]string{ + helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, + helpersv1.StatusMetadataKey: helpersv1.Completed, + helpersv1.CompletionMetadataKey: helpersv1.Full, + }, }, - Spec: v1beta1.ApplicationProfileSpec{ + Spec: v1beta1.ContainerProfileSpec{ Architectures: []string{"amd64"}, - Containers: []v1beta1.ApplicationProfileContainer{ - { - Name: "curl", - ImageID: "docker.io/curlimages/curl@sha256:08e466006f0860e54fc299378de998935333e0e130a15f6f98482e9f8dab3058", - ImageTag: "docker.io/curlimages/curl:8.5.0", - Capabilities: []string{ - "CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_DAC_READ_SEARCH", - "CAP_SETGID", "CAP_SETPCAP", "CAP_SETUID", "CAP_SYS_ADMIN", - }, - Execs: []v1beta1.ExecCalls{ - {Path: "/bin/sleep", Args: []string{"/bin/sleep", "infinity"}}, - {Path: "/bin/cat", Args: []string{"/bin/cat"}}, - {Path: "/usr/bin/curl", Args: []string{"/usr/bin/curl", "-sm2", "fusioncore.ai"}}, - }, - Opens: []v1beta1.OpenCalls{ - {Path: "/etc/*", Flags: []string{"O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"}}, - {Path: "/etc/ssl/openssl.cnf", Flags: []string{"O_RDONLY", "O_LARGEFILE"}}, - {Path: "/home/*", Flags: []string{"O_RDONLY", "O_LARGEFILE"}}, - {Path: "/lib/*", Flags: []string{"O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"}}, - {Path: "/usr/lib/*", Flags: []string{"O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"}}, - {Path: "/usr/local/lib/*", Flags: []string{"O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"}}, - {Path: "/proc/*/cgroup", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, - {Path: "/proc/*/kernel/cap_last_cap", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, - {Path: "/proc/*/mountinfo", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, - {Path: "/proc/*/task/*/fd", Flags: []string{"O_RDONLY", "O_DIRECTORY", "O_CLOEXEC"}}, - {Path: "/sys/fs/cgroup/cpu.max", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, - {Path: "/sys/kernel/mm/transparent_hugepage/hpage_pmd_size", Flags: []string{"O_RDONLY"}}, - {Path: "/7/setgroups", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, - {Path: "/runc", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, - }, - Syscalls: []string{ - "arch_prctl", "bind", "brk", "capget", "capset", "chdir", - "clone", "close", "close_range", "connect", "epoll_ctl", - "epoll_pwait", "execve", "exit", "exit_group", "faccessat2", - "fchown", "fcntl", "fstat", "fstatfs", "futex", "getcwd", - "getdents64", "getegid", "geteuid", "getgid", "getpeername", - "getppid", "getsockname", "getsockopt", "gettid", "getuid", - "ioctl", "membarrier", "mmap", "mprotect", "munmap", - "nanosleep", "newfstatat", "open", "openat", "openat2", - "pipe", "poll", "prctl", "read", "recvfrom", "recvmsg", - "rt_sigaction", "rt_sigprocmask", "rt_sigreturn", "sendto", - "set_tid_address", "setgid", "setgroups", "setsockopt", - "setuid", "sigaltstack", "socket", "statx", "tkill", - "unknown", "write", "writev", - }, - }, + ImageID: "docker.io/curlimages/curl@sha256:08e466006f0860e54fc299378de998935333e0e130a15f6f98482e9f8dab3058", + ImageTag: "docker.io/curlimages/curl:8.5.0", + Capabilities: []string{ + "CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_DAC_READ_SEARCH", + "CAP_SETGID", "CAP_SETPCAP", "CAP_SETUID", "CAP_SYS_ADMIN", + }, + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/sleep", Args: []string{"/bin/sleep", "infinity"}}, + {Path: "/bin/cat", Args: []string{"/bin/cat"}}, + {Path: "/usr/bin/curl", Args: []string{"/usr/bin/curl", "-sm2", "fusioncore.ai"}}, + }, + Opens: []v1beta1.OpenCalls{ + {Path: "/etc/*", Flags: []string{"O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"}}, + {Path: "/etc/ssl/openssl.cnf", Flags: []string{"O_RDONLY", "O_LARGEFILE"}}, + {Path: "/home/*", Flags: []string{"O_RDONLY", "O_LARGEFILE"}}, + {Path: "/lib/*", Flags: []string{"O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"}}, + {Path: "/usr/lib/*", Flags: []string{"O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"}}, + {Path: "/usr/local/lib/*", Flags: []string{"O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"}}, + {Path: "/proc/*/cgroup", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, + {Path: "/proc/*/kernel/cap_last_cap", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, + {Path: "/proc/*/mountinfo", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, + {Path: "/proc/*/task/*/fd", Flags: []string{"O_RDONLY", "O_DIRECTORY", "O_CLOEXEC"}}, + {Path: "/sys/fs/cgroup/cpu.max", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, + {Path: "/sys/kernel/mm/transparent_hugepage/hpage_pmd_size", Flags: []string{"O_RDONLY"}}, + {Path: "/7/setgroups", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, + {Path: "/runc", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, + }, + Syscalls: []string{ + "arch_prctl", "bind", "brk", "capget", "capset", "chdir", + "clone", "close", "close_range", "connect", "epoll_ctl", + "epoll_pwait", "execve", "exit", "exit_group", "faccessat2", + "fchown", "fcntl", "fstat", "fstatfs", "futex", "getcwd", + "getdents64", "getegid", "geteuid", "getgid", "getpeername", + "getppid", "getsockname", "getsockopt", "gettid", "getuid", + "ioctl", "membarrier", "mmap", "mprotect", "munmap", + "nanosleep", "newfstatat", "open", "openat", "openat2", + "pipe", "poll", "prctl", "read", "recvfrom", "recvmsg", + "rt_sigaction", "rt_sigprocmask", "rt_sigreturn", "sendto", + "set_tid_address", "setgid", "setgroups", "setsockopt", + "setuid", "sigaltstack", "socket", "statx", "tkill", + "unknown", "write", "writev", }, }, } k8sClient := k8sinterface.NewKubernetesApi() storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - _, err := storageClient.ApplicationProfiles(ns.Name).Create( + _, err := storageClient.ContainerProfiles(ns.Name).Create( context.Background(), profile, metav1.CreateOptions{}) - require.NoError(t, err, "create wildcard profile %q in ns %s", wildcardProfileName, ns.Name) + require.NoError(t, err, "create wildcard ContainerProfile %q in ns %s", wildcardProfileName, ns.Name) - // Poll until the profile is retrievable from storage before deploying. require.Eventually(t, func() bool { - _, apErr := storageClient.ApplicationProfiles(ns.Name).Get( + _, cpErr := storageClient.ContainerProfiles(ns.Name).Get( context.Background(), wildcardProfileName, v1.GetOptions{}) - return apErr == nil - }, 30*time.Second, 1*time.Second, "AP must be retrievable before deploying the pod") + return cpErr == nil + }, 30*time.Second, 1*time.Second, "CP must be retrievable before deploying the pod") wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/curl-user-profile-wildcards-deployment.yaml")) @@ -2032,42 +2029,40 @@ func Test_33_AnalyzeOpensWildcardAnchoring(t *testing.T) { t.Helper() ns := testutils.NewRandomNamespace() - profile := &v1beta1.ApplicationProfile{ + profile := &v1beta1.ContainerProfile{ ObjectMeta: metav1.ObjectMeta{ Name: profileName, Namespace: ns.Name, + Annotations: map[string]string{ + helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, + helpersv1.StatusMetadataKey: helpersv1.Completed, + helpersv1.CompletionMetadataKey: helpersv1.Full, + }, }, - Spec: v1beta1.ApplicationProfileSpec{ + Spec: v1beta1.ContainerProfileSpec{ Architectures: []string{"amd64"}, - Containers: []v1beta1.ApplicationProfileContainer{ - { - Name: "nginx", - Execs: []v1beta1.ExecCalls{ - {Path: "/bin/cat", Args: []string{"/bin/cat"}}, - }, - Opens: []v1beta1.OpenCalls{ - {Path: profilePath, Flags: []string{"O_RDONLY"}}, - // Dynamic linker fires this on every exec — keep - // it whitelisted so it doesn't drown out the - // signal we actually care about. - {Path: "/etc/ld.so.cache", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, - }, - }, + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/cat", Args: []string{"/bin/cat"}}, + }, + Opens: []v1beta1.OpenCalls{ + {Path: profilePath, Flags: []string{"O_RDONLY"}}, + // Dynamic linker fires this on every exec — keep it whitelisted. + {Path: "/etc/ld.so.cache", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, }, }, } k8sClient := k8sinterface.NewKubernetesApi() storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - _, err := storageClient.ApplicationProfiles(ns.Name).Create( + _, err := storageClient.ContainerProfiles(ns.Name).Create( context.Background(), profile, metav1.CreateOptions{}) - require.NoError(t, err, "create user-defined profile %q in ns %s", profileName, ns.Name) + require.NoError(t, err, "create user-defined ContainerProfile %q in ns %s", profileName, ns.Name) require.Eventually(t, func() bool { - _, apErr := storageClient.ApplicationProfiles(ns.Name).Get( + _, cpErr := storageClient.ContainerProfiles(ns.Name).Get( context.Background(), profileName, v1.GetOptions{}) - return apErr == nil - }, 30*time.Second, 1*time.Second, "AP must be retrievable from storage before deploying the pod") + return cpErr == nil + }, 30*time.Second, 1*time.Second, "CP must be retrievable from storage before deploying the pod") wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-user-profile-deployment.yaml")) @@ -2260,90 +2255,7 @@ func Test_32_UnexpectedProcessArguments(t *testing.T) { k8sClient := k8sinterface.NewKubernetesApi() storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - ap := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: overlayName, - Namespace: ns.Name, - }, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{ - { - Name: "curl", - Execs: []v1beta1.ExecCalls{ - // Profile shape: Path AND Args[0] both use the - // absolute-path symlink form (/bin/sh, - // /usr/bin/nslookup, ...). With the symlink- - // faithful precedence in parse.get_exec_path - // (fix 9a6eb359), the rule queries the - // symlink-as-invoked path that the kernel - // preserves in argv[0]. Recording-side - // resolveExecPath uses the same precedence so - // auto-learned profiles get the same key. - // - // Storage's CompareExecArgs is a strict - // positional compare — no special argv[0] - // normalisation — so Args[0] MUST be the same - // string as runtime argv[0]. For - // kubectl-exec'd processes that's the absolute - // path the caller invoked. - // - // pod startup: sleep - {Path: "/bin/sleep", Args: []string{"/bin/sleep", dynamicpathdetector.ExecArgsWildcard}}, - // sh -c - {Path: "/bin/sh", Args: []string{"/bin/sh", "-c", dynamicpathdetector.ExecArgsWildcard}}, - // echo hello - {Path: "/bin/echo", Args: []string{"/bin/echo", "hello", dynamicpathdetector.ExecArgsWildcard}}, - // curl -s - {Path: "/usr/bin/curl", Args: []string{"/usr/bin/curl", "-s", dynamicpathdetector.DynamicIdentifier}}, - // curl -s file:///etc/hosts file:///etc/hostname - // — a ⋯ in a NON-trailing position: it matches exactly - // one arg, and the LITERAL args after it must still - // anchor. (file:// URLs are used as the post-⋯ literals - // so curl reads local files and exits 0.) - {Path: "/usr/bin/curl", Args: []string{"/usr/bin/curl", "-s", dynamicpathdetector.DynamicIdentifier, "file:///etc/hosts", "file:///etc/hostname"}}, - // Busybox-symlink mirror entries. The curl image's - // /bin/{sleep,sh,echo} are symlinks to /bin/busybox, - // so the kernel's resolved /proc//exe — what - // IG captures as event.exepath — is /bin/busybox. - // parse.get_exec_path(args, comm, exepath) returns - // exepath first, so ap.was_executed queries arrive - // at the rule keyed on /bin/busybox, not the - // symlink form. Without a matching profile entry - // keyed on /bin/busybox, R0001 fires before R0040 - // ever evaluates and the test trips its R0001 - // precondition. The symlink-form entries above are - // retained for environments where exepath resolves - // to the as-invoked path (non-symlinked utilities; - // fexecve / argv[0] fallback in resolveExecPath). - {Path: "/bin/busybox", Args: []string{"/bin/sleep", dynamicpathdetector.ExecArgsWildcard}}, - {Path: "/bin/busybox", Args: []string{"/bin/sh", "-c", dynamicpathdetector.ExecArgsWildcard}}, - {Path: "/bin/busybox", Args: []string{"/bin/echo", "hello", dynamicpathdetector.ExecArgsWildcard}}, - // Literal "*" arg: echo invoked with a GENUINE literal "*" - // (e.g. an unexpanded glob), recorded verbatim. Under the - // symbol contract a "*" in argv is DATA, not a wildcard, so - // this entry matches ONLY `echo star *` and must NOT broaden - // to `echo star `. CT-level mirror of storage's - // TestAP_LiteralStarVsDynamic. (busybox + symlink forms.) - {Path: "/bin/echo", Args: []string{"/bin/echo", "star", "*"}}, - {Path: "/bin/busybox", Args: []string{"/bin/echo", "star", "*"}}, - }, - Syscalls: []string{"socket", "connect", "sendto", "recvfrom", "read", "write", "close", "openat", "mmap", "mprotect", "munmap", "fcntl", "ioctl", "poll", "epoll_create1", "epoll_ctl", "epoll_wait", "bind", "listen", "accept4", "getsockopt", "setsockopt", "getsockname", "getpid", "fstat", "rt_sigaction", "rt_sigprocmask", "writev", "execve"}, - }, - }, - }, - } - _, err := storageClient.ApplicationProfiles(ns.Name).Create( - context.Background(), ap, metav1.CreateOptions{}) - require.NoError(t, err, "create AP") - - // User-supplied SBOB pattern (mirrors Test_28): the pod carries BOTH - // kubescape.io/user-defined-profile and kubescape.io/user-defined-network. - // Node-agent uses the single overlay name as the lookup key for BOTH - // the user ApplicationProfile and the user NetworkNeighborhood, so the - // NN must exist under the same name and be created before the pod. - // User-authored objects carry managed-by=User + a terminal - // status/completion and the workload-binding labels. - nn := &v1beta1.NetworkNeighborhood{ + cp := &v1beta1.ContainerProfile{ ObjectMeta: metav1.ObjectMeta{ Name: overlayName, Namespace: ns.Name, @@ -2360,26 +2272,79 @@ func Test_32_UnexpectedProcessArguments(t *testing.T) { helpersv1.RelatedNamespaceMetadataKey: ns.Name, }, }, - Spec: v1beta1.NetworkNeighborhoodSpec{ + Spec: v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{ + // Profile shape: Path AND Args[0] both use the + // absolute-path symlink form (/bin/sh, + // /usr/bin/nslookup, ...). With the symlink- + // faithful precedence in parse.get_exec_path + // (fix 9a6eb359), the rule queries the + // symlink-as-invoked path that the kernel + // preserves in argv[0]. Recording-side + // resolveExecPath uses the same precedence so + // auto-learned profiles get the same key. + // + // Storage's CompareExecArgs is a strict + // positional compare — no special argv[0] + // normalisation — so Args[0] MUST be the same + // string as runtime argv[0]. For + // kubectl-exec'd processes that's the absolute + // path the caller invoked. + // + // pod startup: sleep + {Path: "/bin/sleep", Args: []string{"/bin/sleep", dynamicpathdetector.ExecArgsWildcard}}, + // sh -c + {Path: "/bin/sh", Args: []string{"/bin/sh", "-c", dynamicpathdetector.ExecArgsWildcard}}, + // echo hello + {Path: "/bin/echo", Args: []string{"/bin/echo", "hello", dynamicpathdetector.ExecArgsWildcard}}, + // curl -s + {Path: "/usr/bin/curl", Args: []string{"/usr/bin/curl", "-s", dynamicpathdetector.DynamicIdentifier}}, + // curl -s file:///etc/hosts file:///etc/hostname + // — a ⋯ in a NON-trailing position: it matches exactly + // one arg, and the LITERAL args after it must still + // anchor. (file:// URLs are used as the post-⋯ literals + // so curl reads local files and exits 0.) + {Path: "/usr/bin/curl", Args: []string{"/usr/bin/curl", "-s", dynamicpathdetector.DynamicIdentifier, "file:///etc/hosts", "file:///etc/hostname"}}, + // Busybox-symlink mirror entries. The curl image's + // /bin/{sleep,sh,echo} are symlinks to /bin/busybox, + // so the kernel's resolved /proc//exe — what + // IG captures as event.exepath — is /bin/busybox. + // parse.get_exec_path(args, comm, exepath) returns + // exepath first, so ap.was_executed queries arrive + // at the rule keyed on /bin/busybox, not the + // symlink form. Without a matching profile entry + // keyed on /bin/busybox, R0001 fires before R0040 + // ever evaluates and the test trips its R0001 + // precondition. The symlink-form entries above are + // retained for environments where exepath resolves + // to the as-invoked path (non-symlinked utilities; + // fexecve / argv[0] fallback in resolveExecPath). + {Path: "/bin/busybox", Args: []string{"/bin/sleep", dynamicpathdetector.ExecArgsWildcard}}, + {Path: "/bin/busybox", Args: []string{"/bin/sh", "-c", dynamicpathdetector.ExecArgsWildcard}}, + {Path: "/bin/busybox", Args: []string{"/bin/echo", "hello", dynamicpathdetector.ExecArgsWildcard}}, + // Literal "*" arg: echo invoked with a GENUINE literal "*" + // (e.g. an unexpanded glob), recorded verbatim. Under the + // symbol contract a "*" in argv is DATA, not a wildcard, so + // this entry matches ONLY `echo star *` and must NOT broaden + // to `echo star `. CT-level mirror of storage's + // TestAP_LiteralStarVsDynamic. (busybox + symlink forms.) + {Path: "/bin/echo", Args: []string{"/bin/echo", "star", "*"}}, + {Path: "/bin/busybox", Args: []string{"/bin/echo", "star", "*"}}, + }, + Syscalls: []string{"socket", "connect", "sendto", "recvfrom", "read", "write", "close", "openat", "mmap", "mprotect", "munmap", "fcntl", "ioctl", "poll", "epoll_create1", "epoll_ctl", "epoll_wait", "bind", "listen", "accept4", "getsockopt", "setsockopt", "getsockname", "getpid", "fstat", "rt_sigaction", "rt_sigprocmask", "writev", "execve"}, LabelSelector: metav1.LabelSelector{ MatchLabels: map[string]string{"app": "curl-32"}, }, - Containers: []v1beta1.NetworkNeighborhoodContainer{ - {Name: "curl"}, - }, }, } - _, err = storageClient.NetworkNeighborhoods(ns.Name).Create( - context.Background(), nn, metav1.CreateOptions{}) - require.NoError(t, err, "create NN") + _, err := storageClient.ContainerProfiles(ns.Name).Create( + context.Background(), cp, metav1.CreateOptions{}) + require.NoError(t, err, "create user-defined ContainerProfile") require.Eventually(t, func() bool { - _, apErr := storageClient.ApplicationProfiles(ns.Name).Get( - context.Background(), overlayName, v1.GetOptions{}) - _, nnErr := storageClient.NetworkNeighborhoods(ns.Name).Get( - context.Background(), overlayName, v1.GetOptions{}) - return apErr == nil && nnErr == nil - }, 30*time.Second, 1*time.Second, "AP+NN must be in storage before pod deploy") + _, cpErr := storageClient.ContainerProfiles(ns.Name).Get(context.Background(), overlayName, v1.GetOptions{}) + return cpErr == nil + }, 30*time.Second, 1*time.Second, "user-defined CP must be in storage before pod deploy") wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/curl-exec-arg-wildcards-deployment.yaml")) From 903443da54f619b3d9718f8b937bdb016624b5b1 Mon Sep 17 00:00:00 2001 From: Entlein Date: Thu, 2 Jul 2026 11:13:03 +0200 Subject: [PATCH 06/29] fix(projection): classify '*' path entries as Patterns, not Values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit containsDynamicSegment recognised only the one-segment DynamicIdentifier ('⋯'), so a path-surface opens entry bearing the zero-or-more WildcardIdentifier ('*') — e.g. '/etc/ssl/*' — was routed to Values as if it were a literal. was_path_opened tolerates this (Values and Patterns are both matched via CompareDynamic), but it is wrong for any consumer that treats Values as exact membership, and it drops '*'-only entries a rule needs when spec.All is false and no prefix/suffix matcher retains them. Recognise both wildcard markers. Regression test pins '/etc/ssl/*' -> Patterns. Pre-existing in upstream (identical containsDynamicSegment). Signed-off-by: Entlein Signed-off-by: entlein --- .../containerprofilecache/projection_apply.go | 15 ++++++++-- ...projection_wildcard_classification_test.go | 29 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 pkg/objectcache/containerprofilecache/projection_wildcard_classification_test.go diff --git a/pkg/objectcache/containerprofilecache/projection_apply.go b/pkg/objectcache/containerprofilecache/projection_apply.go index c0d7e129f..711ac7311 100644 --- a/pkg/objectcache/containerprofilecache/projection_apply.go +++ b/pkg/objectcache/containerprofilecache/projection_apply.go @@ -143,10 +143,19 @@ func projectField(spec objectcache.FieldSpec, rawEntries []string, isPathSurface return pf } -// containsDynamicSegment reports whether e contains the dynamic-path marker. -// Always references the constant from the storage package; never hardcodes the glyph. +// containsDynamicSegment reports whether e contains a wildcard-path marker — +// either the one-segment DynamicIdentifier ("⋯") OR the zero-or-more +// WildcardIdentifier ("*"). On path surfaces both are dynamic and must be +// routed to Patterns, never treated as literal Values. Omitting "*" here +// silently misclassifies entries like "/etc/ssl/*" as literals; that happens +// to be harmless for was_path_opened (both Values and Patterns are matched via +// CompareDynamic), but it is wrong for any consumer that treats Values as exact +// membership and it drops "*"-only entries a rule needs when spec.All is false +// and no prefix/suffix matcher retains them. Always reference the storage +// constants; never hardcode the glyphs. func containsDynamicSegment(e string) bool { - return strings.Contains(e, dynamicpathdetector.DynamicIdentifier) + return strings.Contains(e, dynamicpathdetector.DynamicIdentifier) || + strings.Contains(e, dynamicpathdetector.WildcardIdentifier) } // --- Field extractors --- diff --git a/pkg/objectcache/containerprofilecache/projection_wildcard_classification_test.go b/pkg/objectcache/containerprofilecache/projection_wildcard_classification_test.go new file mode 100644 index 000000000..b3d96f3d1 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/projection_wildcard_classification_test.go @@ -0,0 +1,29 @@ +package containerprofilecache + +import ( + "testing" + + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestProjectField_StarPathRoutesToPatterns pins that a path-surface opens +// entry containing the "*" WildcardIdentifier is classified as a Pattern, +// not a literal Value. Regression guard: containsDynamicSegment previously +// recognised only "⋯", silently routing "/etc/ssl/*" into Values. +func TestProjectField_StarPathRoutesToPatterns(t *testing.T) { + cp := &v1beta1.ContainerProfile{ + Spec: v1beta1.ContainerProfileSpec{ + Opens: []v1beta1.OpenCalls{{Path: "/etc/ssl/*"}, {Path: "/etc/ld.so.cache"}}, + }, + } + pcp := Apply(nil, cp, nil) // nil spec => pass-through (All=true) + + require.Contains(t, pcp.Opens.Patterns, "/etc/ssl/*", + "a '*'-bearing path entry must be a Pattern") + _, inValues := pcp.Opens.Values["/etc/ssl/*"] + assert.False(t, inValues, "'*'-bearing path entry must NOT be a literal Value") + _, cacheInValues := pcp.Opens.Values["/etc/ld.so.cache"] + assert.True(t, cacheInValues, "a literal path entry stays a Value") +} From 8c82261bbc4378611380ee3ef3afea288b291c38 Mon Sep 17 00:00:00 2001 From: Entlein Date: Fri, 24 Jul 2026 14:19:46 +0200 Subject: [PATCH 07/29] test(migrate): Test_33 must enable R0002 file-access monitoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test_33's anchoring subtests assert R0002 alerts, but the rule's file-access monitoring is opt-in (monitored prefixes incl. /etc/). Test_33 never applied r0002-files-access-enabled.yaml (Test_27 does via enableR0002ForTest), so R0002 never evaluated the opens and every 'expect alert' case silently passed as a no-alert — invisible because Test_33 had never run in CI. Enable it like Test_27. Verified on a live cluster: /etc/ssl/* correctly alerts on the bare parent /etc/ssl and stays silent on the child /etc/ssl/openssl.cnf. Signed-off-by: entlein --- tests/component_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/component_test.go b/tests/component_test.go index cb69dd402..65f428a2b 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -1981,6 +1981,11 @@ func Test_27_ApplicationProfileOpens(t *testing.T) { func Test_33_AnalyzeOpensWildcardAnchoring(t *testing.T) { start := time.Now() defer tearDownTest(t, start) + // R0002 file-access monitoring is opt-in (monitored prefixes incl. /etc/); + // without this the rule never evaluates opens and every "expect alert" + // anchoring case silently passes as a no-alert. Test_27 enables it the same + // way; Test_33 was missing it (it had never run in CI to expose the gap). + defer enableR0002ForTest(t)() const ruleName = "Files Access Anomalies in container" const profileName = "nginx-regex-profile" From f2f730fbfddb6b0e0b47037031e44ccd3dda212f Mon Sep 17 00:00:00 2001 From: Entlein Date: Fri, 24 Jul 2026 19:46:59 +0200 Subject: [PATCH 08/29] feat(migrate): user-managed ContainerProfiles carry no lifecycle annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user-authored profile is authoritative and complete by definition — it should not carry the learning-lifecycle status/completion markers, nor a managed-by annotation. The pod's user-defined-profile label is what declares it user-authored (a signature, added by the signing tooling, is the integrity marker). - read-path/reconciler no longer gate on managed-by: the label-referenced CP is used as authoritative, and the entry State is forced to Completed+Full so the rule engine enforces it despite the absent completion annotation - the converter emits clean CPs (name + namespace + spec only) - fake client models an absent overlay-name fetch (drives the legacy fallback) Signed-off-by: entlein --- .../containerprofilecache.go | 12 ++++- .../containerprofilecache_test.go | 6 +++ .../containerprofilecache/reconciler.go | 7 +++ .../containerprofilecache/reconciler_test.go | 2 +- .../containerprofilecache/usercp.go | 47 +++++-------------- 5 files changed, 37 insertions(+), 37 deletions(-) diff --git a/pkg/objectcache/containerprofilecache/containerprofilecache.go b/pkg/objectcache/containerprofilecache/containerprofilecache.go index ef596aae5..b73c48d51 100644 --- a/pkg/objectcache/containerprofilecache/containerprofilecache.go +++ b/pkg/objectcache/containerprofilecache/containerprofilecache.go @@ -412,7 +412,7 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( userDefinedCP, userCPErr = c.storageClient.GetContainerProfile(rctx, ns, overlayName) return userCPErr }) - if userCPErr != nil || !isUserDefinedContainerProfile(userDefinedCP) { + if userCPErr != nil { userDefinedCP = nil var userAPErr error _ = c.refreshRPC(ctx, func(rctx context.Context) error { @@ -525,6 +525,16 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( // New way: track the user-defined CP for re-fetch; no legacy refs. entry.UserCPRef = &namespacedName{Namespace: ns, Name: overlayName} entry.UserCPRV = userDefinedCP.ResourceVersion + // A user-authored profile is authoritative and complete by + // definition — it carries no learning-lifecycle status/completion + // annotations (those are meaningless on an authored profile). Force + // the terminal state so the rule engine enforces it (rule_manager + // gates on Completed+Full). + entry.State = &objectcache.ProfileState{ + Status: helpersv1.Completed, + Completion: helpersv1.Full, + Name: userDefinedCP.Name, + } } else { if entry.UserAPRef == nil { entry.UserAPRef = &namespacedName{Namespace: ns, Name: overlayName} diff --git a/pkg/objectcache/containerprofilecache/containerprofilecache_test.go b/pkg/objectcache/containerprofilecache/containerprofilecache_test.go index cb6345b1e..613df4326 100644 --- a/pkg/objectcache/containerprofilecache/containerprofilecache_test.go +++ b/pkg/objectcache/containerprofilecache/containerprofilecache_test.go @@ -87,6 +87,12 @@ func (f *fakeProfileClient) GetContainerProfile(_ context.Context, _, name strin if f.userCP != nil && name == f.userCP.Name { return f.userCP, nil } + // The overlay label points at overlayOnly; with no user CP published at that + // name it is absent, which drives the legacy AP/NN fallback path. (The base + // CP fetch uses the derived slug, a different name, and still gets f.cp.) + if f.overlayOnly != "" && name == f.overlayOnly { + return nil, apierrors.NewNotFound(schema.GroupResource{Resource: "containerprofiles"}, name) + } return f.cp, f.cpErr } func (f *fakeProfileClient) ListApplicationProfiles(_ context.Context, _ string, _ int64, _ string) (*v1beta1.ApplicationProfileList, error) { diff --git a/pkg/objectcache/containerprofilecache/reconciler.go b/pkg/objectcache/containerprofilecache/reconciler.go index 34c842d99..cae1e7282 100644 --- a/pkg/objectcache/containerprofilecache/reconciler.go +++ b/pkg/objectcache/containerprofilecache/reconciler.go @@ -578,6 +578,13 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( } if userDefinedCP != nil { newEntry.UserCPRef = &namespacedName{Namespace: userDefinedCP.Namespace, Name: userDefinedCP.Name} + // A user-authored profile is complete by definition (no learning-lifecycle + // annotations); force the terminal state so the rule engine enforces it. + newEntry.State = &objectcache.ProfileState{ + Status: helpersv1.Completed, + Completion: helpersv1.Full, + Name: userDefinedCP.Name, + } } else if prev.UserCPRef != nil { newEntry.UserCPRef = prev.UserCPRef } diff --git a/pkg/objectcache/containerprofilecache/reconciler_test.go b/pkg/objectcache/containerprofilecache/reconciler_test.go index e76c384d6..cbbe9f269 100644 --- a/pkg/objectcache/containerprofilecache/reconciler_test.go +++ b/pkg/objectcache/containerprofilecache/reconciler_test.go @@ -995,7 +995,7 @@ func TestOverlayLabel_TransientFetchFailure_RefsRetained(t *testing.T) { }, } // Overlay fetch returns an error; the base CP is fine. - client := &fakeProfileClient{cp: cp, apErr: assertErrNotFound("override"), nnErr: assertErrNotFound("override")} + client := &fakeProfileClient{cp: cp, overlayOnly: "override", apErr: assertErrNotFound("override"), nnErr: assertErrNotFound("override")} c, k8s := newTestCache(t, client) id := "container-transient-overlay" diff --git a/pkg/objectcache/containerprofilecache/usercp.go b/pkg/objectcache/containerprofilecache/usercp.go index 9b95b1629..0e6c8a01a 100644 --- a/pkg/objectcache/containerprofilecache/usercp.go +++ b/pkg/objectcache/containerprofilecache/usercp.go @@ -1,42 +1,30 @@ package containerprofilecache import ( - helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// isUserDefinedContainerProfile reports whether a fetched ContainerProfile is a -// user-authored profile (the migrated "new way"), identified by the -// managed-by: User annotation — the same marker the legacy user-authored AP/NN -// carry. A learned ContainerProfile that happens to share the label-referenced -// name is deliberately NOT treated as user-defined. -func isUserDefinedContainerProfile(cp *v1beta1.ContainerProfile) bool { - return cp != nil && cp.Annotations[helpersv1.ManagedByMetadataKey] == helpersv1.ManagedByUserValue -} - // ConvertUserProfilesToContainerProfile builds the single user-defined // ContainerProfile equivalent to a legacy user-authored ApplicationProfile + // NetworkNeighborhood pair, for one container. // -// The result's Spec is exactly what projectUserProfiles produces when overlaying -// the AP+NN onto an empty base, so projecting this CP yields a byte-identical -// ProjectedContainerProfile to the legacy AP+NN overlay path — the migration is -// behaviour-preserving by construction (see usercp_diff_oracle_test.go). This is -// the migration artifact: authoring one ContainerProfile replaces the AP+NN pair. +// The result carries ONLY name + namespace and the merged Spec. A user-managed +// ContainerProfile has no learning-lifecycle annotations (status/completion) and +// no managed-by marker — those are meaningless on an authored profile: the pod's +// kubescape.io/user-defined-profile label is what declares it user-authored, and +// a signature (added later by the signing tooling) is the integrity marker. // -// Either userAP or userNN may be nil. Metadata (name/namespace/labels and the -// managed-by/status/completion provenance annotations) is carried from whichever -// source is present; Architectures (an AP spec-level field, not projected by -// Apply) is copied for artifact completeness. +// The Spec is exactly what projectUserProfiles produces overlaying the AP+NN onto +// an empty base, so projecting this CP yields a byte-identical +// ProjectedContainerProfile to the legacy overlay path (see the diff oracle). +// Either userAP or userNN may be nil. func ConvertUserProfilesToContainerProfile(userAP *v1beta1.ApplicationProfile, userNN *v1beta1.NetworkNeighborhood, pod *corev1.Pod, containerName string) *v1beta1.ContainerProfile { base := &v1beta1.ContainerProfile{} if meta := userProfileMeta(userAP, userNN); meta != nil { base.Name = meta.Name base.Namespace = meta.Namespace - base.Annotations = copyStringMap(meta.Annotations) - base.Labels = copyStringMap(meta.Labels) } if userAP != nil { base.Spec.Architectures = append([]string(nil), userAP.Spec.Architectures...) @@ -46,9 +34,9 @@ func ConvertUserProfilesToContainerProfile(userAP *v1beta1.ApplicationProfile, u return projected } -// userProfileMeta returns the ObjectMeta to carry onto the converted CP, -// preferring the ApplicationProfile (AP and NN share name/labels/annotations for -// a user-defined pair). +// userProfileMeta returns the source name/namespace to carry onto the converted +// CP, preferring the ApplicationProfile (AP and NN share a name for a +// user-defined pair). func userProfileMeta(userAP *v1beta1.ApplicationProfile, userNN *v1beta1.NetworkNeighborhood) *metav1.ObjectMeta { if userAP != nil { return &userAP.ObjectMeta @@ -58,14 +46,3 @@ func userProfileMeta(userAP *v1beta1.ApplicationProfile, userNN *v1beta1.Network } return nil } - -func copyStringMap(m map[string]string) map[string]string { - if len(m) == 0 { - return nil - } - out := make(map[string]string, len(m)) - for k, v := range m { - out[k] = v - } - return out -} From d1bab208462623fb3d217928607c20b41913dd6d Mon Sep 17 00:00:00 2001 From: Entlein Date: Fri, 24 Jul 2026 19:52:19 +0200 Subject: [PATCH 09/29] test(migrate): clean user-defined CPs + restore an authoring yaml example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Test_28 now loads its user-defined ContainerProfile from a real yaml (resources/containerprofile-user-defined-network.yaml) via a loader helper, so the fixture doubles as the copy-pasteable "how to author a user-defined profile" example. - The example — and all the test CPs — carry only name + spec: the nonsensical learning-lifecycle annotations (status/completion) and the managed-by marker are dropped (node-agent now treats a label-referenced CP as authoritative and forces the enforce-state itself). Signed-off-by: entlein --- tests/component_test.go | 98 +++++-------------- ...containerprofile-user-defined-network.yaml | 68 +++++++++++++ 2 files changed, 94 insertions(+), 72 deletions(-) create mode 100644 tests/resources/containerprofile-user-defined-network.yaml diff --git a/tests/component_test.go b/tests/component_test.go index 65f428a2b..cf829858b 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "fmt" + "os" "path" "reflect" "slices" @@ -30,6 +31,7 @@ import ( v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" + "sigs.k8s.io/yaml" ) func tearDownTest(t *testing.T, startTime time.Time) { @@ -1660,11 +1662,6 @@ func Test_27_ApplicationProfileOpens(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: profileName, Namespace: ns.Name, - Annotations: map[string]string{ - helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, - helpersv1.StatusMetadataKey: helpersv1.Completed, - helpersv1.CompletionMetadataKey: helpersv1.Full, - }, }, Spec: v1beta1.ContainerProfileSpec{ Architectures: []string{"amd64"}, @@ -1868,11 +1865,6 @@ func Test_27_ApplicationProfileOpens(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: wildcardProfileName, Namespace: ns.Name, - Annotations: map[string]string{ - helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, - helpersv1.StatusMetadataKey: helpersv1.Completed, - helpersv1.CompletionMetadataKey: helpersv1.Full, - }, }, Spec: v1beta1.ContainerProfileSpec{ Architectures: []string{"amd64"}, @@ -2038,11 +2030,6 @@ func Test_33_AnalyzeOpensWildcardAnchoring(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: profileName, Namespace: ns.Name, - Annotations: map[string]string{ - helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, - helpersv1.StatusMetadataKey: helpersv1.Completed, - helpersv1.CompletionMetadataKey: helpersv1.Full, - }, }, Spec: v1beta1.ContainerProfileSpec{ Architectures: []string{"amd64"}, @@ -2264,11 +2251,6 @@ func Test_32_UnexpectedProcessArguments(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: overlayName, Namespace: ns.Name, - Annotations: map[string]string{ - helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, - helpersv1.StatusMetadataKey: helpersv1.Completed, - helpersv1.CompletionMetadataKey: helpersv1.Full, - }, Labels: map[string]string{ helpersv1.ApiGroupMetadataKey: "apps", helpersv1.ApiVersionMetadataKey: "v1", @@ -2716,6 +2698,29 @@ func Test_32_UnexpectedProcessArguments(t *testing.T) { }) } +// applyUserDefinedContainerProfile reads a ContainerProfile example yaml (the +// copy-pasteable authoring example), stamps it into ns, and creates it. A +// user-managed CP carries only name + spec — the pod's user-defined-profile +// label is what binds it; no lifecycle annotations are needed. +func applyUserDefinedContainerProfile(t *testing.T, ns, resourcePath string) *v1beta1.ContainerProfile { + t.Helper() + b, err := os.ReadFile(path.Join(utils.CurrentDir(), resourcePath)) + require.NoError(t, err, "read %s", resourcePath) + var cp v1beta1.ContainerProfile + require.NoError(t, yaml.Unmarshal(b, &cp), "unmarshal %s", resourcePath) + cp.Namespace = ns + cp.ResourceVersion = "" + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) + _, err = storageClient.ContainerProfiles(ns).Create(context.Background(), &cp, metav1.CreateOptions{}) + require.NoError(t, err, "create ContainerProfile from %s", resourcePath) + require.Eventually(t, func() bool { + _, e := storageClient.ContainerProfiles(ns).Get(context.Background(), cp.Name, v1.GetOptions{}) + return e == nil + }, 30*time.Second, time.Second, "CP from %s must be in storage before pod deploy", resourcePath) + return &cp +} + func Test_28_UserDefinedNetworkNeighborhood(t *testing.T) { start := time.Now() defer tearDownTest(t, start) @@ -2725,8 +2730,6 @@ func Test_28_UserDefinedNetworkNeighborhood(t *testing.T) { setup := func(t *testing.T) *testutils.TestWorkload { t.Helper() ns := testutils.NewRandomNamespace() - k8sClient := k8sinterface.NewKubernetesApi() - storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) // Upstream ContainerProfileCache (kubescape/node-agent#788) reads ONE // pod label `kubescape.io/user-defined-profile=` and uses @@ -2739,56 +2742,7 @@ func Test_28_UserDefinedNetworkNeighborhood(t *testing.T) { // The kubescape.io/user-defined-profile pod label names this CP; node-agent // uses it directly as the authoritative base. Its Spec merges the former AP // surfaces (execs, syscalls) with the former NN surfaces (egress, selector). - cp := &v1beta1.ContainerProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: overlayName, - Namespace: ns.Name, - Annotations: map[string]string{ - helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, - helpersv1.StatusMetadataKey: helpersv1.Completed, - helpersv1.CompletionMetadataKey: helpersv1.Full, - }, - Labels: map[string]string{ - helpersv1.ApiGroupMetadataKey: "apps", - helpersv1.ApiVersionMetadataKey: "v1", - helpersv1.RelatedKindMetadataKey: "Deployment", - helpersv1.RelatedNameMetadataKey: "curl-28", - helpersv1.RelatedNamespaceMetadataKey: ns.Name, - }, - }, - Spec: v1beta1.ContainerProfileSpec{ - Execs: []v1beta1.ExecCalls{ - {Path: "/bin/sleep"}, - {Path: "/usr/bin/curl"}, - {Path: "/usr/bin/nslookup"}, - {Path: "/usr/bin/wget"}, - }, - Syscalls: []string{"socket", "connect", "sendto", "recvfrom", "read", "write", "close", "openat", "mmap", "mprotect", "munmap", "fcntl", "ioctl", "poll", "epoll_create1", "epoll_ctl", "epoll_wait", "bind", "listen", "accept4", "getsockopt", "setsockopt", "getsockname", "getpid", "fstat", "rt_sigaction", "rt_sigprocmask", "writev"}, - LabelSelector: metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "curl-28"}, - }, - Egress: []v1beta1.NetworkNeighbor{ - { - Identifier: "fusioncore-egress", - Type: "external", - DNS: "fusioncore.ai.", - DNSNames: []string{"fusioncore.ai."}, - IPAddress: "162.0.217.171", - Ports: []v1beta1.NetworkPort{ - {Name: "TCP-80", Protocol: "TCP", Port: ptr.To(int32(80))}, - }, - }, - }, - }, - } - _, err := storageClient.ContainerProfiles(ns.Name).Create( - context.Background(), cp, metav1.CreateOptions{}) - require.NoError(t, err, "create user-defined ContainerProfile") - - require.Eventually(t, func() bool { - _, cpErr := storageClient.ContainerProfiles(ns.Name).Get(context.Background(), overlayName, v1.GetOptions{}) - return cpErr == nil - }, 30*time.Second, 1*time.Second, "user-defined CP must be in storage before pod deploy") + _ = applyUserDefinedContainerProfile(t, ns.Name, "resources/containerprofile-user-defined-network.yaml") wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-user-defined-deployment.yaml")) diff --git a/tests/resources/containerprofile-user-defined-network.yaml b/tests/resources/containerprofile-user-defined-network.yaml new file mode 100644 index 000000000..d0bce179a --- /dev/null +++ b/tests/resources/containerprofile-user-defined-network.yaml @@ -0,0 +1,68 @@ +# Example: a user-defined (user-authored) ContainerProfile. +# +# This is the "new way" of authoring an allow-list behavioural profile: a single +# ContainerProfile replaces the legacy ApplicationProfile + NetworkNeighborhood +# pair. Bind it to a workload by adding the pod label +# kubescape.io/user-defined-profile: +# to the workload's pod template (see nginx-user-defined-deployment.yaml). +# +# A user-managed profile carries ONLY name + namespace (namespace is injected by +# the test/tooling). It has NO learning-lifecycle annotations (status/completion) +# and NO managed-by marker — those are meaningless on an authored profile. When +# signed, the signing tooling adds a signature annotation; nothing else. +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: curl-28-overlay +spec: + # Allowed executables (R0001). + execs: + - path: /bin/sleep + - path: /usr/bin/curl + - path: /usr/bin/nslookup + - path: /usr/bin/wget + # Allowed syscalls (R0003). + syscalls: + - socket + - connect + - sendto + - recvfrom + - read + - write + - close + - openat + - mmap + - mprotect + - munmap + - fcntl + - ioctl + - poll + - epoll_create1 + - epoll_ctl + - epoll_wait + - bind + - listen + - accept4 + - getsockopt + - setsockopt + - getsockname + - getpid + - fstat + - rt_sigaction + - rt_sigprocmask + - writev + # The workload pod selector (was NetworkNeighborhood.spec.labelSelector). + matchLabels: + app: curl-28 + # Allowed egress (R0005 domains, R0011 addresses). Anything not listed alerts. + egress: + - identifier: fusioncore-egress + type: external + dns: fusioncore.ai. + dnsNames: + - fusioncore.ai. + ipAddress: 162.0.217.171 + ports: + - name: TCP-80 + protocol: TCP + port: 80 From 856dc36b56f2a87d444b8c93a912bcf128e03668 Mon Sep 17 00:00:00 2001 From: kubescape Date: Fri, 24 Jul 2026 20:28:26 +0200 Subject: [PATCH 10/29] docs(migrate): ContainerProfile authoring examples for network endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate the network-wildcards NetworkNeighborhood fixtures into their user-authored ContainerProfile form so users can copy-paste them to author user-defined-profile allow-lists: - per-container spec.egress/ingress (NN's spec.containers[] collapses, one CP document per container; fixture 20 splits into two) - no lifecycle annotations — name only (namespace injected by tooling), matching the clean user-managed CP contract - teaching comments preserved verbatim from the NN fixtures Adds 00-fusioncore-homoglyph-attack.yaml: a pinned single-vendor allow-list and the look-alike (homoglyph) domains its exact-match dnsNames compare rejects (each fires R0005). All 21 documents strict-parse against v1beta1 ContainerProfile and carry zero annotations (verified). Signed-off-by: entlein --- .../00-fusioncore-homoglyph-attack.yaml | 54 +++++++++++++++++ .../network-wildcards-cp/01-literal-ipv4.yaml | 21 +++++++ .../network-wildcards-cp/02-literal-ipv6.yaml | 22 +++++++ .../network-wildcards-cp/03-cidr-ipv4.yaml | 23 ++++++++ .../network-wildcards-cp/04-cidr-ipv6.yaml | 21 +++++++ .../05-any-ip-sentinel.yaml | 24 ++++++++ .../network-wildcards-cp/06-any-as-cidr.yaml | 29 ++++++++++ .../07-mixed-ip-list.yaml | 31 ++++++++++ .../08-deprecated-ipaddress.yaml | 24 ++++++++ .../network-wildcards-cp/09-dns-literal.yaml | 24 ++++++++ .../10-dns-leading-wildcard.yaml | 30 ++++++++++ .../11-dns-mid-ellipsis.yaml | 36 ++++++++++++ .../12-dns-trailing-star.yaml | 41 +++++++++++++ .../13-dns-trailing-dot-normalisation.yaml | 34 +++++++++++ .../14-recursive-star-rejected.yaml | 32 ++++++++++ .../15-egress-and-ingress.yaml | 41 +++++++++++++ .../network-wildcards-cp/16-egress-none.yaml | 33 +++++++++++ .../17-realistic-stripe-api.yaml | 53 +++++++++++++++++ .../18-cluster-dns-via-mid-ellipsis.yaml | 50 ++++++++++++++++ .../19-port-protocol-with-cidr.yaml | 36 ++++++++++++ .../20-multi-container-mixed-wildcards.yaml | 58 +++++++++++++++++++ .../resources/network-wildcards-cp/README.md | 54 +++++++++++++++++ 22 files changed, 771 insertions(+) create mode 100644 tests/resources/network-wildcards-cp/00-fusioncore-homoglyph-attack.yaml create mode 100644 tests/resources/network-wildcards-cp/01-literal-ipv4.yaml create mode 100644 tests/resources/network-wildcards-cp/02-literal-ipv6.yaml create mode 100644 tests/resources/network-wildcards-cp/03-cidr-ipv4.yaml create mode 100644 tests/resources/network-wildcards-cp/04-cidr-ipv6.yaml create mode 100644 tests/resources/network-wildcards-cp/05-any-ip-sentinel.yaml create mode 100644 tests/resources/network-wildcards-cp/06-any-as-cidr.yaml create mode 100644 tests/resources/network-wildcards-cp/07-mixed-ip-list.yaml create mode 100644 tests/resources/network-wildcards-cp/08-deprecated-ipaddress.yaml create mode 100644 tests/resources/network-wildcards-cp/09-dns-literal.yaml create mode 100644 tests/resources/network-wildcards-cp/10-dns-leading-wildcard.yaml create mode 100644 tests/resources/network-wildcards-cp/11-dns-mid-ellipsis.yaml create mode 100644 tests/resources/network-wildcards-cp/12-dns-trailing-star.yaml create mode 100644 tests/resources/network-wildcards-cp/13-dns-trailing-dot-normalisation.yaml create mode 100644 tests/resources/network-wildcards-cp/14-recursive-star-rejected.yaml create mode 100644 tests/resources/network-wildcards-cp/15-egress-and-ingress.yaml create mode 100644 tests/resources/network-wildcards-cp/16-egress-none.yaml create mode 100644 tests/resources/network-wildcards-cp/17-realistic-stripe-api.yaml create mode 100644 tests/resources/network-wildcards-cp/18-cluster-dns-via-mid-ellipsis.yaml create mode 100644 tests/resources/network-wildcards-cp/19-port-protocol-with-cidr.yaml create mode 100644 tests/resources/network-wildcards-cp/20-multi-container-mixed-wildcards.yaml create mode 100644 tests/resources/network-wildcards-cp/README.md diff --git a/tests/resources/network-wildcards-cp/00-fusioncore-homoglyph-attack.yaml b/tests/resources/network-wildcards-cp/00-fusioncore-homoglyph-attack.yaml new file mode 100644 index 000000000..52cde2e69 --- /dev/null +++ b/tests/resources/network-wildcards-cp/00-fusioncore-homoglyph-attack.yaml @@ -0,0 +1,54 @@ +# Fixture 00 — the fusioncore homoglyph attack +# +# THE SCENARIO +# A workload is allowed to reach exactly one external vendor, fusioncore.ai. +# An attacker who has code-exec in the container tries to exfiltrate to a +# look-alike domain that a human reviewer skimming an allow-list would wave +# through — but which is a DIFFERENT registrable domain under attacker +# control. +# +# WHY IT MATTERS +# dnsNames match is an EXACT, normalised string compare (lower-cased, +# trailing-dot normalised — see fixture 13). It is NOT a visual/semantic +# compare. So every homoglyph below is a cache-miss against the allow-list +# and fires R0005 (unexpected domain). The security value of a pinned +# dnsNames allow-list over "eyeball the domain in review" is exactly this: +# the matcher cannot be fooled by characters that look identical to a human. +# +# LOOK-ALIKES THAT THIS PROFILE REJECTS (each → R0005) +# fusioncοre.ai. ← Greek omicron U+03BF in place of latin 'o' +# fusiоncore.ai. ← Cyrillic 'о' U+043E in place of latin 'o' +# fusioncore.аi. ← Cyrillic 'а' U+0430 in place of latin 'a' +# fusi0ncore.ai. ← digit zero for letter 'o' +# fusioncore-ai.com. ← hyphen + different TLD, visually close +# fusioncore.ai.evil.example. ← legit label as a PREFIX of an attacker apex +# xn--fusioncre-... (punycode) ← IDNA-encoded homoglyph on the wire +# +# None of these normalise to "fusioncore.ai." — the ONLY allowed string — +# so each is an unexpected egress domain and alerts. +# +# AUTHORING TAKEAWAY +# Pin the exact FQDN(s) you trust. Do NOT reach for a wildcard (fixtures +# 10/12) to "cover typos" — a wildcard widens the attack surface, it does +# not narrow the homoglyph gap. If you legitimately call several vendor +# sub-domains, list each one explicitly. +# +# Bind this profile to a workload with the pod label +# kubescape.io/user-defined-profile: fusioncore-homoglyph +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: fusioncore-homoglyph +spec: + matchLabels: + app: curl-fusioncore + egress: + - identifier: fusioncore-vendor + type: external + # The ONE trusted external domain. Exact match, trailing dot normalised. + dnsNames: + - "fusioncore.ai." + ipAddress: 162.0.217.171 + ports: + - {name: TCP-80, protocol: TCP, port: 80} + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards-cp/01-literal-ipv4.yaml b/tests/resources/network-wildcards-cp/01-literal-ipv4.yaml new file mode 100644 index 000000000..78d441df8 --- /dev/null +++ b/tests/resources/network-wildcards-cp/01-literal-ipv4.yaml @@ -0,0 +1,21 @@ +# Fixture 01 — IPv4 literal in ipAddresses[] +# +# Edge case: single IPv4 literal in the new plural field +# Expects: observed IP "162.0.217.171" matches; "162.0.217.172" does NOT +# Match path: networkmatch.MatchIP(["162.0.217.171"], observed) → true iff equal +# Spec ref: §5.7 "IPv4 / IPv6 literal" row +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: nw-01-literal-ipv4 +spec: + matchLabels: + app: nw-01 + egress: + - identifier: literal-ipv4 + type: external + ipAddresses: + - "162.0.217.171" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards-cp/02-literal-ipv6.yaml b/tests/resources/network-wildcards-cp/02-literal-ipv6.yaml new file mode 100644 index 000000000..3c5198454 --- /dev/null +++ b/tests/resources/network-wildcards-cp/02-literal-ipv6.yaml @@ -0,0 +1,22 @@ +# Fixture 02 — IPv6 literal, canonicalisation +# +# Edge case: IPv6 literal, both compressed and expanded forms MUST compare equal +# Expects: "2001:db8::1", "2001:0db8:0000:0000:0000:0000:0000:0001", +# and "2001:DB8::1" all match each other +# Match path: net.ParseIP(...) normalises before .Equal() — verifier responsibility +# Spec ref: §5.7 — "textual canonicalisation is the verifier's responsibility" +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: nw-02-literal-ipv6 +spec: + matchLabels: + app: nw-02 + egress: + - identifier: literal-ipv6 + type: external + ipAddresses: + - "2001:db8::1" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards-cp/03-cidr-ipv4.yaml b/tests/resources/network-wildcards-cp/03-cidr-ipv4.yaml new file mode 100644 index 000000000..fe3f1c8cb --- /dev/null +++ b/tests/resources/network-wildcards-cp/03-cidr-ipv4.yaml @@ -0,0 +1,23 @@ +# Fixture 03 — IPv4 CIDR +# +# Edge case: a single CIDR block covers a range of IPs +# Expects: observed "10.0.0.1" matches; "10.255.255.254" matches; +# "11.0.0.1" does NOT match +# Match path: net.ParseCIDR("10.0.0.0/8") → *IPNet; IPNet.Contains(observed) +# Perf: compile once at profile-load, reuse the *IPNet on every event +# Spec ref: §5.7 "CIDR" row +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: nw-03-cidr-ipv4 +spec: + matchLabels: + app: nw-03 + egress: + - identifier: rfc1918-class-a + type: internal + ipAddresses: + - "10.0.0.0/8" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards-cp/04-cidr-ipv6.yaml b/tests/resources/network-wildcards-cp/04-cidr-ipv6.yaml new file mode 100644 index 000000000..20966f210 --- /dev/null +++ b/tests/resources/network-wildcards-cp/04-cidr-ipv6.yaml @@ -0,0 +1,21 @@ +# Fixture 04 — IPv6 CIDR +# +# Edge case: IPv6 CIDR matching +# Expects: observed "2001:db8::1" matches; "2001:db9::1" does NOT +# Match path: same code path as IPv4 CIDR — net.ParseCIDR recognises both +# Spec ref: §5.7 "CIDR" row, second example +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: nw-04-cidr-ipv6 +spec: + matchLabels: + app: nw-04 + egress: + - identifier: rfc3849-doc-prefix + type: external + ipAddresses: + - "2001:db8::/32" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards-cp/05-any-ip-sentinel.yaml b/tests/resources/network-wildcards-cp/05-any-ip-sentinel.yaml new file mode 100644 index 000000000..e0e5155dc --- /dev/null +++ b/tests/resources/network-wildcards-cp/05-any-ip-sentinel.yaml @@ -0,0 +1,24 @@ +# Fixture 05 — `*` sentinel for ANY IP +# +# Edge case: a single "*" entry — matches any IPv4 or IPv6 address +# Expects: every observed IP matches; this is permissive-mode profiling +# Match path: compileIP("*") returns isAny=true; runtime short-circuits +# Spec ref: §5.7 "* (any-IP sentinel)" row + the warning at the bottom +# Operations: strongly DISCOURAGED outside development profiles — +# equivalent to disabling egress filtering for this workload. +# Producers should normally enumerate concrete IPs/CIDRs. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: nw-05-any-sentinel +spec: + matchLabels: + app: nw-05 + egress: + - identifier: any-ip-development-profile + type: external + ipAddresses: + - "*" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards-cp/06-any-as-cidr.yaml b/tests/resources/network-wildcards-cp/06-any-as-cidr.yaml new file mode 100644 index 000000000..9ce3e7ba5 --- /dev/null +++ b/tests/resources/network-wildcards-cp/06-any-as-cidr.yaml @@ -0,0 +1,29 @@ +# Fixture 06 — RFC-aligned alternatives to the `*` sentinel +# +# Edge case: `0.0.0.0/0` (RFC 4632 — all IPv4) and `::/0` (RFC 4291 — all IPv6) +# MUST behave identically to `*` +# Expects: observed "1.2.3.4" matches via 0.0.0.0/0; +# observed "2001:db8::1" matches via ::/0 +# Match path: regular CIDR matching — no special casing needed +# Spec ref: §5.7 — "*" sentinel "is sugar for the union of 0.0.0.0/0 + ::/0" +# Why both forms exist: +# Producers who prefer standards-compliant CIDR over our `*` +# sugar can express "any IP" via these two CIDRs and the +# document will be accepted by tooling that doesn't recognise +# the `*` sentinel. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: nw-06-any-as-cidr +spec: + matchLabels: + app: nw-06 + egress: + - identifier: any-via-cidrs + type: external + ipAddresses: + - "0.0.0.0/0" + - "::/0" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards-cp/07-mixed-ip-list.yaml b/tests/resources/network-wildcards-cp/07-mixed-ip-list.yaml new file mode 100644 index 000000000..4aad619ba --- /dev/null +++ b/tests/resources/network-wildcards-cp/07-mixed-ip-list.yaml @@ -0,0 +1,31 @@ +# Fixture 07 — mixed list (literal + CIDR + sentinel) +# +# Edge case: a single ipAddresses[] list mixes all three forms +# Expects: +# "10.1.2.3" → matches via 10.0.0.0/8 +# "162.0.217.171" → matches via the literal +# "8.8.8.8" → matches via the `*` sentinel +# (this fixture intentionally has an unconstrained `*` because +# of the sentinel — the literal and CIDR are illustrative) +# Match path: ANY entry matches → match passes (logical OR) +# Spec ref: §5.7 algorithm "for each entry e in profile.ipAddresses" +# Test value: exercises the loop ordering and short-circuit-on-first-match +# behaviour +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: nw-07-mixed-ip-list +spec: + matchLabels: + app: nw-07 + egress: + - identifier: mixed-shapes + type: external + ipAddresses: + - "162.0.217.171" # IPv4 literal + - "10.0.0.0/8" # IPv4 CIDR + - "2001:db8::/32" # IPv6 CIDR + - "*" # any (sentinel — overrides everything; here for test) + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards-cp/08-deprecated-ipaddress.yaml b/tests/resources/network-wildcards-cp/08-deprecated-ipaddress.yaml new file mode 100644 index 000000000..1f0f71501 --- /dev/null +++ b/tests/resources/network-wildcards-cp/08-deprecated-ipaddress.yaml @@ -0,0 +1,24 @@ +# Fixture 08 — backward compatibility with deprecated singular `ipAddress` +# +# Edge case: only the deprecated singular field populated; ipAddresses absent +# Expects: observed "10.0.0.42" matches via the singular field; +# behaviour unchanged from v0.0.1 +# Match path: verifier walks BOTH singular and plural fields, treating them +# as a logical OR +# Spec ref: §4.7 ipAddress row — "Deprecated since v0.0.2 — kept for back-compat" +# Producer rule: MUST NOT populate both `ipAddress` (singular) and `ipAddresses` +# (plural) on the same entry — admission strategy rejects +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: nw-08-deprecated-ipaddress +spec: + matchLabels: + app: nw-08 + egress: + - identifier: legacy-singular-ip + type: external + ipAddress: "10.0.0.42" # DEPRECATED — kept here on purpose to exercise back-compat + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards-cp/09-dns-literal.yaml b/tests/resources/network-wildcards-cp/09-dns-literal.yaml new file mode 100644 index 000000000..190ce1c98 --- /dev/null +++ b/tests/resources/network-wildcards-cp/09-dns-literal.yaml @@ -0,0 +1,24 @@ +# Fixture 09 — DNS literal +# +# Edge case: plain FQDN, byte-equality after trailing-dot normalisation +# Expects: observed "api.stripe.com." matches; "api.stripe.com" matches +# (both forms equivalent); "v1.api.stripe.com." does NOT +# Match path: normalise trailing dot on both profile entry and observed name, +# then byte-equality +# Spec ref: §5.8 "Literal" row, plus the trailing-dot normalisation paragraph +# RFC ref: RFC 1035 § 3.1 (FQDN syntax) +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: nw-09-dns-literal +spec: + matchLabels: + app: nw-09 + egress: + - identifier: stripe-api-literal + type: external + dnsNames: + - "api.stripe.com." + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards-cp/10-dns-leading-wildcard.yaml b/tests/resources/network-wildcards-cp/10-dns-leading-wildcard.yaml new file mode 100644 index 000000000..9a020ce89 --- /dev/null +++ b/tests/resources/network-wildcards-cp/10-dns-leading-wildcard.yaml @@ -0,0 +1,30 @@ +# Fixture 10 — DNS leading wildcard `*.` +# +# Edge case: RFC 4592 wildcard label — exactly ONE label before the suffix +# Expects: +# observed "api.example.com." → match (one label "api") +# observed "webhooks.example.com." → match (one label "webhooks") +# observed "v1.api.example.com." → NO match (two labels — leading * is exactly one) +# observed "example.com." → NO match (apex — leading * requires at least one) +# observed ".example.com." → NO match (empty label — invalid DNS) +# Match path: label-split + per-position match using the same recursive +# matcher as path wildcards (`compareLabels`) +# Spec ref: §5.8 "*." row + the rationale block +# RFC ref: RFC 4592 (DNS wildcard match) — "exactly one label" is the +# only ratified wildcard form; bind/coredns/cilium/k8s ingress +# all honour this convention +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: nw-10-dns-leading-wildcard +spec: + matchLabels: + app: nw-10 + egress: + - identifier: example-com-subdomains + type: external + dnsNames: + - "*.example.com." + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards-cp/11-dns-mid-ellipsis.yaml b/tests/resources/network-wildcards-cp/11-dns-mid-ellipsis.yaml new file mode 100644 index 000000000..ecec5cfc5 --- /dev/null +++ b/tests/resources/network-wildcards-cp/11-dns-mid-ellipsis.yaml @@ -0,0 +1,36 @@ +# Fixture 11 — DNS mid-label `⋯` (DynamicIdentifier) +# +# Edge case: exactly ONE label between two static segments +# (the user's `svc.*.kubernetes.io.` use case, spelt with ⋯ +# because mid-label `*` is non-standard) +# Expects: +# observed "svc.kube-system.cluster.local." → match +# observed "svc.default.cluster.local." → match +# observed "svc.cluster.local." → NO match (zero labels in slot) +# observed "svc.a.b.cluster.local." → NO match (two labels — ⋯ is exactly one) +# Match path: label-split + the existing dynamicpathdetector.CompareDynamic +# (DNS labels and path segments are structurally identical) +# Spec ref: §5.8 ".⋯." row — "DynamicIdentifier — exactly one label" +# Why this exists: +# RFC 4592 only standardises LEADING wildcards. Mid-label `*` is non-standard +# (cilium uses regex; bind/coredns reject it). v0.0.2 uses `⋯` (our token, +# from path/argv wildcards) for mid positions so the wire format never +# claims false RFC 4592 compliance. +# Token reminder: +# `⋯` is U+22EF (MIDLINE HORIZONTAL ELLIPSIS) — ONE Unicode codepoint. +# It is NOT three ASCII periods (`...`). +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: nw-11-dns-mid-ellipsis +spec: + matchLabels: + app: nw-11 + egress: + - identifier: cluster-svc-resolution + type: internal + dnsNames: + - "svc.⋯.cluster.local." + ports: + - {name: UDP-53, protocol: UDP, port: 53} diff --git a/tests/resources/network-wildcards-cp/12-dns-trailing-star.yaml b/tests/resources/network-wildcards-cp/12-dns-trailing-star.yaml new file mode 100644 index 000000000..a4c8d613e --- /dev/null +++ b/tests/resources/network-wildcards-cp/12-dns-trailing-star.yaml @@ -0,0 +1,41 @@ +# Fixture 12 — DNS trailing wildcard `.*` +# +# Edge case: one OR MORE labels after the prefix (NEVER zero) +# Expects: +# observed "mycorp.com.api." → match (one label after) +# observed "mycorp.com.api.v1." → match (two labels after) +# observed "mycorp.com.api.v1.eu-west-1." → match (three labels after) +# observed "mycorp.com." → NO match (apex — zero labels; +# trailing `*` requires ≥1) +# Match path: label-split + recursive matcher with one-or-more-segment +# semantic on trailing `*`. Same defensive arity rule as paths +# (§5.1) — closes the apex blind spot. +# Spec ref: §5.8 ".*" row, "one or more labels (never zero)" +# +# IMPORTANT clarification on label order: +# DNS names are read LEFT-TO-RIGHT but their label hierarchy goes +# RIGHT-TO-LEFT (the rightmost label is the TLD). So for `mycorp.com.*`, +# the `*` sits in the LEFTMOST positions of any matching name. This +# is opposite to the path convention. Both conventions agree that the +# `*` consumes "1+ tokens at the variable end" — they just differ on +# which end is variable. +# +# Producers should usually prefer `*.mycorp.com.` (leading-`*` per +# RFC 4592) for "any subdomain" intent, since that's the standardised +# form. The trailing form documented here is for cases where the +# variable hierarchy is on the LEFT of a fixed registry suffix. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: nw-12-dns-trailing-star +spec: + matchLabels: + app: nw-12 + egress: + - identifier: mycorp-anything-deeper + type: external + dnsNames: + - "mycorp.com.*" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards-cp/13-dns-trailing-dot-normalisation.yaml b/tests/resources/network-wildcards-cp/13-dns-trailing-dot-normalisation.yaml new file mode 100644 index 000000000..c9cabe86d --- /dev/null +++ b/tests/resources/network-wildcards-cp/13-dns-trailing-dot-normalisation.yaml @@ -0,0 +1,34 @@ +# Fixture 13 — trailing-dot normalisation +# +# Edge case: DNS literals MUST compare equal whether or not the trailing +# dot is present, on either side +# Expects (with profile entry "api.stripe.com." — WITH dot): +# observed "api.stripe.com." → match +# observed "api.stripe.com" → match (verifier normalises) +# Expects (with profile entry "api.stripe.com" — WITHOUT dot): +# observed "api.stripe.com." → match +# observed "api.stripe.com" → match +# Match path: verifier MUST canonicalise both sides before comparison +# (e.g. always append "." if missing) +# Spec ref: §5.8 "Trailing-dot normalisation" paragraph +# Producer guidance: emit the trailing dot — it's the FQDN-canonical form per +# RFC 1035. But verifiers MUST accept either. +# +# This fixture deliberately mixes both forms in dnsNames[] to ensure the +# normalisation runs on profile-side entries, not just observed names. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: nw-13-dns-trailing-dot +spec: + matchLabels: + app: nw-13 + egress: + - identifier: mixed-trailing-dot-forms + type: external + dnsNames: + - "api.stripe.com." # canonical FQDN form + - "api.github.com" # without trailing dot — equivalent + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards-cp/14-recursive-star-rejected.yaml b/tests/resources/network-wildcards-cp/14-recursive-star-rejected.yaml new file mode 100644 index 000000000..64d2a0e24 --- /dev/null +++ b/tests/resources/network-wildcards-cp/14-recursive-star-rejected.yaml @@ -0,0 +1,32 @@ +# Fixture 14 — `**` recursive wildcard MUST be rejected +# +# Edge case: a producer attempts to use the recursive `**` wildcard +# Expects: apiserver admission strategy REJECTS the document at write time +# (kubectl apply returns an error; nothing is persisted) +# Match path: N/A — never reaches a runtime matcher +# Spec ref: §5.8 last row "** (recursive zero-or-more) — NOT in v0.0.2" +# and "Empty / ** rejection" paragraph +# Why deferred to v0.0.3: +# `**` semantics need careful design — should it match zero labels? +# how does it interact with leading/trailing `*`? Reserve the syntax now +# so producers don't accidentally rely on a future behaviour change. +# +# This fixture is INTENTIONALLY INVALID. The component test should: +# 1. Attempt `kubectl apply -f 14-recursive-star-rejected.yaml` +# 2. Assert the command fails with a validation error +# 3. Assert no NetworkNeighborhood named `nw-14-recursive-rejected` exists +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: nw-14-recursive-rejected +spec: + matchLabels: + app: nw-14 + egress: + - identifier: invalid-recursive + type: external + dnsNames: + - "**.example.com." # INVALID — admission MUST reject + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards-cp/15-egress-and-ingress.yaml b/tests/resources/network-wildcards-cp/15-egress-and-ingress.yaml new file mode 100644 index 000000000..73f1ba47a --- /dev/null +++ b/tests/resources/network-wildcards-cp/15-egress-and-ingress.yaml @@ -0,0 +1,41 @@ +# Fixture 15 — egress AND ingress on the same container +# +# Edge case: both directions populated; matchers MUST be independently scoped +# Expects: +# pktType=='OUTGOING' to "10.1.2.3" → match in egress (CIDR 10.0.0.0/8) +# pktType=='OUTGOING' to "192.0.2.1" → NO match in egress (NOT in CIDR) +# pktType=='INCOMING' from "192.168.1.42" → match in ingress (CIDR 192.168.0.0/16) +# pktType=='INCOMING' from "10.0.0.42" → NO match in ingress (NOT in 192.168/16) +# (even though 10.0.0.0/8 IS in egress — +# direction isolation is the contract) +# Match path: nn.was_address_in_egress() walks Spec.Egress only; +# nn.was_address_in_ingress() walks Spec.Ingress only +# Spec ref: §4.7 "egress and ingress" — direction isolation contract +# +# Note on current rule coverage: +# The default kubescape rule set (R0005, R0011, etc.) only fires on +# pktType=='OUTGOING'. The ingress block is fully matchable via the +# nn.was_address_in_ingress / nn.is_domain_in_ingress CEL functions, +# but no built-in rule consumes them as of v0.0.2. Custom rules MAY. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: nw-15-egress-and-ingress +spec: + matchLabels: + app: nw-15 + egress: + - identifier: outbound-class-a + type: internal + ipAddresses: + - "10.0.0.0/8" + ports: + - {name: TCP-443, protocol: TCP, port: 443} + ingress: + - identifier: inbound-rfc1918-class-c + type: internal + ipAddresses: + - "192.168.0.0/16" + ports: + - {name: TCP-8080, protocol: TCP, port: 8080} diff --git a/tests/resources/network-wildcards-cp/16-egress-none.yaml b/tests/resources/network-wildcards-cp/16-egress-none.yaml new file mode 100644 index 000000000..5687bcb37 --- /dev/null +++ b/tests/resources/network-wildcards-cp/16-egress-none.yaml @@ -0,0 +1,33 @@ +# Fixture 16 — NONE egress (declared zero-egress traffic) +# +# Edge case: egress: [] explicit empty list — declares "this workload +# makes ZERO outbound network connections" +# Expects: verifier emits net.egress_unexpected on the FIRST observed +# outgoing connection (any IP, any DNS, any port) +# Spec ref: §5.4 NONE semantic — "explicit empty list = declared +# zero-activity, hard violation on first observation" +# Distinction from absent: +# `egress:` MISSING from the doc = NULL (verifier-defined posture) +# `egress: []` = NONE (zero-traffic contract) +# This fixture pins the latter. +# +# Producer use case: +# A worker pod that should ONLY accept inbound work and never reach out. +# A locked-down database whose only legitimate traffic is the ingress +# replication stream. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: nw-16-egress-none +spec: + matchLabels: + app: nw-16 + egress: [] # NONE — any outbound traffic is a violation + ingress: + - identifier: control-plane-only + type: internal + ipAddresses: + - "10.0.0.1" + ports: + - {name: TCP-9000, protocol: TCP, port: 9000} diff --git a/tests/resources/network-wildcards-cp/17-realistic-stripe-api.yaml b/tests/resources/network-wildcards-cp/17-realistic-stripe-api.yaml new file mode 100644 index 000000000..fc839ca07 --- /dev/null +++ b/tests/resources/network-wildcards-cp/17-realistic-stripe-api.yaml @@ -0,0 +1,53 @@ +# Fixture 17 — realistic Stripe API integration +# +# Edge case: end-to-end realistic profile for a workload that calls +# Stripe (well-known external SaaS) plus cluster DNS +# Demonstrates: +# - egress[] with multiple entries (external + internal) +# - ipAddresses[] with both literal and CIDR +# - dnsNames[] with literal AND leading wildcard (RFC 4592) +# - selectors-based internal entry (auto-translated to NetworkPolicy; +# not consulted by R0005/R0011 runtime — see §4.7 caveat) +# - port specifications +# Expects: +# POST https://api.stripe.com (resolved to one of Stripe's IPs) → match +# POST https://files.stripe.com (matches *.stripe.com.) → match +# POST https://api.example.com → NO match +# UDP to kube-dns:53 → match (NetworkPolicy) +# but R0011/R0005 +# don't consult selectors +# — see §4.7 note +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: nw-17-realistic-stripe +spec: + matchLabels: + app: payment-service + egress: + - identifier: stripe-api + type: external + ipAddresses: + - "162.0.217.171" # Stripe public IP example + - "163.0.0.0/16" # Stripe routing range — for completeness + dnsNames: + - "api.stripe.com." + - "*.stripe.com." # leading-* RFC 4592 — covers files.stripe.com., + # webhooks.stripe.com., billing.stripe.com. + # but NOT v1.api.stripe.com. (two labels deep) + ports: + - {name: TCP-443, protocol: TCP, port: 443} + - identifier: cluster-dns + type: internal + # Selector-based entry — auto-translates to a NetworkPolicy egress rule + # that K8s enforces. Note: R0005/R0011 runtime matchers do NOT consult + # selectors as of v0.0.2 — they only walk ipAddresses/dnsNames. + namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - {name: UDP-53, protocol: UDP, port: 53} diff --git a/tests/resources/network-wildcards-cp/18-cluster-dns-via-mid-ellipsis.yaml b/tests/resources/network-wildcards-cp/18-cluster-dns-via-mid-ellipsis.yaml new file mode 100644 index 000000000..15cdfcec7 --- /dev/null +++ b/tests/resources/network-wildcards-cp/18-cluster-dns-via-mid-ellipsis.yaml @@ -0,0 +1,50 @@ +# Fixture 18 — Kubernetes service-FQDN resolution via mid-`⋯` +# +# Edge case: The user's specific case from the v0.0.2 design discussion. +# In Kubernetes, services are resolved as +# ..svc.cluster.local. +# A workload that wants to permit "any namespace's +# service" should match exactly one label between fixed +# anchors. +# Expects: +# observed "redis.production.svc.cluster.local." → NO match (we anchored on `redis`, +# and only the namespace label is +# wildcarded) +# observed "redis.staging.svc.cluster.local." → NO match (same — we'd need +# a different fixture for "any svc +# in any ns") +# observed "kubernetes.default.svc.cluster.local." → match (one label "default") +# Match path: label-split + recursive matcher; `⋯` consumes exactly one +# label between two fixed segments +# Spec ref: §5.8 ".⋯." row, the example uses this exact pattern +# +# Why `⋯` and not `*`: +# RFC 4592 only standardises *.. Mid-label `*` is non-standard +# (cilium uses regex; bind/coredns reject it). v0.0.2 uses `⋯` (DynamicIdentifier, +# the project's existing token from path/argv wildcards) for mid positions. +# +# Hardcoded short-circuit removal candidate: +# The default rule R0005 currently has a hardcoded +# `!event.name.endsWith('.svc.cluster.local.')` short-circuit. With this +# fixture's mid-⋯ form, that hardcode becomes profile-expressible — a +# future PR can REMOVE the rule-side short-circuit and let producers +# declare the equivalent via this NN. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: nw-18-cluster-dns-mid-ellipsis +spec: + matchLabels: + app: nw-18 + egress: + - identifier: any-namespace-kubernetes-svc + type: internal + dnsNames: + - "kubernetes.⋯.svc.cluster.local." + # ↑ matches kubernetes.default.svc.cluster.local. exactly, + # parametric on the namespace label. Use one entry per + # service the workload calls; the wildcard is on the + # namespace position, not the service name. + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards-cp/19-port-protocol-with-cidr.yaml b/tests/resources/network-wildcards-cp/19-port-protocol-with-cidr.yaml new file mode 100644 index 000000000..02c41b23e --- /dev/null +++ b/tests/resources/network-wildcards-cp/19-port-protocol-with-cidr.yaml @@ -0,0 +1,36 @@ +# Fixture 19 — port + protocol + CIDR composed match +# +# Edge case: nn.was_address_port_protocol_in_egress matcher — the granular +# variant that requires IP+port+protocol all to match within +# the same NetworkNeighbor entry +# Expects: +# observed (10.1.2.3, 443, TCP) → match (both CIDR and port match within entry) +# observed (10.1.2.3, 80, TCP) → NO match (CIDR ok but port mismatch) +# observed (192.168.1.1, 443, TCP)→ NO match (port ok but CIDR mismatch) +# observed (10.1.2.3, 443, UDP) → NO match (CIDR + port ok but protocol mismatch) +# Match path: for each NetworkNeighbor: +# if MatchIP(entry.IPs, observed) && entry contains matching +# (port, protocol) tuple → true +# Spec ref: §4.7 ports[] row — name + protocol + port (uint16 nullable) +# +# This fixture validates that the new IP-matcher integration preserves the +# port-protocol grouping contract — a CIDR match alone isn't sufficient +# unless the entry's ports list also contains the (port, protocol) pair. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: nw-19-port-proto-cidr +spec: + matchLabels: + app: nw-19 + egress: + - identifier: tls-only-class-a + type: internal + ipAddresses: + - "10.0.0.0/8" + ports: + - {name: TCP-443, protocol: TCP, port: 443} + # Note: no UDP entry, no port-80 entry — only TCP/443 within this CIDR. + # A request to (10.1.2.3, 80, TCP) should NOT match because the + # port-protocol filter is per-NetworkNeighbor-entry, not global. diff --git a/tests/resources/network-wildcards-cp/20-multi-container-mixed-wildcards.yaml b/tests/resources/network-wildcards-cp/20-multi-container-mixed-wildcards.yaml new file mode 100644 index 000000000..371700b23 --- /dev/null +++ b/tests/resources/network-wildcards-cp/20-multi-container-mixed-wildcards.yaml @@ -0,0 +1,58 @@ +# Fixture 20 — multi-container pod with different rules per container +# +# Edge case: a single NetworkNeighborhood applies to a multi-container pod; +# each container has its own egress/ingress block; the verifier +# MUST scope matching by container ID (not pod ID) +# Expects: +# container "frontend" can hit *.example.com. but NOT 10.0.0.0/8; +# container "sidecar" can hit 10.0.0.0/8 but NOT *.example.com.; +# if the verifier conflates containers, both restrictions collapse to "either" +# and the test fails +# Match path: nn.* CEL functions resolve the ContainerProfile by containerID, +# so the matchers operate on the ALREADY-scoped Spec.Egress slice +# Spec ref: §4.2 container entry — each container is independently profiled +# +# This is also the most realistic deployment shape: a frontend that calls +# external APIs plus an in-cluster sidecar that talks to DBs/caches. +# +# container: frontend +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: nw-20-multi-container-frontend +spec: + matchLabels: + app: nw-20 + egress: + - identifier: external-api + type: external + dnsNames: + - "*.example.com." # leading-* RFC 4592 + - "api.partner.io." # literal + ports: + - {name: TCP-443, protocol: TCP, port: 443} +--- +# container: sidecar +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: nw-20-multi-container-sidecar +spec: + matchLabels: + app: nw-20 + egress: + - identifier: in-cluster-services + type: internal + ipAddresses: + - "10.0.0.0/8" # cluster pod CIDR + - "172.16.0.0/12" # alt cluster service CIDR + ports: + - {name: TCP-6379, protocol: TCP, port: 6379} # redis + - {name: TCP-5432, protocol: TCP, port: 5432} # postgres + ingress: + - identifier: from-frontend + type: internal + ipAddresses: + - "10.244.0.0/16" # narrower — only the frontend pod's CIDR + ports: + - {name: TCP-9090, protocol: TCP, port: 9090} # sidecar metrics diff --git a/tests/resources/network-wildcards-cp/README.md b/tests/resources/network-wildcards-cp/README.md new file mode 100644 index 000000000..6660c1040 --- /dev/null +++ b/tests/resources/network-wildcards-cp/README.md @@ -0,0 +1,54 @@ +# Network endpoint fixtures — ContainerProfile (user-defined-profile) form + +These are the **ContainerProfile** authoring examples for the network egress/ +ingress surface — the "new way" (migration #862) of authoring a user-defined +behavioural allow-list. Each file is a copy-pasteable, self-documenting example +of one edge case in the v0.0.2 network-endpoint grammar. + +## Relationship to `../network-wildcards/` + +`../network-wildcards/*.yaml` hold the same edge cases as **NetworkNeighborhood** +documents (per-workload: `spec.containers[]`). Those are consumed as-is by the +CEL matcher unit tests (`pkg/rulemanager/cel/libraries/networkneighborhood/ +fixtures_test.go`) and must stay in NN form. + +The files **here** are the migrated, user-authorable equivalents: + +| NetworkNeighborhood (learned / legacy) | ContainerProfile (user-authored, new) | +|---|---| +| per-**workload** | per-**container** | +| `spec.matchLabels` + `spec.containers[].{egress,ingress}` | `spec.matchLabels` + `spec.{egress,ingress}` directly | +| bound by workload selector | bound by pod label `kubescape.io/user-defined-profile: ` | +| carries `managed-by/status/completion` annotations | **no annotations** — name (+ namespace, injected) only; a signature is added by the signing tool | + +A multi-container NN (fixture 20) becomes **one CP document per container**, +`---`-separated in the same file. + +## Contents + +- `00-fusioncore-homoglyph-attack.yaml` — flagship security example: a pinned + single-vendor allow-list and the look-alike (homoglyph) domains it rejects. +- `01`–`20` — the network-endpoint edge cases (literal IPv4/v6, CIDR, the `*` + any-IP sentinel, mixed lists, deprecated singular `ipAddress`, DNS literals, + leading-`*` / trailing-`*` / mid-`⋯` wildcards, trailing-dot normalisation, + the rejected recursive `**`, egress+ingress direction isolation, ports/ + protocols, cluster-DNS via mid-`⋯`, and a multi-container split). + +## Wildcard token vocabulary + +| Token | Meaning | +|---|---| +| `⋯` (U+22EF, single codepoint — NOT three ASCII periods) | exactly one DNS label between fixed anchors | +| `*` leading | RFC 4592 wildcard — exactly one label before the suffix | +| `*` trailing | one or more labels after the prefix (never zero) | +| `*` as an `ipAddresses[i]` entry | sugar for `0.0.0.0/0` ∪ `::/0` (any IP) | + +## Authoring rules these examples follow + +- User-managed ContainerProfiles carry **only** `metadata.name` (namespace is + injected by tooling). No `managed-by`, no `status/completion` — those are + meaningless on an authored profile; the read path forces the enforcement + state for a label-referenced CP. +- `14-recursive-star-rejected.yaml` is **intentionally invalid** (`dnsNames: + ["**"]`) — do not `kubectl apply` it; it documents that recursive `**` is + not v0.0.2 syntax. From aacf3983d76880a8069cba99349744e3754ded9f Mon Sep 17 00:00:00 2001 From: kubescape Date: Fri, 24 Jul 2026 21:11:59 +0200 Subject: [PATCH 11/29] test(migrate): migrate legacy AP/NN CT fixtures to ContainerProfile The component tests build their user-defined profiles inline as ContainerProfiles (Test_27/32/33) or load a CP yaml (Test_28); the legacy ApplicationProfile / NetworkNeighborhood fixtures they were derived from are no longer referenced by any Go test. Migrate the two CT-relevant ones to their user-authored CP form and delete the orphan: - exec-arg-wildcards-profile.yaml (AP curl-32-overlay) -> containerprofile-exec-arg-wildcards.yaml. CP form mirrors Test_32's inline CP exactly (same execs incl. busybox-symlink + literal-* entries, same syscalls, matchLabels app: curl-32). Demonstrates the exec-arg wildcard grammar for authoring. - known-network-neighborhood.yaml (NN fusioncore-network) -> containerprofile-fusioncore-network.yaml. Clean user-managed CP: name only, no managed-by / status / completion annotations. - user-profile.yaml: deleted. Zero references anywhere; its nginx/server exec surface matches no current test or deployment. Both new documents strict-parse against v1beta1 ContainerProfile and carry zero annotations (verified). CT compiles unchanged (go vet -tags component); no Go test referenced the deleted files. Signed-off-by: entlein --- .../containerprofile-exec-arg-wildcards.yaml | 92 +++++++++++++++++++ .../containerprofile-fusioncore-network.yaml | 34 +++++++ .../resources/exec-arg-wildcards-profile.yaml | 74 --------------- .../resources/known-network-neighborhood.yaml | 49 ---------- tests/resources/user-profile.yaml | 47 ---------- 5 files changed, 126 insertions(+), 170 deletions(-) create mode 100644 tests/resources/containerprofile-exec-arg-wildcards.yaml create mode 100644 tests/resources/containerprofile-fusioncore-network.yaml delete mode 100644 tests/resources/exec-arg-wildcards-profile.yaml delete mode 100644 tests/resources/known-network-neighborhood.yaml delete mode 100644 tests/resources/user-profile.yaml diff --git a/tests/resources/containerprofile-exec-arg-wildcards.yaml b/tests/resources/containerprofile-exec-arg-wildcards.yaml new file mode 100644 index 000000000..649d9e44d --- /dev/null +++ b/tests/resources/containerprofile-exec-arg-wildcards.yaml @@ -0,0 +1,92 @@ +# User-defined ContainerProfile fixture for exec-arg wildcard CTs (Test_32 family). +# +# Encodes the exec-arg wildcard contract enforced by storage's +# dynamicpathdetector.MatchExecArgs (see compare_exec_args.go). The tokens are +# dedicated, collision-free sentinels — a "*" in argv is a LITERAL character, +# NEVER a wildcard: +# +# "⋯" (U+22EF) matches exactly ONE whole arg, or one embedded segment. +# "⋯⋯" (U+22EF x2) matches ZERO-OR-MORE whole trailing args (positional). +# "*" a literal "*" — matches only itself, does NOT broaden. +# +# YAML note: "*" MUST be quoted ("*") — bare * is a YAML alias indicator. The +# ⋯ runes are plain UTF-8 and need no quoting but are quoted here for clarity. +# +# This is the YAML equivalent of Test_32_UnexpectedProcessArguments's inline CP. +# Bind it to a workload with the pod label +# kubescape.io/user-defined-profile: curl-32-overlay +# (see resources/curl-exec-arg-wildcards-deployment.yaml). The namespace is +# injected by the tooling; a user-managed CP carries no other metadata. +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: curl-32-overlay +spec: + architectures: ["amd64"] + # The workload pod selector (was NetworkNeighborhood.spec.labelSelector). + matchLabels: + app: curl-32 + execs: + # sleep — ⋯⋯ absorbs zero-or-more trailing args. + - path: /bin/sleep + args: ["/bin/sleep", "⋯⋯"] + # sh -c — literal anchor "-c", then ⋯⋯. + - path: /bin/sh + args: ["/bin/sh", "-c", "⋯⋯"] + # echo hello — literal anchor "hello", then ⋯⋯. + - path: /bin/echo + args: ["/bin/echo", "hello", "⋯⋯"] + # curl -s — ⋯ matches one arg, not two. + - path: /usr/bin/curl + args: ["/usr/bin/curl", "-s", "⋯"] + # curl -s — ⋯ MID-VECTOR: one arg, then the literal + # args after it must anchor (a mismatch on a trailing literal fires R0040). + - path: /usr/bin/curl + args: ["/usr/bin/curl", "-s", "⋯", "file:///etc/hosts", "file:///etc/hostname"] + # echo star — "*" is data: matches only `echo star *`, + # must NOT broaden to `echo star ` (the threat-model guard). + - path: /bin/echo + args: ["/bin/echo", "star", "*"] + # Busybox-symlink mirror entries: the curl image's /bin/{sleep,sh,echo} + # are symlinks to /bin/busybox, so the kernel exepath the rule queries is + # /bin/busybox. Without these, R0001 fires before R0040 can evaluate. + - path: /bin/busybox + args: ["/bin/sleep", "⋯⋯"] + - path: /bin/busybox + args: ["/bin/sh", "-c", "⋯⋯"] + - path: /bin/busybox + args: ["/bin/echo", "hello", "⋯⋯"] + - path: /bin/busybox + args: ["/bin/echo", "star", "*"] + # Allowed syscalls (R0003) — mirrors the inline CP so the process-argument + # subtests exercise R0040, not a spurious R0003. + syscalls: + - socket + - connect + - sendto + - recvfrom + - read + - write + - close + - openat + - mmap + - mprotect + - munmap + - fcntl + - ioctl + - poll + - epoll_create1 + - epoll_ctl + - epoll_wait + - bind + - listen + - accept4 + - getsockopt + - setsockopt + - getsockname + - getpid + - fstat + - rt_sigaction + - rt_sigprocmask + - writev + - execve diff --git a/tests/resources/containerprofile-fusioncore-network.yaml b/tests/resources/containerprofile-fusioncore-network.yaml new file mode 100644 index 000000000..6109fe4ca --- /dev/null +++ b/tests/resources/containerprofile-fusioncore-network.yaml @@ -0,0 +1,34 @@ +# User-defined ContainerProfile for the fusioncore network scenario (Test_28). +# +# The "new way" migration of the former user-defined NetworkNeighborhood +# (known-network-neighborhood.yaml): a single ContainerProfile replaces the +# AP + NN pair. Bind it to a workload with the pod label +# kubescape.io/user-defined-profile: fusioncore-network +# +# A user-managed profile carries ONLY name (+ namespace, injected by tooling). +# It has NO learning-lifecycle annotations (status/completion) and NO managed-by +# marker — those are meaningless on an authored profile. When signed, the signing +# tooling adds a signature annotation; nothing else. +# +# Modeled after a real auto-learned neighbourhood from curlimages/curl:8.5.0. +# Allows exactly fusioncore.ai (162.0.217.171) on TCP/80. Anything else alerts +# (unknown domain → R0005, unknown IP → R0011). See 00-fusioncore-homoglyph-attack +# in network-wildcards-cp/ for why the exact-match dnsNames allow-list matters. +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: fusioncore-network +spec: + matchLabels: + app: curl-fusioncore-28-1 + ingress: [] + egress: + - identifier: a5e64ff1db824089b1706ac872303e55075f92cf6a652b5272f06c3a2b9e8d10 + type: external + dnsNames: + - fusioncore.ai. + ipAddress: 162.0.217.171 + ports: + - name: TCP-80 + protocol: TCP + port: 80 diff --git a/tests/resources/exec-arg-wildcards-profile.yaml b/tests/resources/exec-arg-wildcards-profile.yaml deleted file mode 100644 index 97dba6e92..000000000 --- a/tests/resources/exec-arg-wildcards-profile.yaml +++ /dev/null @@ -1,74 +0,0 @@ -# User ApplicationProfile fixture for exec-arg wildcard CTs (Test_32 family). -# -# Encodes the exec-arg wildcard contract enforced by storage's -# dynamicpathdetector.MatchExecArgs (see compare_exec_args.go). The tokens are -# dedicated, collision-free sentinels — a "*" in argv is a LITERAL character, -# NEVER a wildcard: -# -# "⋯" (U+22EF) matches exactly ONE whole arg, or one embedded segment. -# "⋯⋯" (U+22EF x2) matches ZERO-OR-MORE whole trailing args (positional). -# "*" a literal "*" — matches only itself, does NOT broaden. -# -# YAML note: "*" MUST be quoted ("*") — bare * is a YAML alias indicator. The -# ⋯ runes are plain UTF-8 and need no quoting but are quoted here for clarity. -# -# This is the YAML equivalent of Test_32_UnexpectedProcessArguments's inline AP. -# Apply with the workload label kubescape.io/user-defined-profile=curl-32-overlay -# (see resources/curl-exec-arg-wildcards-deployment.yaml). Substitute {namespace} -# for the test namespace, or set it explicitly. -apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: ApplicationProfile -metadata: - name: curl-32-overlay - namespace: "{namespace}" - resourceVersion: "1" - annotations: - kubescape.io/managed-by: User -spec: - architectures: ["amd64"] - containers: - - name: curl - imageID: "" - imageTag: "" - capabilities: [] - opens: [] - syscalls: [] - endpoints: [] - execs: - # sleep — ⋯⋯ absorbs zero-or-more trailing args. - - path: /bin/sleep - args: ["/bin/sleep", "⋯⋯"] - # sh -c — literal anchor "-c", then ⋯⋯. - - path: /bin/sh - args: ["/bin/sh", "-c", "⋯⋯"] - # echo hello — literal anchor "hello", then ⋯⋯. - - path: /bin/echo - args: ["/bin/echo", "hello", "⋯⋯"] - # curl -s — ⋯ matches one arg, not two. - - path: /usr/bin/curl - args: ["/usr/bin/curl", "-s", "⋯"] - # curl -s — ⋯ MID-VECTOR: one arg, then the literal - # args after it must anchor (a mismatch on a trailing literal fires R0040). - - path: /usr/bin/curl - args: ["/usr/bin/curl", "-s", "⋯", "file:///etc/hosts", "file:///etc/hostname"] - # echo star — "*" is data: matches only `echo star *`, - # must NOT broaden to `echo star ` (the threat-model guard). - - path: /bin/echo - args: ["/bin/echo", "star", "*"] - # Busybox-symlink mirror entries: the curl image's /bin/{sleep,sh,echo} - # are symlinks to /bin/busybox, so the kernel exepath the rule queries is - # /bin/busybox. Without these, R0001 fires before R0040 can evaluate. - - path: /bin/busybox - args: ["/bin/sleep", "⋯⋯"] - - path: /bin/busybox - args: ["/bin/sh", "-c", "⋯⋯"] - - path: /bin/busybox - args: ["/bin/echo", "hello", "⋯⋯"] - - path: /bin/busybox - args: ["/bin/echo", "star", "*"] - seccompProfile: - spec: - defaultAction: "" - initContainers: [] - ephemeralContainers: [] -status: {} diff --git a/tests/resources/known-network-neighborhood.yaml b/tests/resources/known-network-neighborhood.yaml deleted file mode 100644 index 0d4caa0c4..000000000 --- a/tests/resources/known-network-neighborhood.yaml +++ /dev/null @@ -1,49 +0,0 @@ -## -## User-defined NetworkNeighborhood for Test_28. -## -## Referenced directly from a pod via the label: -## kubescape.io/user-defined-network: fusioncore-network -## -## Carries "kubescape.io/managed-by: User" annotation and workload -## labels to match the schema the node-agent cache expects. -## -## Modeled after a real auto-learned NN from curlimages/curl:8.5.0. -## -## Usage: -## sed "s/{{NAMESPACE}}/$NS/g" known-network-neighborhood.yaml \ -## | kubectl apply -f - -## -apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: NetworkNeighborhood -metadata: - name: fusioncore-network - namespace: "{{NAMESPACE}}" - annotations: - kubescape.io/managed-by: User - kubescape.io/status: completed - kubescape.io/completion: complete - labels: - kubescape.io/workload-api-group: apps - kubescape.io/workload-api-version: v1 - kubescape.io/workload-kind: Deployment - kubescape.io/workload-name: curl-fusioncore-deployment - kubescape.io/workload-namespace: "{{NAMESPACE}}" -spec: - matchLabels: - app: curl-fusioncore-28-1 - containers: - - name: curl - ingress: [] - egress: - - dns: fusioncore.ai. - dnsNames: - - fusioncore.ai. - identifier: a5e64ff1db824089b1706ac872303e55075f92cf6a652b5272f06c3a2b9e8d10 - ipAddress: 162.0.217.171 - namespaceSelector: null - podSelector: null - ports: - - name: TCP-80 - port: 80 - protocol: TCP - type: external diff --git a/tests/resources/user-profile.yaml b/tests/resources/user-profile.yaml deleted file mode 100644 index 97a116f6d..000000000 --- a/tests/resources/user-profile.yaml +++ /dev/null @@ -1,47 +0,0 @@ -apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: ApplicationProfile -metadata: - name: {name} - namespace: {namespace} - resourceVersion: "1" # Start with "1" for new resources - annotations: - kubescape.io/managed-by: User -spec: - architectures: ["amd64"] - containers: - - name: nginx - imageID: "" - imageTag: "" - capabilities: [] - opens: [] - syscalls: [] - endpoints: [] - execs: - - path: /usr/bin/ls - args: - - /usr/bin/ls - - -l - seccompProfile: - spec: - defaultAction: "" - - name: server - imageID: "" - imageTag: "" - capabilities: [] - opens: [] - syscalls: [] - endpoints: [] - execs: - - path: /bin/ls - args: - - /bin/ls - - -l - - path: /bin/grpc_health_probe - args: - - "-addr=:9555" - seccompProfile: - spec: - defaultAction: "" - initContainers: [] - ephemeralContainers: [] -status: {} \ No newline at end of file From 82317029eecaf6bf9f892704cdf643a713e16602 Mon Sep 17 00:00:00 2001 From: entlein Date: Sat, 25 Jul 2026 14:23:38 +0200 Subject: [PATCH 12/29] remove migration tooling Signed-off-by: entlein --- .../containerprofilecache/usercp.go | 48 ------ .../usercp_bench_test.go | 42 ----- .../usercp_diff_oracle_test.go | 143 ------------------ 3 files changed, 233 deletions(-) delete mode 100644 pkg/objectcache/containerprofilecache/usercp.go delete mode 100644 pkg/objectcache/containerprofilecache/usercp_bench_test.go delete mode 100644 pkg/objectcache/containerprofilecache/usercp_diff_oracle_test.go diff --git a/pkg/objectcache/containerprofilecache/usercp.go b/pkg/objectcache/containerprofilecache/usercp.go deleted file mode 100644 index 0e6c8a01a..000000000 --- a/pkg/objectcache/containerprofilecache/usercp.go +++ /dev/null @@ -1,48 +0,0 @@ -package containerprofilecache - -import ( - "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// ConvertUserProfilesToContainerProfile builds the single user-defined -// ContainerProfile equivalent to a legacy user-authored ApplicationProfile + -// NetworkNeighborhood pair, for one container. -// -// The result carries ONLY name + namespace and the merged Spec. A user-managed -// ContainerProfile has no learning-lifecycle annotations (status/completion) and -// no managed-by marker — those are meaningless on an authored profile: the pod's -// kubescape.io/user-defined-profile label is what declares it user-authored, and -// a signature (added later by the signing tooling) is the integrity marker. -// -// The Spec is exactly what projectUserProfiles produces overlaying the AP+NN onto -// an empty base, so projecting this CP yields a byte-identical -// ProjectedContainerProfile to the legacy overlay path (see the diff oracle). -// Either userAP or userNN may be nil. -func ConvertUserProfilesToContainerProfile(userAP *v1beta1.ApplicationProfile, userNN *v1beta1.NetworkNeighborhood, pod *corev1.Pod, containerName string) *v1beta1.ContainerProfile { - base := &v1beta1.ContainerProfile{} - if meta := userProfileMeta(userAP, userNN); meta != nil { - base.Name = meta.Name - base.Namespace = meta.Namespace - } - if userAP != nil { - base.Spec.Architectures = append([]string(nil), userAP.Spec.Architectures...) - } - - projected, _ := projectUserProfiles(base, userAP, userNN, pod, containerName) - return projected -} - -// userProfileMeta returns the source name/namespace to carry onto the converted -// CP, preferring the ApplicationProfile (AP and NN share a name for a -// user-defined pair). -func userProfileMeta(userAP *v1beta1.ApplicationProfile, userNN *v1beta1.NetworkNeighborhood) *metav1.ObjectMeta { - if userAP != nil { - return &userAP.ObjectMeta - } - if userNN != nil { - return &userNN.ObjectMeta - } - return nil -} diff --git a/pkg/objectcache/containerprofilecache/usercp_bench_test.go b/pkg/objectcache/containerprofilecache/usercp_bench_test.go deleted file mode 100644 index 92a03d353..000000000 --- a/pkg/objectcache/containerprofilecache/usercp_bench_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package containerprofilecache - -import ( - "testing" - - "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// BenchmarkProjection_Legacy vs BenchmarkProjection_UserDefinedCP measure the -// per-container projection cost of the two paths on the full user-defined shape -// (opens/exec wildcards, endpoints, egress/ingress + selector): -// -// Legacy: overlay user AP + user NN onto an empty base, then Apply. -// New: the converted ContainerProfile is the base — Apply with no overlay. -// -// The new path skips the two-object merge (projectUserProfiles fast-returns a -// DeepCopy when both user inputs are nil), so it does strictly less work per -// projection — which the reconciler runs on every changed tick. -func BenchmarkProjection_Legacy(b *testing.B) { - tc := oracleCases()[0] // full_ap_and_nn - pod := podWith("curl") - base := &v1beta1.ContainerProfile{ObjectMeta: metav1.ObjectMeta{Name: "base", Namespace: "demo"}} - b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { - legacyCP, _ := projectUserProfiles(base, tc.ap, tc.nn, pod, tc.containerName) - _ = Apply(nil, legacyCP, nil) - } -} - -func BenchmarkProjection_UserDefinedCP(b *testing.B) { - tc := oracleCases()[0] // full_ap_and_nn - pod := podWith("curl") - userCP := ConvertUserProfilesToContainerProfile(tc.ap, tc.nn, pod, tc.containerName) - b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { - newCP, _ := projectUserProfiles(userCP, nil, nil, pod, tc.containerName) - _ = Apply(nil, newCP, nil) - } -} diff --git a/pkg/objectcache/containerprofilecache/usercp_diff_oracle_test.go b/pkg/objectcache/containerprofilecache/usercp_diff_oracle_test.go deleted file mode 100644 index af98d54a6..000000000 --- a/pkg/objectcache/containerprofilecache/usercp_diff_oracle_test.go +++ /dev/null @@ -1,143 +0,0 @@ -package containerprofilecache - -import ( - "testing" - - "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" - dynamicpathdetector "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" - "github.com/stretchr/testify/assert" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// The differential oracle for the user-defined AP/NN -> ContainerProfile -// migration. It pins the migration contract at the ENFORCEMENT artifact level: -// -// OLD path (what node-agent does today for a user-defined container): -// overlay the user AP + user NN onto the synthetic empty base CP, then Apply. -// NEW path (after the migration): -// the user authors ONE ContainerProfile (produced by the converter); node-agent -// uses it as the base with no overlay, then Apply. -// -// If these two ProjectedContainerProfiles are equal for every representative -// user-defined shape, the migration is behaviour-preserving. Apply is a pure -// function of cp.Spec (+ the SyncChecksum annotation), so equality here is exactly -// enforcement-equivalence. - -func udMeta(name string) metav1.ObjectMeta { - return metav1.ObjectMeta{ - Name: name, - Namespace: "demo", - Annotations: map[string]string{ - "kubescape.io/managed-by": "User", - "kubescape.io/status": "completed", - "kubescape.io/completion": "complete", - }, - Labels: map[string]string{ - "kubescape.io/workload-kind": "Deployment", - "kubescape.io/workload-name": name, - }, - } -} - -// oracleCases mirror the real user-defined component tests (27/28/32/33): -// opens with wildcard/ellipsis anchoring, execs with argv wildcards, HTTP -// endpoints, and egress/ingress with a LabelSelector. -func oracleCases() []struct { - name string - containerName string - ap *v1beta1.ApplicationProfile - nn *v1beta1.NetworkNeighborhood -} { - dyn := dynamicpathdetector.DynamicIdentifier - full := func() (*v1beta1.ApplicationProfile, *v1beta1.NetworkNeighborhood) { - ap := &v1beta1.ApplicationProfile{ - ObjectMeta: udMeta("curl-overlay"), - Spec: v1beta1.ApplicationProfileSpec{ - Architectures: []string{"amd64"}, - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "curl", - Capabilities: []string{"NET_BIND_SERVICE", "SYS_PTRACE"}, - Execs: []v1beta1.ExecCalls{ - {Path: "/usr/bin/curl", Args: []string{"curl", dyn}}, - {Path: "/bin/sh", Args: []string{"sh", "-c", "echo *"}}, - }, - Opens: []v1beta1.OpenCalls{ - {Path: "/etc/ssl/" + dyn, Flags: []string{"O_RDONLY"}}, - {Path: "/etc/ld.so.cache", Flags: []string{"O_RDONLY"}}, - {Path: "/var/log/*", Flags: []string{"O_RDONLY"}}, - }, - Syscalls: []string{"openat", "read", "connect"}, - Endpoints: []v1beta1.HTTPEndpoint{ - {Endpoint: ":8080/api/products", Methods: []string{"GET"}}, - }, - PolicyByRuleId: map[string]v1beta1.RulePolicy{ - "R0040": {AllowedProcesses: []string{"curl"}}, - }, - }}, - }, - } - nn := &v1beta1.NetworkNeighborhood{ - ObjectMeta: udMeta("curl-overlay"), - Spec: v1beta1.NetworkNeighborhoodSpec{ - LabelSelector: metav1.LabelSelector{MatchLabels: map[string]string{"app": "curl"}}, - Containers: []v1beta1.NetworkNeighborhoodContainer{{ - Name: "curl", - Egress: []v1beta1.NetworkNeighbor{ - {Identifier: "eg-dns", DNS: "fusioncore.ai.", Type: "external", - Ports: []v1beta1.NetworkPort{{Name: "TCP-80", Protocol: "TCP", Port: p80()}}}, - {Identifier: "eg-ip", IPAddress: "162.0.217.171", Type: "external", - Ports: []v1beta1.NetworkPort{{Name: "TCP-80", Protocol: "TCP", Port: p80()}}}, - }, - Ingress: []v1beta1.NetworkNeighbor{ - {Identifier: "in-1", DNSNames: []string{"a.svc.local"}}, - }, - }}, - }, - } - return ap, nn - } - apFull, nnFull := full() - apOnly, _ := full() - _, nnOnly := full() - - return []struct { - name string - containerName string - ap *v1beta1.ApplicationProfile - nn *v1beta1.NetworkNeighborhood - }{ - {"full_ap_and_nn", "curl", apFull, nnFull}, - {"ap_only", "curl", apOnly, nil}, - {"nn_only", "curl", nil, nnOnly}, - {"no_matching_container", "sidecar", apFull, nnFull}, - } -} - -func p80() *int32 { v := int32(80); return &v } - -func TestDiffOracle_UserDefinedCP_MatchesLegacyOverlay(t *testing.T) { - for _, tc := range oracleCases() { - tc := tc - t.Run(tc.name, func(t *testing.T) { - pod := podWith("curl") - - // OLD path: overlay user AP + NN onto the synthetic empty base. - emptyBase := &v1beta1.ContainerProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "base", Namespace: "demo"}, - } - legacyCP, _ := projectUserProfiles(emptyBase, tc.ap, tc.nn, pod, tc.containerName) - - // NEW path: the converted single ContainerProfile is the base, no overlay. - userCP := ConvertUserProfilesToContainerProfile(tc.ap, tc.nn, pod, tc.containerName) - newCP, _ := projectUserProfiles(userCP, nil, nil, pod, tc.containerName) - - // nil spec => full pass-through; equality here is spec-independent - // (equal Specs => equal Apply for ANY RuleProjectionSpec). - pLegacy := Apply(nil, legacyCP, nil) - pNew := Apply(nil, newCP, nil) - - assert.Equal(t, pLegacy, pNew, - "migrated user-defined ContainerProfile must enforce identically to the legacy AP+NN overlay") - }) - } -} From 83bc9fbe038e45260fde03d64f9c177872fe3312 Mon Sep 17 00:00:00 2001 From: entlein Date: Sat, 25 Jul 2026 14:24:43 +0200 Subject: [PATCH 13/29] remove useless readme Signed-off-by: entlein --- tests/resources/network-wildcards/README.md | 80 --------------------- 1 file changed, 80 deletions(-) delete mode 100644 tests/resources/network-wildcards/README.md diff --git a/tests/resources/network-wildcards/README.md b/tests/resources/network-wildcards/README.md deleted file mode 100644 index 305e8f914..000000000 --- a/tests/resources/network-wildcards/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# Network-wildcards test fixtures - -Living documentation for the `feat/network-wildcards` work. - -Each `*.yaml` here is a complete `NetworkNeighborhood` document that exercises -ONE edge case in the v0.0.2 wildcard surface. The fixture-walk test -(`TestFixturesParse` + `TestFixturesMatchExpectedBehaviour` in -`pkg/rulemanager/cel/libraries/networkneighborhood/fixtures_test.go`, -plus the lab-side `Test_34_NetworkWildcardSurface`) consumes them -directly; users learning the syntax can copy-paste them as authoritative -examples. - -**Note on `14-recursive-star-rejected.yaml`:** this fixture is intentionally -**rejected at admission** — it carries `dnsNames: ["**"]` to demonstrate -that the recursive-wildcard token is invalid v0.0.2 syntax. Don't `kubectl -apply` it; the apiserver will return a 400. The runtime matcher also -defends by silently dropping it on read, so a broken admission layer -won't accidentally let it through. - -## Wildcard token vocabulary (matches paths + argv vocabulary) - -| Token | Meaning | -|---|---| -| `⋯` (U+22EF, MIDLINE HORIZONTAL ELLIPSIS — single Unicode codepoint, NOT three ASCII periods) | Exactly one segment / argv position / **DNS label** | -| `*` leading | RFC 4592 wildcard — exactly one DNS label before the suffix | -| `*` mid-path | NOT used in DNS — use `⋯` instead | -| `*` trailing | One or more labels after the prefix (never zero — closes the apex blind spot) | -| `*` as `ipAddresses[i]` | Sugar for `0.0.0.0/0` ∪ `::/0` (any IP) | - -## Field summary - -| Field on `NetworkNeighbor` | v0.0.2 status | Match form | -|---|---|---| -| `ipAddress` (string) | **deprecated** — kept for back-compat | byte-equality only | -| `ipAddresses` (list of strings) | **new** | each entry: literal IP / CIDR / `*` sentinel; matches if ANY entry matches | -| `dnsNames` (list of strings) | normative | each entry: literal / leading-`*` / mid-`⋯` / trailing-`*`; matches if ANY entry matches | -| `dns` (single string) | **deprecated** since v0.0.1 | byte-equality only | -| `ports[]` | normative | name + protocol + port (uint16, nullable per §5.4) | -| `podSelector`, `namespaceSelector` | schema-level (passed through to auto-generated NetworkPolicy) | NOT consulted by the runtime CEL matchers — see §4.7 caveat | - -## Fixture index - -| # | File | Edge case | -|---|------|-----------| -| 01 | `01-literal-ipv4.yaml` | Single IPv4 literal in `ipAddresses[]` | -| 02 | `02-literal-ipv6.yaml` | IPv6 literal — verifier MUST canonicalise | -| 03 | `03-cidr-ipv4.yaml` | IPv4 CIDR — `10.0.0.0/8` covers a /8 range | -| 04 | `04-cidr-ipv6.yaml` | IPv6 CIDR — `2001:db8::/32` | -| 05 | `05-any-ip-sentinel.yaml` | The `*` sentinel — discouraged outside dev | -| 06 | `06-any-as-cidr.yaml` | `0.0.0.0/0` + `::/0` (RFC-aligned alternatives to `*`) | -| 07 | `07-mixed-ip-list.yaml` | Mixed list: literal + CIDR + sentinel — first match wins | -| 08 | `08-deprecated-ipaddress.yaml` | Backward compat — singular `ipAddress` field | -| 09 | `09-dns-literal.yaml` | Plain DNS literal with trailing dot | -| 10 | `10-dns-leading-wildcard.yaml` | `*.example.com.` — RFC 4592, exactly ONE label | -| 11 | `11-dns-mid-ellipsis.yaml` | `svc.⋯.cluster.local.` — exactly ONE label between | -| 12 | `12-dns-trailing-star.yaml` | `mycorp.com.*` — ONE OR MORE labels (never zero) | -| 13 | `13-dns-trailing-dot-normalisation.yaml` | `example.com` and `example.com.` MUST be equivalent | -| 14 | `14-recursive-star-rejected.yaml` | `**` — MUST be rejected by apiserver write strategy | -| 15 | `15-egress-and-ingress.yaml` | Both directions populated on same container | -| 16 | `16-egress-none.yaml` | NONE (`egress: []`) — declared zero-egress | -| 17 | `17-realistic-stripe-api.yaml` | Realistic external API call (Stripe) | -| 18 | `18-cluster-dns-via-mid-ellipsis.yaml` | The user's `svc.⋯.kubernetes.io.` use case | -| 19 | `19-port-protocol-with-cidr.yaml` | Ports + protocol + CIDR composed | -| 20 | `20-multi-container-mixed-wildcards.yaml` | Pod with multiple containers, each with different rules — combined real-world example | - -## Expected behaviour matrix - -The accompanying `expectations.json` (generated alongside) lists, per fixture, -the `(observedIP, observedDNS) → expected match result` triples that -`Test_34_NetworkWildcardSurface` walks. - -## Migration note - -Producers writing v0.0.2-conformant SBoBs SHOULD use `ipAddresses` (plural). -The singular `ipAddress` is retained ONLY for back-compat with v0.0.1-era -profiles; producers MUST NOT populate both on the same entry (the apiserver -admission strategy rejects this). - -The deprecated `dns` (single string) field is retained for v0 compatibility; -v0.0.2 producers MUST emit `dnsNames` (list). From fd5ed04a1d77b2768c47f1867cb21ae7d5f4b295 Mon Sep 17 00:00:00 2001 From: entlein Date: Mon, 27 Jul 2026 18:22:51 +0200 Subject: [PATCH 14/29] couldnt find any active consumers of user-defined AP/NN, so opting to decommission them -step 1 of many -- I understand that this has a long tail of decommissions Signed-off-by: entlein --- .../containerprofilecache.go | 127 ++----- .../containerprofilecache_test.go | 100 +++-- .../containerprofilecache/export_test.go | 12 - .../containerprofilecache/reconciler.go | 81 +--- .../containerprofilecache/reconciler_test.go | 274 -------------- .../t8_overlay_refresh_test.go | 114 ------ .../13-dns-trailing-dot-normalisation.yaml | 2 +- tests/resources/nnlint_test.go | 350 ++++++++++++++++++ 8 files changed, 457 insertions(+), 603 deletions(-) delete mode 100644 pkg/objectcache/containerprofilecache/t8_overlay_refresh_test.go create mode 100644 tests/resources/nnlint_test.go diff --git a/pkg/objectcache/containerprofilecache/containerprofilecache.go b/pkg/objectcache/containerprofilecache/containerprofilecache.go index b73c48d51..4f277853a 100644 --- a/pkg/objectcache/containerprofilecache/containerprofilecache.go +++ b/pkg/objectcache/containerprofilecache/containerprofilecache.go @@ -61,16 +61,11 @@ type CachedContainerProfile struct { PodUID string WorkloadID string - // UserAPRef / UserNNRef are set when the entry was built with a legacy - // user-authored AP/NN overlay. Used by the reconciler to re-fetch on - // refresh and to key deprecation warnings. - UserAPRef *namespacedName - UserNNRef *namespacedName - // UserCPRef is set when the user-defined-profile label names a single // user-authored ContainerProfile (the migrated "new way"), which is used - // as the authoritative base for the container. Mutually exclusive with the - // legacy UserAPRef/UserNNRef overlay. Used by the reconciler to re-fetch. + // as the authoritative base for the container. It is the only user-defined + // source — the legacy AP/NN overlay is no longer supported. Used by the + // reconciler to re-fetch on refresh. UserCPRef *namespacedName // CPName is the storage name of the ContainerProfile. Populated at @@ -87,8 +82,6 @@ type CachedContainerProfile struct { RV string // ContainerProfile resourceVersion at last load UserManagedAPRV string // user-managed AP (ug-) RV at last projection, "" if absent UserManagedNNRV string // user-managed NN (ug-) RV at last projection, "" if absent - UserAPRV string // user-AP (label-referenced) resourceVersion at last projection, "" if no overlay - UserNNRV string // user-NN (label-referenced) resourceVersion at last projection, "" if no overlay UserCPRV string // user-defined ContainerProfile (label-referenced) RV at last load, "" if not used } @@ -335,11 +328,8 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( cp = nil } - // Fetch user-managed AP / NN published at "ug-". Legacy - // caches auto-detected these via the `kubescape.io/managed-by: User` - // annotation and merged them on top of the base profile; we read them - // directly by their well-known name instead, avoiding a List and an - // annotation filter. Both are optional: nil on 404. + + // LEGACY ONLY var userManagedAP *v1beta1.ApplicationProfile var userManagedNN *v1beta1.NetworkNeighborhood if workloadName != "" { @@ -390,61 +380,35 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( return false } - // Fetch user-authored legacy CRDs when the pod carries the - // UserDefinedProfileMetadataKey label. Fix (reviewer #2): fetch - // independently of the base-CP result, so a container that only has a - // user-defined profile still gets a cache entry. Recording the refs is - // gated on successful fetch here (otherwise the projection has no data - // to merge); the reconciler's refresh path re-fetches on each tick so - // transient failures are recovered. - var userAP *v1beta1.ApplicationProfile - var userNN *v1beta1.NetworkNeighborhood + // Fetch the user-authored ContainerProfile when the pod carries the + // UserDefinedProfileMetadataKey label. Migration (#862/#864) is a HARD + // cutover: the label now names a single user-authored ContainerProfile — + // the unified replacement for the legacy AP+NN overlay pair, which is no + // longer supported. The CP is authoritative and needs no overlay merge. On + // a fetch error (transient, or the CP hasn't landed yet) it is left nil and + // the container stays pending; UserCPRef — recorded unconditionally below — + // drives the reconciler to retry the CP on every tick until it materialises. var userDefinedCP *v1beta1.ContainerProfile overlayName, hasOverlay := container.K8s.PodLabels[helpersv1.UserDefinedProfileMetadataKey] if hasOverlay && overlayName != "" { - // Migration (#862): the user-defined-profile label now names a single - // user-authored ContainerProfile ("new way") — the unified replacement - // for the legacy AP+NN pair. Prefer it: it is authoritative and needs no - // overlay merge. Fall back to the legacy AP+NN pair only when no such CP - // exists, in which case emitOverlayMetrics fires the deprecation signal. var userCPErr error _ = c.refreshRPC(ctx, func(rctx context.Context) error { userDefinedCP, userCPErr = c.storageClient.GetContainerProfile(rctx, ns, overlayName) return userCPErr }) if userCPErr != nil { + logger.L().Debug("user-defined ContainerProfile not available", + helpers.String("containerID", containerID), + helpers.String("namespace", ns), + helpers.String("name", overlayName), + helpers.Error(userCPErr)) userDefinedCP = nil - var userAPErr error - _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userAP, userAPErr = c.storageClient.GetApplicationProfile(rctx, ns, overlayName) - return userAPErr - }) - if userAPErr != nil { - logger.L().Debug("user-defined ApplicationProfile not available", - helpers.String("containerID", containerID), - helpers.String("namespace", ns), - helpers.String("name", overlayName), - helpers.Error(userAPErr)) - userAP = nil - } - var userNNErr error - _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userNN, userNNErr = c.storageClient.GetNetworkNeighborhood(rctx, ns, overlayName) - return userNNErr - }) - if userNNErr != nil { - logger.L().Debug("user-defined NetworkNeighborhood not available", - helpers.String("containerID", containerID), - helpers.String("namespace", ns), - helpers.String("name", overlayName), - helpers.Error(userNNErr)) - userNN = nil - } } } + // LEGACY mixed in // Need SOMETHING to cache. If we have nothing, stay pending and retry. - if cp == nil && userDefinedCP == nil && userManagedAP == nil && userManagedNN == nil && userAP == nil && userNN == nil { + if cp == nil && userDefinedCP == nil && userManagedAP == nil && userManagedNN == nil { return false } @@ -484,13 +448,7 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( helpers.String("podName", container.K8s.PodName)) } - // User-managed projection pass (published at the - // "ug-" well-known name). Legacy caches auto-merged these - // in handleUserManagedProfile after detecting the managed-by annotation; - // here we always union in whatever's published at the convention name. - // This is what Test_12_MergingProfilesTest / Test_13_MergingNetworkNeighborhoodTest - // exercise: rules must alert on events absent from the merged base+user-managed - // profile. + // LEGACY userManagedApplied := userManagedAP != nil || userManagedNN != nil if userManagedApplied { projected, warnings := projectUserProfiles(cp, userManagedAP, userManagedNN, pod, container.Runtime.ContainerName) @@ -498,7 +456,7 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( c.emitOverlayMetrics(userManagedAP, userManagedNN, warnings) } - entry := c.buildEntry(cp, userAP, userNN, pod, container, sharedData) + entry := c.buildEntry(cp, pod, container, sharedData) // Override CPName with the real consolidated-CP slug. buildEntry sets // CPName from cp.Name, but when cp was synthesized above (no consolidated // CP in storage yet), cp.Name is the workloadName/overlayName — NOT the @@ -516,14 +474,17 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( entry.UserManagedNNRV = userManagedNN.ResourceVersion } - // Fix (reviewer #2): when the overlay label is set, record UserAPRef / - // UserNNRef even if the initial fetch failed. The refresh loop uses - // these refs to re-fetch on every tick; without them, a transient 404 - // at add time would permanently lose the overlay. + // When the overlay label is set, ALWAYS record UserCPRef so the reconciler + // keeps probing for the user-authored ContainerProfile on every tick — even + // when this first fetch failed (transient error, or the CP simply hasn't + // landed yet). refreshOneEntry only re-fetches the user-defined CP + // `if e.UserCPRef != nil`; without this unconditional assignment a transient + // error at add time would leave the container without an authored profile + // until it restarts. There is no legacy AP/NN fallback anymore — the CP is + // the only user-defined source. if hasOverlay && overlayName != "" { + entry.UserCPRef = &namespacedName{Namespace: ns, Name: overlayName} if userDefinedCP != nil { - // New way: track the user-defined CP for re-fetch; no legacy refs. - entry.UserCPRef = &namespacedName{Namespace: ns, Name: overlayName} entry.UserCPRV = userDefinedCP.ResourceVersion // A user-authored profile is authoritative and complete by // definition — it carries no learning-lifecycle status/completion @@ -535,13 +496,6 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( Completion: helpersv1.Full, Name: userDefinedCP.Name, } - } else { - if entry.UserAPRef == nil { - entry.UserAPRef = &namespacedName{Namespace: ns, Name: overlayName} - } - if entry.UserNNRef == nil { - entry.UserNNRef = &namespacedName{Namespace: ns, Name: overlayName} - } } } @@ -564,8 +518,6 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( // stored. func (c *ContainerProfileCacheImpl) buildEntry( cp *v1beta1.ContainerProfile, - userAP *v1beta1.ApplicationProfile, - userNN *v1beta1.NetworkNeighborhood, pod *corev1.Pod, container *containercollection.Container, sharedData *objectcache.WatchedContainerData, @@ -582,21 +534,10 @@ func (c *ContainerProfileCacheImpl) buildEntry( entry.PodUID = string(pod.UID) } - // Apply label-referenced user overlay (if any). + // The base is authoritative as-is: the user-defined overlay is now a whole + // ContainerProfile adopted directly as `cp` (no AP/NN merge), and the + // user-managed "ug-" merge already ran on `cp` before buildEntry is called. userMerged := cp - if userAP != nil || userNN != nil { - merged, warnings := projectUserProfiles(cp, userAP, userNN, pod, container.Runtime.ContainerName) - userMerged = merged - if userAP != nil { - entry.UserAPRef = &namespacedName{Namespace: userAP.Namespace, Name: userAP.Name} - entry.UserAPRV = userAP.ResourceVersion - } - if userNN != nil { - entry.UserNNRef = &namespacedName{Namespace: userNN.Namespace, Name: userNN.Name} - entry.UserNNRV = userNN.ResourceVersion - } - c.emitOverlayMetrics(userAP, userNN, warnings) - } // Build call-stack search tree. tree := callstackcache.NewCallStackSearchTree() diff --git a/pkg/objectcache/containerprofilecache/containerprofilecache_test.go b/pkg/objectcache/containerprofilecache/containerprofilecache_test.go index 613df4326..ec2435ed9 100644 --- a/pkg/objectcache/containerprofilecache/containerprofilecache_test.go +++ b/pkg/objectcache/containerprofilecache/containerprofilecache_test.go @@ -51,6 +51,13 @@ type fakeProfileClient struct { // with overlay-AP/NN use this to keep the fixture scoped. overlayOnly string + // overlayCPErr, when non-nil, is returned by GetContainerProfile for a + // name matching overlayOnly, instead of the default NotFound. Lets tests + // simulate a *transient* RPC failure on the user-defined-CP fetch (as + // opposed to a genuine "doesn't exist yet"), to prove the overlay is not + // permanently lost. + overlayCPErr error + getCPCalls int } @@ -91,6 +98,9 @@ func (f *fakeProfileClient) GetContainerProfile(_ context.Context, _, name strin // name it is absent, which drives the legacy AP/NN fallback path. (The base // CP fetch uses the derived slug, a different name, and still gets f.cp.) if f.overlayOnly != "" && name == f.overlayOnly { + if f.overlayCPErr != nil { + return nil, f.overlayCPErr + } return nil, apierrors.NewNotFound(schema.GroupResource{Resource: "containerprofiles"}, name) } return f.cp, f.cpErr @@ -181,29 +191,27 @@ func TestSharedFastPath_NoOverlay(t *testing.T) { assert.NotNil(t, entryB.Projected, "entry B must have a projected profile") } -// TestOverlayPath_DeepCopies verifies that when userAP is present the overlay -// is merged into the projected profile. -func TestOverlayPath_DeepCopies(t *testing.T) { - cp := &v1beta1.ContainerProfile{ +// TestOverlayPath_UserDefinedCP_NewWay verifies the migrated path: when the +// user-defined-profile label names a user-authored ContainerProfile +// (managed-by: User), it becomes the authoritative base — UserCPRef is set and +// the projection reflects the CP. +func TestOverlayPath_UserDefinedCP_NewWay(t *testing.T) { + userCP := &v1beta1.ContainerProfile{ ObjectMeta: metav1.ObjectMeta{ - Name: "cp-1", Namespace: "default", ResourceVersion: "1", - Annotations: map[string]string{helpersv1.StatusMetadataKey: helpersv1.Completed}, - }, - Spec: v1beta1.ContainerProfileSpec{Capabilities: []string{"SYS_PTRACE"}}, - } - userAP := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "override", Namespace: "default", ResourceVersion: "u1"}, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "nginx", - Capabilities: []string{"NET_BIND_SERVICE"}, - }}, + Name: "override", Namespace: "default", ResourceVersion: "uc1", + Annotations: map[string]string{ + helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, + helpersv1.StatusMetadataKey: helpersv1.Completed, + helpersv1.CompletionMetadataKey: helpersv1.Full, + }, }, + Spec: v1beta1.ContainerProfileSpec{Capabilities: []string{"NET_BIND_SERVICE"}}, } - client := &fakeProfileClient{cp: cp, ap: userAP, overlayOnly: "override"} + // cp: nil (learning suppressed for user-defined); userCP served at "override". + client := &fakeProfileClient{cp: nil, cpErr: apierrors.NewNotFound(schema.GroupResource{}, "x"), userCP: userCP} c, k8s := newTestCache(t, client) - id := "container-overlay" + id := "container-udcp" primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") ev := eventContainer(id) @@ -212,33 +220,45 @@ func TestOverlayPath_DeepCopies(t *testing.T) { entry, ok := c.entries.Load(id) require.True(t, ok) - assert.NotNil(t, entry.Projected, "overlay path must produce a projected profile") - require.NotNil(t, entry.UserAPRef) - assert.Equal(t, "override", entry.UserAPRef.Name) - assert.Equal(t, "u1", entry.UserAPRV) + assert.NotNil(t, entry.Projected, "user-defined CP path must produce a projected profile") + require.NotNil(t, entry.UserCPRef, "UserCPRef must be recorded for refresh") + assert.Equal(t, "override", entry.UserCPRef.Name) + assert.Equal(t, "uc1", entry.UserCPRV) } -// TestOverlayPath_UserDefinedCP_NewWay verifies the migrated path: when the -// user-defined-profile label names a user-authored ContainerProfile -// (managed-by: User), it becomes the authoritative base — UserCPRef is set, the -// legacy UserAPRef/UserNNRef are NOT, and the projection reflects the CP. -func TestOverlayPath_UserDefinedCP_NewWay(t *testing.T) { - userCP := &v1beta1.ContainerProfile{ +// TestOverlayPath_CPFetchTransientError_RecordsUserCPRef pins the cutover +// semantics: when the overlay label is present but the GetContainerProfile +// fetch at the overlay name fails *transiently* (an RPC error, not a genuine +// absence), an entry that is still built from a present base CP must record +// UserCPRef so the reconciler keeps probing for the user-defined CP on later +// ticks. There is no legacy AP/NN fallback anymore — the CP is the only +// user-defined source — so without this the authored profile would be silently +// lost until the container restarts. +// +// This test fails on code that does not record UserCPRef on a transient +// overlay-CP fetch failure and passes once it does. +func TestOverlayPath_CPFetchTransientError_RecordsUserCPRef(t *testing.T) { + // A completed base CP is present (fetched by the derived slug name), but the + // CP fetch at the overlay name errors transiently, so userDefinedCP is nil + // for this add and the entry is built from the base CP. + baseCP := &v1beta1.ContainerProfile{ ObjectMeta: metav1.ObjectMeta{ - Name: "override", Namespace: "default", ResourceVersion: "uc1", + Name: "cp-base", Namespace: "default", ResourceVersion: "1", Annotations: map[string]string{ - helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, - helpersv1.StatusMetadataKey: helpersv1.Completed, helpersv1.CompletionMetadataKey: helpersv1.Full, + helpersv1.StatusMetadataKey: helpersv1.Completed, }, }, - Spec: v1beta1.ContainerProfileSpec{Capabilities: []string{"NET_BIND_SERVICE"}}, + Spec: v1beta1.ContainerProfileSpec{Capabilities: []string{"SYS_PTRACE"}}, + } + client := &fakeProfileClient{ + cp: baseCP, + overlayOnly: "override", + overlayCPErr: errors.New("etcdserver: request timed out"), // transient } - // cp: nil (learning suppressed for user-defined); userCP served at "override". - client := &fakeProfileClient{cp: nil, cpErr: apierrors.NewNotFound(schema.GroupResource{}, "x"), userCP: userCP} c, k8s := newTestCache(t, client) - id := "container-udcp" + id := "container-cp-transient" primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") ev := eventContainer(id) @@ -247,12 +267,12 @@ func TestOverlayPath_UserDefinedCP_NewWay(t *testing.T) { entry, ok := c.entries.Load(id) require.True(t, ok) - assert.NotNil(t, entry.Projected, "user-defined CP path must produce a projected profile") - require.NotNil(t, entry.UserCPRef, "UserCPRef must be recorded for refresh") + // The contract: UserCPRef is recorded even though the overlay-CP fetch failed + // transiently, so refreshOneEntry (which only re-fetches the CP + // `if e.UserCPRef != nil`) will retry it once the transient error clears. + require.NotNil(t, entry.UserCPRef, "UserCPRef must be recorded so the reconciler retries the CP after a transient failure") assert.Equal(t, "override", entry.UserCPRef.Name) - assert.Equal(t, "uc1", entry.UserCPRV) - assert.Nil(t, entry.UserAPRef, "legacy AP ref must not be set on the new path") - assert.Nil(t, entry.UserNNRef, "legacy NN ref must not be set on the new path") + assert.Equal(t, "default", entry.UserCPRef.Namespace) } // TestDeleteContainer_LockAndCleanup verifies that deleteContainer removes diff --git a/pkg/objectcache/containerprofilecache/export_test.go b/pkg/objectcache/containerprofilecache/export_test.go index c5277665c..fe3ae6ee0 100644 --- a/pkg/objectcache/containerprofilecache/export_test.go +++ b/pkg/objectcache/containerprofilecache/export_test.go @@ -36,15 +36,3 @@ func (c *ContainerProfileCacheImpl) WarmPendingForTest(ids []string) { c.pending.Delete(id) } } - -// SeedEntryWithOverlayForTest seeds an entry with user AP and NN overlay refs. -// Pass empty strings to leave a ref nil. -func (c *ContainerProfileCacheImpl) SeedEntryWithOverlayForTest(containerID string, entry *CachedContainerProfile, apNS, apName, nnNS, nnName string) { - if apName != "" { - entry.UserAPRef = &namespacedName{Namespace: apNS, Name: apName} - } - if nnName != "" { - entry.UserNNRef = &namespacedName{Namespace: nnNS, Name: nnName} - } - c.entries.Set(containerID, entry) -} diff --git a/pkg/objectcache/containerprofilecache/reconciler.go b/pkg/objectcache/containerprofilecache/reconciler.go index cae1e7282..e89f5982d 100644 --- a/pkg/objectcache/containerprofilecache/reconciler.go +++ b/pkg/objectcache/containerprofilecache/reconciler.go @@ -366,46 +366,10 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri userManagedNN = nil } } - var userAP *v1beta1.ApplicationProfile - var userNN *v1beta1.NetworkNeighborhood - if e.UserAPRef != nil { - var userAPErr error - _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userAP, userAPErr = c.storageClient.GetApplicationProfile(rctx, e.UserAPRef.Namespace, e.UserAPRef.Name) - return userAPErr - }) - if userAPErr != nil && e.UserAPRV != "" { - logger.L().Debug("refreshOneEntry: user-defined AP fetch failed; keeping cached entry", - helpers.String("containerID", id), - helpers.String("name", e.UserAPRef.Name), - helpers.Error(userAPErr)) - return - } - if userAPErr != nil { - userAP = nil - } - } - if e.UserNNRef != nil { - var userNNErr error - _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userNN, userNNErr = c.storageClient.GetNetworkNeighborhood(rctx, e.UserNNRef.Namespace, e.UserNNRef.Name) - return userNNErr - }) - if userNNErr != nil && e.UserNNRV != "" { - logger.L().Debug("refreshOneEntry: user-defined NN fetch failed; keeping cached entry", - helpers.String("containerID", id), - helpers.String("name", e.UserNNRef.Name), - helpers.Error(userNNErr)) - return - } - if userNNErr != nil { - userNN = nil - } - } - // Re-fetch the user-defined ContainerProfile (migrated "new way") when the - // entry was built from one. It is the authoritative base; a transient fetch - // error keeps the entry as-is. + // entry was built from one. It is the authoritative base and the only + // user-defined source (the legacy AP/NN overlay is no longer supported); a + // transient fetch error keeps the entry as-is. var userDefinedCP *v1beta1.ContainerProfile if e.UserCPRef != nil { var userCPErr error @@ -438,13 +402,11 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri rvsMatchCP(userDefinedCP, e.UserCPRV) && rvsMatchAP(userManagedAP, e.UserManagedAPRV) && rvsMatchNN(userManagedNN, e.UserManagedNNRV) && - rvsMatchAP(userAP, e.UserAPRV) && - rvsMatchNN(userNN, e.UserNNRV) && e.SpecHash == currentSpecHash { return } - c.rebuildEntryFromSources(id, e, cp, userDefinedCP, userManagedAP, userManagedNN, userAP, userNN) + c.rebuildEntryFromSources(id, e, cp, userDefinedCP, userManagedAP, userManagedNN) } // rvsMatchCP, rvsMatchAP, rvsMatchNN return true when either (a) the object is @@ -482,8 +444,6 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( userDefinedCP *v1beta1.ContainerProfile, userManagedAP *v1beta1.ApplicationProfile, userManagedNN *v1beta1.NetworkNeighborhood, - userAP *v1beta1.ApplicationProfile, - userNN *v1beta1.NetworkNeighborhood, ) { pod := c.k8sObjectCache.GetPod(prev.Namespace, prev.PodName) @@ -527,20 +487,14 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( } projected := effectiveCP - // Ladder pass #1: user-managed "ug-" AP + NN. + // User-managed "ug-" AP + NN overlay merge. (The label-referenced + // user-defined overlay is a whole ContainerProfile adopted directly as + // effectiveCP above — there is no separate AP/NN merge pass anymore.) if userManagedAP != nil || userManagedNN != nil { p, warnings := projectUserProfiles(projected, userManagedAP, userManagedNN, pod, prev.ContainerName) projected = p c.emitOverlayMetrics(userManagedAP, userManagedNN, warnings) } - // Ladder pass #2: label-referenced user overlay AP + NN. - var userWarnings []partialProfileWarning - if userAP != nil || userNN != nil { - p, w := projectUserProfiles(projected, userAP, userNN, pod, prev.ContainerName) - projected = p - userWarnings = w - } - c.emitOverlayMetrics(userAP, userNN, userWarnings) // Rebuild the call-stack search tree from the projected profile. tree := callstackcache.NewCallStackSearchTree() @@ -572,34 +526,23 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( RV: rvOfCP(cp), UserManagedAPRV: rvOfAP(userManagedAP), UserManagedNNRV: rvOfNN(userManagedNN), - UserAPRV: rvOfAP(userAP), - UserNNRV: rvOfNN(userNN), UserCPRV: rvOfCP(userDefinedCP), } if userDefinedCP != nil { + // The user-authored CP is authoritative and complete by definition (no + // learning-lifecycle annotations); force the terminal state so the rule + // engine enforces it. newEntry.UserCPRef = &namespacedName{Namespace: userDefinedCP.Namespace, Name: userDefinedCP.Name} - // A user-authored profile is complete by definition (no learning-lifecycle - // annotations); force the terminal state so the rule engine enforces it. newEntry.State = &objectcache.ProfileState{ Status: helpersv1.Completed, Completion: helpersv1.Full, Name: userDefinedCP.Name, } } else if prev.UserCPRef != nil { + // No CP this tick (transient error or not-yet-landed): keep the ref so + // the reconciler retries the CP on the next tick. newEntry.UserCPRef = prev.UserCPRef } - if userAP != nil { - newEntry.UserAPRef = &namespacedName{Namespace: userAP.Namespace, Name: userAP.Name} - } else if prev.UserAPRef != nil { - // Preserve the ref so subsequent ticks still know to re-fetch the - // overlay (e.g. transient fetch error during this tick). - newEntry.UserAPRef = prev.UserAPRef - } - if userNN != nil { - newEntry.UserNNRef = &namespacedName{Namespace: userNN.Namespace, Name: userNN.Name} - } else if prev.UserNNRef != nil { - newEntry.UserNNRef = prev.UserNNRef - } c.entries.Set(id, newEntry) } diff --git a/pkg/objectcache/containerprofilecache/reconciler_test.go b/pkg/objectcache/containerprofilecache/reconciler_test.go index cbbe9f269..6db8c1e45 100644 --- a/pkg/objectcache/containerprofilecache/reconciler_test.go +++ b/pkg/objectcache/containerprofilecache/reconciler_test.go @@ -341,110 +341,6 @@ func TestReconcilerExitsOnCtxCancel(t *testing.T) { // test is only that iteration stopped early. } -// TestRefreshFastSkipWhenAllRVsMatch — delta #4. When CP RV and both overlay -// RVs match the cached values, refreshOneEntry returns without rebuilding. -func TestRefreshFastSkipWhenAllRVsMatch(t *testing.T) { - cp := &v1beta1.ContainerProfile{ObjectMeta: metav1.ObjectMeta{ - Name: "cp", Namespace: "default", ResourceVersion: "100", - Annotations: map[string]string{helpersv1.StatusMetadataKey: helpersv1.Completed}, - }} - ap := &v1beta1.ApplicationProfile{ObjectMeta: metav1.ObjectMeta{Name: "override", Namespace: "default", ResourceVersion: "50"}} - nn := &v1beta1.NetworkNeighborhood{ObjectMeta: metav1.ObjectMeta{Name: "override", Namespace: "default", ResourceVersion: "60"}} - client := &countingProfileClient{cp: cp, ap: ap, nn: nn} - k8s := newControllableK8sCache() - metrics := newCountingMetrics() - c := newReconcilerCache(t, client, k8s, metrics) - - id := "c1" - entry := &CachedContainerProfile{ - Projected: Apply(nil, cp, nil), - State: &objectcache.ProfileState{Name: cp.Name}, - ContainerName: "nginx", - PodName: "nginx-abc", - Namespace: "default", - PodUID: "uid-1", - CPName: "cp", - UserAPRef: &namespacedName{Namespace: "default", Name: "override"}, - UserNNRef: &namespacedName{Namespace: "default", Name: "override"}, - RV: "100", - UserAPRV: "50", - UserNNRV: "60", - } - c.entries.Set(id, entry) - - c.refreshAllEntries(context.Background()) - - // Fetched CP once + overlays once each to check RVs; then fast-skipped. - assert.Equal(t, int64(1), client.cpCalls.Load(), "CP should be fetched once") - assert.Equal(t, int64(1), client.apCalls.Load(), "AP should be fetched once for RV check") - assert.Equal(t, int64(1), client.nnCalls.Load(), "NN should be fetched once for RV check") - - stored, ok := c.entries.Load(id) - require.True(t, ok) - // Same pointer: the entry was NOT rebuilt. - assert.Same(t, entry, stored, "entry must not be replaced on fast-skip") - // No legacy-load metric emitted on fast-skip. - assert.Equal(t, 0, metrics.legacyLoad(kindApplication, completenessFull)) - assert.Equal(t, 0, metrics.legacyLoad(kindNetwork, completenessFull)) -} - -// TestRefreshRebuildsOnUserAPChange — entry has stale UserAPRV; refresh sees -// a newer AP RV and rebuilds. -func TestRefreshRebuildsOnUserAPChange(t *testing.T) { - cp := &v1beta1.ContainerProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cp", Namespace: "default", ResourceVersion: "100", - Annotations: map[string]string{helpersv1.StatusMetadataKey: helpersv1.Completed}, - }, - Spec: v1beta1.ContainerProfileSpec{Capabilities: []string{"SYS_PTRACE"}}, - } - ap := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "override", Namespace: "default", ResourceVersion: "51"}, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "nginx", - Capabilities: []string{"NET_BIND_SERVICE"}, - }}, - }, - } - client := &countingProfileClient{cp: cp, ap: ap} - k8s := newControllableK8sCache() - metrics := newCountingMetrics() - c := newReconcilerCache(t, client, k8s, metrics) - - id := "c1" - entry := &CachedContainerProfile{ - Projected: Apply(nil, cp, nil), - State: &objectcache.ProfileState{Name: cp.Name}, - ContainerName: "nginx", - PodName: "nginx-abc", - Namespace: "default", - PodUID: "uid-1", - CPName: "cp", - UserAPRef: &namespacedName{Namespace: "default", Name: "override"}, - RV: "100", - UserAPRV: "50", // stale: storage now returns 51 - } - c.entries.Set(id, entry) - - c.SetProjectionSpec(objectcache.RuleProjectionSpec{ - Capabilities: objectcache.FieldSpec{InUse: true, All: true}, - Hash: "test-caps", - }) - c.refreshAllEntries(context.Background()) - - stored, ok := c.entries.Load(id) - require.True(t, ok) - assert.NotSame(t, entry, stored, "entry must be replaced when user-AP RV changes") - assert.Equal(t, "51", stored.UserAPRV, "new UserAPRV must be recorded") - caps := make([]string, 0, len(stored.Projected.Capabilities.Values)) - for cap := range stored.Projected.Capabilities.Values { - caps = append(caps, cap) - } - assert.ElementsMatch(t, []string{"SYS_PTRACE", "NET_BIND_SERVICE"}, caps, - "rebuilt projection must include merged overlay capabilities") -} - // TestRefreshRebuildsOnCPChange — CP RV changed; entry rebuilds with fresh CP. func TestRefreshRebuildsOnCPChange(t *testing.T) { cp := &v1beta1.ContainerProfile{ @@ -473,82 +369,6 @@ func TestRefreshRebuildsOnCPChange(t *testing.T) { assert.Equal(t, "101", stored.RV, "RV must update to the fresh CP's version") } -// TestT8_EndToEndRefreshUpdatesProjection — delta #5. Mutate the user-AP in -// the stubbed storage so its RV + execs change; assert the cached projection -// reflects the new execs AND that the legacy-load metric was re-emitted. -func TestT8_EndToEndRefreshUpdatesProjection(t *testing.T) { - cp := &v1beta1.ContainerProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cp", Namespace: "default", ResourceVersion: "100", - Annotations: map[string]string{helpersv1.StatusMetadataKey: helpersv1.Completed}, - }, - Spec: v1beta1.ContainerProfileSpec{ - Execs: []v1beta1.ExecCalls{{Path: "/bin/base", Args: []string{"a"}}}, - }, - } - ap := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "override", Namespace: "default", ResourceVersion: "50"}, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "nginx", - Execs: []v1beta1.ExecCalls{{Path: "/bin/old", Args: []string{"x"}}}, - }}, - }, - } - client := &countingProfileClient{cp: cp, ap: ap} - k8s := newControllableK8sCache() - metrics := newCountingMetrics() - c := newReconcilerCache(t, client, k8s, metrics) - - id := "c1" - entry := &CachedContainerProfile{ - Projected: Apply(nil, cp, nil), - State: &objectcache.ProfileState{Name: cp.Name}, - ContainerName: "nginx", - PodName: "nginx-abc", - Namespace: "default", - PodUID: "uid-1", - CPName: "cp", - UserAPRef: &namespacedName{Namespace: "default", Name: "override"}, - RV: "100", - UserAPRV: "50", - } - c.entries.Set(id, entry) - - // Mutate storage: new AP RV + new execs. - client.ap = &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "override", Namespace: "default", ResourceVersion: "51"}, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "nginx", - Execs: []v1beta1.ExecCalls{{Path: "/bin/new", Args: []string{"y"}}}, - }}, - }, - } - - c.SetProjectionSpec(objectcache.RuleProjectionSpec{ - Execs: objectcache.FieldSpec{InUse: true, All: true}, - Hash: "test-execs", - }) - c.refreshAllEntries(context.Background()) - - stored, ok := c.entries.Load(id) - require.True(t, ok) - assert.Equal(t, "51", stored.UserAPRV, "refresh must record the new user-AP RV") - - // The projection must include the new exec (merged on top of the base CP's exec). - var paths []string - for path := range stored.Projected.Execs.Values { - paths = append(paths, path) - } - assert.Contains(t, paths, "/bin/base", "base CP exec must be preserved") - assert.Contains(t, paths, "/bin/new", "new user-AP exec must be projected into the cache") - assert.NotContains(t, paths, "/bin/old", "stale user-AP exec must NOT be in the projection") - - assert.GreaterOrEqual(t, metrics.legacyLoad(kindApplication, completenessFull), 1, - "refresh with user-AP overlay must emit full-load metric") -} - // TestRefreshNoEntryWhenCPGetFails — storage error on CP keeps the existing // entry unchanged (no deletion). func TestRefreshNoEntryWhenCPGetFails(t *testing.T) { @@ -585,10 +405,6 @@ func TestRefreshPreservesEntryOnTransientOverlayError(t *testing.T) { workloadName string userManagedAPRV string userManagedNNRV string - userAPRef *namespacedName - userAPRV string - userNNRef *namespacedName - userNNRV string } tests := []struct { name string @@ -612,22 +428,6 @@ func TestRefreshPreservesEntryOnTransientOverlayError(t *testing.T) { userManagedNNRV: "7", }, }, - { - name: "user-defined AP timeout preserves entry", - apErr: true, - overlay: overlayFields{ - userAPRef: &namespacedName{Namespace: "default", Name: "override"}, - userAPRV: "50", - }, - }, - { - name: "user-defined NN timeout preserves entry", - nnErr: true, - overlay: overlayFields{ - userNNRef: &namespacedName{Namespace: "default", Name: "override"}, - userNNRV: "60", - }, - }, } for _, tc := range tests { @@ -657,10 +457,6 @@ func TestRefreshPreservesEntryOnTransientOverlayError(t *testing.T) { WorkloadName: tc.overlay.workloadName, UserManagedAPRV: tc.overlay.userManagedAPRV, UserManagedNNRV: tc.overlay.userManagedNNRV, - UserAPRef: tc.overlay.userAPRef, - UserAPRV: tc.overlay.userAPRV, - UserNNRef: tc.overlay.userNNRef, - UserNNRV: tc.overlay.userNNRV, } c.entries.Set(id, entry) @@ -672,8 +468,6 @@ func TestRefreshPreservesEntryOnTransientOverlayError(t *testing.T) { // Overlay RVs must be unchanged (not cleared to ""). assert.Equal(t, tc.overlay.userManagedAPRV, stored.UserManagedAPRV) assert.Equal(t, tc.overlay.userManagedNNRV, stored.UserManagedNNRV) - assert.Equal(t, tc.overlay.userAPRV, stored.UserAPRV) - assert.Equal(t, tc.overlay.userNNRV, stored.UserNNRV) }) } } @@ -983,38 +777,6 @@ func TestPartialCP_PreRunning_Accepted(t *testing.T) { assert.Equal(t, 0, c.pending.Len(), "not pending when accepted") } -// TestOverlayLabel_TransientFetchFailure_RefsRetained verifies that when -// UserDefinedProfileMetadataKey is set but the user-AP/NN fetch fails, the -// entry still records UserAPRef / UserNNRef so the refresh loop can re-fetch -// on subsequent ticks instead of permanently dropping the overlay. -func TestOverlayLabel_TransientFetchFailure_RefsRetained(t *testing.T) { - cp := &v1beta1.ContainerProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cp-with-overlay", Namespace: "default", ResourceVersion: "1", - Annotations: map[string]string{helpersv1.StatusMetadataKey: helpersv1.Completed}, - }, - } - // Overlay fetch returns an error; the base CP is fine. - client := &fakeProfileClient{cp: cp, overlayOnly: "override", apErr: assertErrNotFound("override"), nnErr: assertErrNotFound("override")} - c, k8s := newTestCache(t, client) - - id := "container-transient-overlay" - primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") - - // Build the container with the overlay label set. - ct := eventContainer(id) - ct.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "override"} - - require.NoError(t, c.addContainer(ct, context.Background())) - - entry, ok := c.entries.Load(id) - require.True(t, ok, "entry stored with base CP even if overlay fetch failed") - require.NotNil(t, entry.UserAPRef, "UserAPRef retained for refresh retry") - require.NotNil(t, entry.UserNNRef, "UserNNRef retained for refresh retry") - assert.Equal(t, "override", entry.UserAPRef.Name) - assert.Equal(t, "override", entry.UserNNRef.Name) -} - // TestRefreshDoesNotResurrectDeletedEntry verifies the Phase-4 reviewer race: // refreshAllEntries snapshots entries without a lock; if deleteContainer // removes the entry before refreshOneEntry takes the lock, the refresh must @@ -1048,42 +810,6 @@ func TestRefreshDoesNotResurrectDeletedEntry(t *testing.T) { assert.Nil(t, c.GetProjectedContainerProfile(id), "refresh must not resurrect deleted entry") } -// TestUserDefinedProfileOnly_NoBaseCP verifies that a container with only a -// user-defined AP/NN (no base CP yet) still gets a cache entry, mirroring the -// legacy behavior where user-defined profiles were stored directly. -func TestUserDefinedProfileOnly_NoBaseCP(t *testing.T) { - userAP := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "user-override", Namespace: "default", ResourceVersion: "10"}, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{ - {Name: "nginx", Capabilities: []string{"CAP_NET_ADMIN"}}, - }, - }, - } - // Base CP fetch fails (404); only the overlay exists. - client := &fakeProfileClient{cp: nil, cpErr: assertErrNotFound("no-base"), ap: userAP} - c, k8s := newTestCache(t, client) - - c.SetProjectionSpec(objectcache.RuleProjectionSpec{ - Capabilities: objectcache.FieldSpec{InUse: true, All: true}, - Execs: objectcache.FieldSpec{InUse: true, All: true}, - Hash: "user-only-test", - }) - - id := "container-user-only" - primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") - ct := eventContainer(id) - ct.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "user-override"} - - require.NoError(t, c.addContainer(ct, context.Background())) - - cached := c.GetProjectedContainerProfile(id) - require.NotNil(t, cached, "entry populated from user-AP even without base CP") - // The synthesized CP + projection should carry the user AP's capabilities. - _, hasCap := cached.Capabilities.Values["CAP_NET_ADMIN"] - assert.True(t, hasCap, "projected entry must contain CAP_NET_ADMIN from user-AP") -} - // primePreRunningSharedData is a variant of primeSharedData that sets the // PreRunningContainer flag. func primePreRunningSharedData(t *testing.T, k8s *objectcache.K8sObjectCacheMock, containerID, wlid string) { diff --git a/pkg/objectcache/containerprofilecache/t8_overlay_refresh_test.go b/pkg/objectcache/containerprofilecache/t8_overlay_refresh_test.go deleted file mode 100644 index 4bf4496ef..000000000 --- a/pkg/objectcache/containerprofilecache/t8_overlay_refresh_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package containerprofilecache_test - -// TestT8_EndToEndRefreshUpdatesProjection mirrors the same-named unit test from -// reconciler_test.go using only the public / test-helper API so it can live at -// the integration test level (tests/containerprofilecache/). -// -// Scenario: an entry backed by CP (RV=100) + user-AP overlay (RV=50) is seeded -// via SeedEntryWithOverlayForTest. Storage is mutated to serve a new AP -// (RV=51, different execs). A single RefreshAllEntriesForTest call must rebuild -// the projection so the cached execs reflect the new AP, not the stale one. - -import ( - "context" - "testing" - "time" - - helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" - "github.com/kubescape/node-agent/pkg/config" - "github.com/kubescape/node-agent/pkg/objectcache" - cpc "github.com/kubescape/node-agent/pkg/objectcache/containerprofilecache" - "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func TestT8_EndToEndRefreshUpdatesProjection(t *testing.T) { - cp := &v1beta1.ContainerProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cp", - Namespace: "default", - ResourceVersion: "100", - Annotations: map[string]string{helpersv1.StatusMetadataKey: helpersv1.Completed}, - }, - Spec: v1beta1.ContainerProfileSpec{ - Execs: []v1beta1.ExecCalls{{Path: "/bin/base", Args: []string{"a"}}}, - }, - } - apV1 := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: "override", - Namespace: "default", - ResourceVersion: "50", - }, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "nginx", - Execs: []v1beta1.ExecCalls{{Path: "/bin/old", Args: []string{"x"}}}, - }}, - }, - } - apV2 := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: "override", - Namespace: "default", - ResourceVersion: "51", - }, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "nginx", - Execs: []v1beta1.ExecCalls{{Path: "/bin/new", Args: []string{"y"}}}, - }}, - }, - } - - store := newFakeStorage(cp) - store.mu.Lock() - store.ap = apV1 - store.mu.Unlock() - - k8s := newFakeK8sCache() - cfg := config.Config{ - ProfilesCacheRefreshRate: 30 * time.Second, - StorageRPCBudget: 500 * time.Millisecond, - } - cache := cpc.NewContainerProfileCache(cfg, store, k8s, nil) - - const id = "c1" - // Seed a projected entry with a stale UserAPRV so refresh sees the RV change. - cache.SeedEntryWithOverlayForTest(id, &cpc.CachedContainerProfile{ - Projected: cpc.Apply(nil, cp, nil), - State: &objectcache.ProfileState{Name: cp.Name}, - ContainerName: "nginx", - PodName: "nginx-abc", - Namespace: "default", - PodUID: "uid-1", - CPName: "cp", - RV: "100", - UserAPRV: "50", // stale — triggers rebuild when storage returns RV=51 - }, "default", "override", "", "") - - // Advance storage to apV2 (RV=51). The reconciler will see the RV mismatch - // and rebuild the projection from cp + apV2. - store.mu.Lock() - store.ap = apV2 - store.mu.Unlock() - - cache.SetProjectionSpec(objectcache.RuleProjectionSpec{ - Execs: objectcache.FieldSpec{InUse: true, All: true}, - Hash: "test-execs", - }) - cache.RefreshAllEntriesForTest(context.Background()) - - pcp := cache.GetProjectedContainerProfile(id) - require.NotNil(t, pcp, "entry must remain after refresh") - - var paths []string - for path := range pcp.Execs.Values { - paths = append(paths, path) - } - assert.Contains(t, paths, "/bin/base", "base CP exec must be preserved after overlay refresh") - assert.Contains(t, paths, "/bin/new", "new user-AP exec must appear in the rebuilt projection") - assert.NotContains(t, paths, "/bin/old", "stale user-AP exec must NOT survive the rebuild") -} diff --git a/tests/resources/network-wildcards-cp/13-dns-trailing-dot-normalisation.yaml b/tests/resources/network-wildcards-cp/13-dns-trailing-dot-normalisation.yaml index c9cabe86d..2bd56d58f 100644 --- a/tests/resources/network-wildcards-cp/13-dns-trailing-dot-normalisation.yaml +++ b/tests/resources/network-wildcards-cp/13-dns-trailing-dot-normalisation.yaml @@ -29,6 +29,6 @@ spec: type: external dnsNames: - "api.stripe.com." # canonical FQDN form - - "api.github.com" # without trailing dot — equivalent + - "api.stripe.com" # same host, without trailing dot — must compare equal ports: - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/nnlint_test.go b/tests/resources/nnlint_test.go new file mode 100644 index 000000000..4ff13283b --- /dev/null +++ b/tests/resources/nnlint_test.go @@ -0,0 +1,350 @@ +// Network-endpoint fixture lint tests — the Test_28 (network) counterpart of +// aplint_test.go (kubescape/node-agent#847, LintApplicationProfileYAML). +// +// Validates every user-authored network surface under tests/resources/ — the +// migrated ContainerProfile authoring examples (spec.egress / spec.ingress) and +// the NetworkNeighborhood fixtures (spec.containers[].egress/ingress) — against +// the v0.0.2 endpoint grammar: DNS wildcard tokens, IP/CIDR/sentinel forms, the +// deprecated singular-vs-plural IP fields, and port/protocol shape. +// +// Runs as a regular `go test ./...` — no component tag, no kind cluster. +// +// LintNetworkProfileYAML is exported and returns []NetViolation rather than +// calling t.Errorf directly, so this file can be lifted verbatim into a bobctl +// subcommand `bobctl lint ` with no testing-package dependency. +// The Test_* functions below are thin wrappers that turn violations into +// t.Errorf calls. Identifiers are net-prefixed so this coexists with +// aplint_test.go's Violation / templatePlaceholderRe in the same package. +package resources + +import ( + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "sigs.k8s.io/yaml" +) + +// netTemplatePlaceholderRe matches an unsubstituted template token such as +// {name} or {namespace}: a flow scalar {identifier} with no colon, which never +// appears in a concrete applied resource. Such fixtures are rendered at runtime +// via substitution and cannot be strict-parsed as-is, so the directory scan +// skips them. The linter itself stays strict for real callers like bobctl. +var netTemplatePlaceholderRe = regexp.MustCompile(`\{[a-zA-Z_][a-zA-Z0-9_]*\}`) + +// dnsWildcardLabel is the one-label DynamicIdentifier ("⋯", U+22EF) and the +// leading/trailing RFC-4592 wildcard ("*"). Both are valid ONLY as a whole DNS +// label; embedded in a longer label (e.g. "**", "*foo", "⋯⋯") they are invalid. +// Mirrors storage/pkg/registry/file/dynamicpathdetector; duplicated so this +// linter has zero dependency on the storage module. +const ( + dnsDynamicLabel = "⋯" + dnsWildcardLabel = "*" +) + +// netProfileLike captures only the network fields we lint. We do not import the +// storage v1beta1 types so the linter runs in isolation (bobctl, CI, etc). +// Both surfaces are accepted: ContainerProfile puts egress/ingress directly on +// spec; NetworkNeighborhood nests them under spec.containers[]. +type netProfileLike struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Metadata struct { + Name string `json:"name"` + } `json:"metadata"` + Spec struct { + Egress []netEndpoint `json:"egress"` + Ingress []netEndpoint `json:"ingress"` + Containers []struct { + Name string `json:"name"` + Egress []netEndpoint `json:"egress"` + Ingress []netEndpoint `json:"ingress"` + } `json:"containers"` + } `json:"spec"` +} + +type netEndpoint struct { + Identifier string `json:"identifier"` + Type string `json:"type"` + DNS string `json:"dns"` // deprecated singular + DNSNames []string `json:"dnsNames"` // v0.0.2 list form + IPAddress string `json:"ipAddress"` + IPAddresses []string `json:"ipAddresses"` + Ports []netPort `json:"ports"` + // Selector-based targets (translate to NetworkPolicy egress rules). Raw + // so the linter needn't import metav1; presence (non-null) counts as a + // declared target for R-NN-12. + PodSelector json.RawMessage `json:"podSelector"` + NamespaceSelector json.RawMessage `json:"namespaceSelector"` +} + +// hasSelector reports whether a raw selector field was set to a real object +// (not absent, not explicit null). +func hasSelector(raw json.RawMessage) bool { + s := strings.TrimSpace(string(raw)) + return s != "" && s != "null" +} + +type netPort struct { + Name string `json:"name"` + Protocol string `json:"protocol"` + Port int `json:"port"` +} + +// NetViolation is a single rule failure. Returned as data so callers can treat +// lint output however they like (CLI exit code, JSON, t.Errorf). +type NetViolation struct { + Rule string + Path string + Msg string +} + +func (v NetViolation) String() string { + if v.Path != "" { + return fmt.Sprintf("[%s] %s: %s", v.Rule, v.Path, v.Msg) + } + return fmt.Sprintf("[%s] %s", v.Rule, v.Msg) +} + +// LintNetworkProfileYAML parses one YAML doc as a network profile and runs all +// rules. Empty slice == clean. Pure function — no I/O, no testing coupling. +// +// Rule IDs: +// +// R-NN-00 — yaml parse failure +// R-NN-01 — kind must be ContainerProfile or NetworkNeighborhood +// R-NN-02 — at least one endpoint (egress or ingress) declared +// R-NN-10 — endpoint identifier non-empty +// R-NN-11 — endpoint type in {internal, external} (or unset) +// R-NN-12 — endpoint declares at least one target (dnsNames/ipAddresses/dns/ipAddress) +// R-NN-13 — dnsNames wildcard tokens are whole-label; no recursive "**", no ascii "..." +// R-NN-14 — an entry MUST NOT set both singular ipAddress and plural ipAddresses +// R-NN-15 — ipAddresses entries are a literal IP, a CIDR, or the "*" sentinel +// R-NN-20 — ports use TCP/UDP and a port in 1..65535 +func LintNetworkProfileYAML(doc []byte, sourceLabel string) []NetViolation { + var np netProfileLike + if err := yaml.Unmarshal(doc, &np); err != nil { + return []NetViolation{{Rule: "R-NN-00", Path: sourceLabel, Msg: fmt.Sprintf("yaml parse: %v", err)}} + } + return LintNetworkProfile(&np, sourceLabel) +} + +// LintNetworkProfile runs every rule against an already-parsed profile. +func LintNetworkProfile(np *netProfileLike, src string) []NetViolation { + var v []NetViolation + add := func(rule, msg string) { v = append(v, NetViolation{Rule: rule, Path: src, Msg: msg}) } + + if np.Kind != "ContainerProfile" && np.Kind != "NetworkNeighborhood" { + add("R-NN-01", fmt.Sprintf("kind is %q, expected ContainerProfile or NetworkNeighborhood", np.Kind)) + } + + // Gather endpoints from both surfaces, labelling direction. + type dirEP struct { + dir string + ep netEndpoint + } + var eps []dirEP + for _, e := range np.Spec.Egress { + eps = append(eps, dirEP{"egress", e}) + } + for _, e := range np.Spec.Ingress { + eps = append(eps, dirEP{"ingress", e}) + } + for _, c := range np.Spec.Containers { + for _, e := range c.Egress { + eps = append(eps, dirEP{"containers[" + c.Name + "].egress", e}) + } + for _, e := range c.Ingress { + eps = append(eps, dirEP{"containers[" + c.Name + "].ingress", e}) + } + } + if len(eps) == 0 { + add("R-NN-02", "no egress or ingress endpoints declared") + } + + for _, de := range eps { + lintEndpoint(de.dir, de.ep, add) + } + return v +} + +func lintEndpoint(dir string, e netEndpoint, add func(rule, msg string)) { + where := func(msg string) string { return fmt.Sprintf("%s[%s]: %s", dir, e.Identifier, msg) } + + if strings.TrimSpace(e.Identifier) == "" { + add("R-NN-10", dir+": endpoint has empty identifier") + } + if e.Type != "" && e.Type != "internal" && e.Type != "external" { + add("R-NN-11", where(fmt.Sprintf("type %q is not internal|external", e.Type))) + } + if len(e.DNSNames) == 0 && len(e.IPAddresses) == 0 && e.DNS == "" && e.IPAddress == "" && + !hasSelector(e.PodSelector) && !hasSelector(e.NamespaceSelector) { + add("R-NN-12", where("endpoint declares no target (dnsNames/ipAddresses/dns/ipAddress/selector)")) + } + if e.IPAddress != "" && len(e.IPAddresses) > 0 { + add("R-NN-14", where("sets both singular ipAddress and plural ipAddresses — pick one")) + } + + for _, d := range e.DNSNames { + if msg := dnsNameProblem(d); msg != "" { + add("R-NN-13", where(fmt.Sprintf("dnsName %q: %s", d, msg))) + } + } + for _, ip := range e.IPAddresses { + if !validIPEntry(ip) { + add("R-NN-15", where(fmt.Sprintf("ipAddresses entry %q is not an IP, CIDR, or \"*\" sentinel", ip))) + } + } + for _, p := range e.Ports { + if p.Protocol != "TCP" && p.Protocol != "UDP" { + add("R-NN-20", where(fmt.Sprintf("port %q protocol %q is not TCP|UDP", p.Name, p.Protocol))) + } + if p.Port < 1 || p.Port > 65535 { + add("R-NN-20", where(fmt.Sprintf("port %q value %d out of range 1..65535", p.Name, p.Port))) + } + } +} + +// dnsNameProblem returns "" if the DNS name is well-formed, else a reason. +// Trailing-dot is NOT required (it is normalised on read; fixtures 12/13 omit +// it deliberately). Wildcard tokens must be whole labels. +func dnsNameProblem(d string) string { + if d == "" { + return "empty" + } + if strings.Contains(d, "...") { + return `contains "..." — use the single-codepoint ellipsis "⋯" (U+22EF) for a mid-label wildcard` + } + for _, label := range strings.Split(d, ".") { + if label == "" { + continue // apex / trailing-dot slot + } + if strings.Contains(label, dnsWildcardLabel) && label != dnsWildcardLabel { + return fmt.Sprintf("label %q — %q is valid only as a whole label (no %q, %q, etc.)", + label, dnsWildcardLabel, "**", "*foo") + } + if strings.Contains(label, dnsDynamicLabel) && label != dnsDynamicLabel { + return fmt.Sprintf("label %q — %q is valid only as a whole label", label, dnsDynamicLabel) + } + } + return "" +} + +// validIPEntry accepts a literal IP, a CIDR (a.b.c.d/n or v6), or "*" (the +// any-IP sentinel = 0.0.0.0/0 ∪ ::/0). +func validIPEntry(s string) bool { + if s == "*" { + return true + } + if net.ParseIP(s) != nil { + return true + } + if _, _, err := net.ParseCIDR(s); err == nil { + return true + } + return false +} + +// --- Test wrappers over the fixture directories --- + +// netFixtureGlobs are the user-authored network surfaces to lint, relative to +// this package directory (tests/resources). +var netFixtureGlobs = []string{ + "network-wildcards-cp/*.yaml", + "containerprofile-*-network.yaml", +} + +// intentionallyInvalid maps a fixture basename to the rule its doc MUST trip — +// the mirror of aplint's malformed-variant assertions. Everything else must be +// clean. +var intentionallyInvalid = map[string]string{ + "14-recursive-star-rejected.yaml": "R-NN-13", +} + +// Test_NN_LinterCatches feeds one deliberately-broken doc per rule and asserts +// the matching rule fires — proving the fixture-clean pass above is meaningful. +func Test_NN_LinterCatches(t *testing.T) { + const head = "apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1\nkind: ContainerProfile\nmetadata:\n name: bad\nspec:\n" + cases := []struct { + rule string + doc string + }{ + {"R-NN-01", "kind: Pod\nmetadata:\n name: x\nspec:\n egress:\n - {identifier: a, dnsNames: [\"x.io.\"]}"}, + {"R-NN-02", head + " matchLabels: {app: x}"}, + {"R-NN-10", head + " egress:\n - {dnsNames: [\"x.io.\"]}"}, + {"R-NN-11", head + " egress:\n - {identifier: a, type: sideways, dnsNames: [\"x.io.\"]}"}, + {"R-NN-12", head + " egress:\n - {identifier: a}"}, + {"R-NN-13", head + " egress:\n - {identifier: a, dnsNames: [\"**.example.com.\"]}"}, + {"R-NN-13", head + " egress:\n - {identifier: a, dnsNames: [\"svc.*foo.local.\"]}"}, + {"R-NN-13", head + " egress:\n - {identifier: a, dnsNames: [\"svc...local.\"]}"}, + {"R-NN-14", head + " egress:\n - {identifier: a, ipAddress: \"1.2.3.4\", ipAddresses: [\"1.2.3.4\"]}"}, + {"R-NN-15", head + " egress:\n - {identifier: a, ipAddresses: [\"not-an-ip\"]}"}, + {"R-NN-20", head + " egress:\n - {identifier: a, dnsNames: [\"x.io.\"], ports: [{name: p, protocol: SCTP, port: 80}]}"}, + {"R-NN-20", head + " egress:\n - {identifier: a, dnsNames: [\"x.io.\"], ports: [{name: p, protocol: TCP, port: 70000}]}"}, + } + for _, c := range cases { + vs := LintNetworkProfileYAML([]byte(c.doc), c.rule) + found := false + for _, v := range vs { + if v.Rule == c.rule { + found = true + } + } + if !found { + t.Errorf("expected %s to fire, got %v\n--- doc ---\n%s", c.rule, vs, c.doc) + } + } +} + +func Test_NN_FixturesLintClean(t *testing.T) { + var files []string + for _, g := range netFixtureGlobs { + m, err := filepath.Glob(g) + if err != nil { + t.Fatalf("glob %q: %v", g, err) + } + files = append(files, m...) + } + if len(files) == 0 { + t.Fatal("no network fixtures matched — wrong working dir?") + } + + for _, f := range files { + base := filepath.Base(f) + raw, err := os.ReadFile(f) + if err != nil { + t.Errorf("%s: read: %v", base, err) + continue + } + if netTemplatePlaceholderRe.Match(raw) { + t.Logf("%s: skipped (unrendered template placeholder)", base) + continue + } + for i, doc := range strings.Split(string(raw), "\n---") { + if strings.TrimSpace(doc) == "" { + continue + } + vs := LintNetworkProfileYAML([]byte(doc), fmt.Sprintf("%s#%d", base, i)) + if wantRule, bad := intentionallyInvalid[base]; bad { + found := false + for _, v := range vs { + if v.Rule == wantRule { + found = true + } + } + if !found { + t.Errorf("%s: expected a %s violation (fixture is deliberately invalid), got %v", base, wantRule, vs) + } + continue + } + for _, v := range vs { + t.Errorf("%s", v) + } + } + } +} From b5bdc015948a412dc4c411632fa5ae1d1db63008 Mon Sep 17 00:00:00 2001 From: entlein Date: Tue, 28 Jul 2026 17:05:39 +0200 Subject: [PATCH 15/29] next step of many, also opened PRs in charts, backend, synchronizer, storage etc Signed-off-by: entlein --- .../containerprofilecache.go | 71 ++--- .../containerprofilecache_test.go | 67 ++--- .../integration_helpers_test.go | 22 -- .../containerprofilecache/metrics.go | 66 ----- .../containerprofilecache/projection.go | 242 +++--------------- .../containerprofilecache/projection_test.go | 185 +++---------- .../containerprofilecache/reconciler.go | 100 ++------ .../containerprofilecache/reconciler_test.go | 209 +++++---------- pkg/storage/storage_interface.go | 4 - pkg/storage/storage_mock.go | 19 -- pkg/storage/v1/applicationprofile.go | 19 -- pkg/storage/v1/networkneighborhood.go | 19 -- 12 files changed, 208 insertions(+), 815 deletions(-) delete mode 100644 pkg/objectcache/containerprofilecache/metrics.go delete mode 100644 pkg/storage/v1/applicationprofile.go delete mode 100644 pkg/storage/v1/networkneighborhood.go diff --git a/pkg/objectcache/containerprofilecache/containerprofilecache.go b/pkg/objectcache/containerprofilecache/containerprofilecache.go index 4f277853a..1dee771f2 100644 --- a/pkg/objectcache/containerprofilecache/containerprofilecache.go +++ b/pkg/objectcache/containerprofilecache/containerprofilecache.go @@ -80,8 +80,7 @@ type CachedContainerProfile struct { WorkloadName string RV string // ContainerProfile resourceVersion at last load - UserManagedAPRV string // user-managed AP (ug-) RV at last projection, "" if absent - UserManagedNNRV string // user-managed NN (ug-) RV at last projection, "" if absent + UserManagedCPRV string // user-managed CP (ug-) RV at last projection, "" if absent UserCPRV string // user-defined ContainerProfile (label-referenced) RV at last load, "" if not used } @@ -112,10 +111,6 @@ type ContainerProfileCacheImpl struct { rpcBudget time.Duration refreshInProgress atomic.Bool - // deprecationDedup tracks (kind|ns/name@rv) keys to emit one WARN log - // per legacy CRD resource-version across the process lifetime. - deprecationDedup sync.Map - // Projection spec — installed by SetProjectionSpec when rulemanager loads rules. currentSpecMu sync.RWMutex currentSpec *objectcache.RuleProjectionSpec @@ -329,41 +324,27 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( } - // LEGACY ONLY - var userManagedAP *v1beta1.ApplicationProfile - var userManagedNN *v1beta1.NetworkNeighborhood + // User-managed overlay: the migrated "ug-" ContainerProfile + // (annotated managed-by: User), unioned on top of the base. This replaces the + // legacy ug- ApplicationProfile + NetworkNeighborhood pair. Optional: nil on 404. + var userManagedCP *v1beta1.ContainerProfile if workloadName != "" { - ugName := helpersv1.UserApplicationProfilePrefix + workloadName - var ugAPErr error - _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userManagedAP, ugAPErr = c.storageClient.GetApplicationProfile(rctx, ns, ugName) - return ugAPErr - }) - if ugAPErr != nil { - if shouldLogOptionalUserManagedFetchError(ugAPErr) { - logger.L().Debug("failed to fetch user-managed ApplicationProfile", - helpers.String("containerID", containerID), - helpers.String("namespace", ns), - helpers.String("name", ugName), - helpers.Error(ugAPErr)) - } - userManagedAP = nil - } - ugNNName := helpersv1.UserNetworkNeighborhoodPrefix + workloadName - var ugNNErr error + // UserApplicationProfilePrefix is the shared "ug-" user-managed prefix. + ugCPName := helpersv1.UserApplicationProfilePrefix + workloadName + var ugCPErr error _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userManagedNN, ugNNErr = c.storageClient.GetNetworkNeighborhood(rctx, ns, ugNNName) - return ugNNErr + userManagedCP, ugCPErr = c.storageClient.GetContainerProfile(rctx, ns, ugCPName) + return ugCPErr }) - if ugNNErr != nil { - if shouldLogOptionalUserManagedFetchError(ugNNErr) { - logger.L().Debug("failed to fetch user-managed NetworkNeighborhood", + if ugCPErr != nil { + if shouldLogOptionalUserManagedFetchError(ugCPErr) { + logger.L().Debug("failed to fetch user-managed ContainerProfile", helpers.String("containerID", containerID), helpers.String("namespace", ns), - helpers.String("name", ugNNName), - helpers.Error(ugNNErr)) + helpers.String("name", ugCPName), + helpers.Error(ugCPErr)) } - userManagedNN = nil + userManagedCP = nil } } @@ -406,9 +387,8 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( } } - // LEGACY mixed in // Need SOMETHING to cache. If we have nothing, stay pending and retry. - if cp == nil && userDefinedCP == nil && userManagedAP == nil && userManagedNN == nil { + if cp == nil && userDefinedCP == nil && userManagedCP == nil { return false } @@ -448,12 +428,10 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( helpers.String("podName", container.K8s.PodName)) } - // LEGACY - userManagedApplied := userManagedAP != nil || userManagedNN != nil - if userManagedApplied { - projected, warnings := projectUserProfiles(cp, userManagedAP, userManagedNN, pod, container.Runtime.ContainerName) - cp = projected - c.emitOverlayMetrics(userManagedAP, userManagedNN, warnings) + // User-managed "ug-" overlay pass: union the migrated ug- ContainerProfile + // on top of the base. (Legacy AP/NN overlay merge removed.) + if userManagedCP != nil { + cp = projectUserManagedCP(cp, userManagedCP) } entry := c.buildEntry(cp, pod, container, sharedData) @@ -467,11 +445,8 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( // Fill in user-managed bookkeeping so refreshOneEntry can re-fetch these // sources on every tick. WorkloadName is the "ug-" lookup prefix. entry.WorkloadName = workloadName - if userManagedAP != nil { - entry.UserManagedAPRV = userManagedAP.ResourceVersion - } - if userManagedNN != nil { - entry.UserManagedNNRV = userManagedNN.ResourceVersion + if userManagedCP != nil { + entry.UserManagedCPRV = userManagedCP.ResourceVersion } // When the overlay label is set, ALWAYS record UserCPRef so the reconciler diff --git a/pkg/objectcache/containerprofilecache/containerprofilecache_test.go b/pkg/objectcache/containerprofilecache/containerprofilecache_test.go index ec2435ed9..1f024e47b 100644 --- a/pkg/objectcache/containerprofilecache/containerprofilecache_test.go +++ b/pkg/objectcache/containerprofilecache/containerprofilecache_test.go @@ -3,6 +3,7 @@ package containerprofilecache import ( "context" "errors" + "strings" "testing" "time" @@ -26,29 +27,22 @@ import ( // always returns the same CP pointer (so the fast-path can be asserted via // pointer equality). type fakeProfileClient struct { - cp *v1beta1.ContainerProfile + cp *v1beta1.ContainerProfile // userCP, when non-nil, is returned by GetContainerProfile for a name // matching userCP.Name (the migrated user-defined ContainerProfile). Other // names fall through to cp. Lets tests exercise the new-way overlay path. userCP *v1beta1.ContainerProfile - ap *v1beta1.ApplicationProfile // returned for Get by ap.Name match (or any if overlayOnly is empty) - nn *v1beta1.NetworkNeighborhood - cpErr error - apErr error - nnErr error - - // userManagedAP / userManagedNN, when non-nil, are returned for any - // GetApplicationProfile / GetNetworkNeighborhood whose name starts with - // the "ug-" prefix (the convention used by legacy user-managed profiles). - // This lets tests exercise the user-managed merge path added for - // Test_12_MergingProfilesTest / Test_13_MergingNetworkNeighborhoodTest - // without fighting the overlayOnly restriction. - userManagedAP *v1beta1.ApplicationProfile - userManagedNN *v1beta1.NetworkNeighborhood - - // overlayOnly, if non-empty, restricts ap/nn returns to only the given - // name; other names return (nil, nil). Tests that mix workload-AP/NN - // with overlay-AP/NN use this to keep the fixture scoped. + cpErr error + + // userManagedCP, when non-nil, is returned by GetContainerProfile for any + // name starting with the "ug-" user-managed prefix. This is the migrated + // replacement for the legacy ug- ApplicationProfile + NetworkNeighborhood + // overlay pair and lets tests exercise the user-managed merge path. + userManagedCP *v1beta1.ContainerProfile + + // overlayOnly, if non-empty, scopes the overlay name whose GetContainerProfile + // returns a genuine NotFound (or overlayCPErr). Tests use this to keep the + // user-defined-CP fixture scoped. overlayOnly string // overlayCPErr, when non-nil, is returned by GetContainerProfile for a @@ -66,37 +60,24 @@ var _ storage.ProfileClient = (*fakeProfileClient)(nil) func TestShouldLogOptionalUserManagedFetchError(t *testing.T) { assert.False(t, shouldLogOptionalUserManagedFetchError(nil)) assert.False(t, shouldLogOptionalUserManagedFetchError( - apierrors.NewNotFound(schema.GroupResource{Group: "softwarecomposition.kubescape.io", Resource: "applicationprofiles"}, "ug-nginx"), + apierrors.NewNotFound(schema.GroupResource{Group: "softwarecomposition.kubescape.io", Resource: "containerprofiles"}, "ug-nginx"), )) assert.True(t, shouldLogOptionalUserManagedFetchError(errors.New("boom"))) } -func (f *fakeProfileClient) GetApplicationProfile(_ context.Context, _, name string) (*v1beta1.ApplicationProfile, error) { - if len(name) >= 3 && name[:3] == helpersv1.UserApplicationProfilePrefix { - return f.userManagedAP, nil - } - if f.overlayOnly != "" && name != f.overlayOnly { - return nil, nil - } - return f.ap, f.apErr -} -func (f *fakeProfileClient) GetNetworkNeighborhood(_ context.Context, _, name string) (*v1beta1.NetworkNeighborhood, error) { - if len(name) >= 3 && name[:3] == helpersv1.UserNetworkNeighborhoodPrefix { - return f.userManagedNN, nil - } - if f.overlayOnly != "" && name != f.overlayOnly { - return nil, nil - } - return f.nn, f.nnErr -} func (f *fakeProfileClient) GetContainerProfile(_ context.Context, _, name string) (*v1beta1.ContainerProfile, error) { f.getCPCalls++ + // User-managed "ug-" overlay: a single ContainerProfile, the + // migrated replacement for the legacy ug- AP/NN pair. + if strings.HasPrefix(name, helpersv1.UserApplicationProfilePrefix) { + return f.userManagedCP, nil + } if f.userCP != nil && name == f.userCP.Name { return f.userCP, nil } // The overlay label points at overlayOnly; with no user CP published at that - // name it is absent, which drives the legacy AP/NN fallback path. (The base - // CP fetch uses the derived slug, a different name, and still gets f.cp.) + // name it is absent (or a transient error). The base CP fetch uses the + // derived slug, a different name, and still gets f.cp. if f.overlayOnly != "" && name == f.overlayOnly { if f.overlayCPErr != nil { return nil, f.overlayCPErr @@ -105,12 +86,6 @@ func (f *fakeProfileClient) GetContainerProfile(_ context.Context, _, name strin } return f.cp, f.cpErr } -func (f *fakeProfileClient) ListApplicationProfiles(_ context.Context, _ string, _ int64, _ string) (*v1beta1.ApplicationProfileList, error) { - return &v1beta1.ApplicationProfileList{}, nil -} -func (f *fakeProfileClient) ListNetworkNeighborhoods(_ context.Context, _ string, _ int64, _ string) (*v1beta1.NetworkNeighborhoodList, error) { - return &v1beta1.NetworkNeighborhoodList{}, nil -} // newTestCache returns a cache wired with an in-memory K8sObjectCacheMock. func newTestCache(t *testing.T, client storage.ProfileClient) (*ContainerProfileCacheImpl, *objectcache.K8sObjectCacheMock) { diff --git a/pkg/objectcache/containerprofilecache/integration_helpers_test.go b/pkg/objectcache/containerprofilecache/integration_helpers_test.go index 4965f0c73..c72d5725f 100644 --- a/pkg/objectcache/containerprofilecache/integration_helpers_test.go +++ b/pkg/objectcache/containerprofilecache/integration_helpers_test.go @@ -55,8 +55,6 @@ func makeTestPod(name, namespace, uid string, containerStatuses []corev1.Contain type stubStorage struct { mu sync.RWMutex cp *v1beta1.ContainerProfile - ap *v1beta1.ApplicationProfile - nn *v1beta1.NetworkNeighborhood } var _ storage.ProfileClient = (*stubStorage)(nil) @@ -71,26 +69,6 @@ func (s *stubStorage) GetContainerProfile(_ context.Context, _, _ string) (*v1be return s.cp, nil } -func (s *stubStorage) GetApplicationProfile(_ context.Context, _, _ string) (*v1beta1.ApplicationProfile, error) { - s.mu.RLock() - defer s.mu.RUnlock() - return s.ap, nil -} - -func (s *stubStorage) GetNetworkNeighborhood(_ context.Context, _, _ string) (*v1beta1.NetworkNeighborhood, error) { - s.mu.RLock() - defer s.mu.RUnlock() - return s.nn, nil -} - -func (s *stubStorage) ListApplicationProfiles(_ context.Context, _ string, _ int64, _ string) (*v1beta1.ApplicationProfileList, error) { - return &v1beta1.ApplicationProfileList{}, nil -} - -func (s *stubStorage) ListNetworkNeighborhoods(_ context.Context, _ string, _ int64, _ string) (*v1beta1.NetworkNeighborhoodList, error) { - return &v1beta1.NetworkNeighborhoodList{}, nil -} - // stubK8sCache is a controllable K8sObjectCache stub. type stubK8sCache struct { mu sync.RWMutex diff --git a/pkg/objectcache/containerprofilecache/metrics.go b/pkg/objectcache/containerprofilecache/metrics.go deleted file mode 100644 index 3a3a48cee..000000000 --- a/pkg/objectcache/containerprofilecache/metrics.go +++ /dev/null @@ -1,66 +0,0 @@ -package containerprofilecache - -import ( - "fmt" - - "github.com/kubescape/go-logger" - "github.com/kubescape/go-logger/helpers" - "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" -) - -// Kind labels for ReportContainerProfileLegacyLoad and related metrics. -const ( - kindApplication = "application" - kindNetwork = "network" - - completenessFull = "full" - completenessPartial = "partial" -) - -// reportDeprecationWarn emits a one-shot WARN log for a user-authored legacy -// CRD (ApplicationProfile or NetworkNeighborhood) that was merged into the -// ContainerProfile. Dedup key is (kind, namespace, name, resourceVersion) so a -// single RV only logs once per process lifetime, even across many containers. -func (c *ContainerProfileCacheImpl) reportDeprecationWarn(kind, namespace, name, rv string, reason string) { - key := fmt.Sprintf("%s|%s/%s@%s", kind, namespace, name, rv) - if _, already := c.deprecationDedup.LoadOrStore(key, struct{}{}); already { - return - } - logger.L().Warning("ContainerProfileCache - user-authored legacy profile merged (deprecated)", - helpers.String("kind", kind), - helpers.String("namespace", namespace), - helpers.String("name", name), - helpers.String("resourceVersion", rv), - helpers.String("reason", reason)) -} - -// emitOverlayMetrics fires the per-kind completeness metric + deprecation WARN -// once per (kind, namespace, name, rv). Shared by addContainer's buildEntry -// and the reconciler's rebuildEntry so the two stay in lockstep. -func (c *ContainerProfileCacheImpl) emitOverlayMetrics( - userAP *v1beta1.ApplicationProfile, - userNN *v1beta1.NetworkNeighborhood, - warnings []partialProfileWarning, -) { - partialByKind := map[string]struct{}{} - for _, w := range warnings { - partialByKind[w.Kind] = struct{}{} - c.metricsManager.ReportContainerProfileLegacyLoad(w.Kind, completenessPartial) - c.reportDeprecationWarn(w.Kind, w.Namespace, w.Name, w.ResourceVersion, - fmt.Sprintf("pod has containers missing from user CRD: %v", w.MissingContainers)) - } - if userAP != nil { - if _, partial := partialByKind[kindApplication]; !partial { - c.metricsManager.ReportContainerProfileLegacyLoad(kindApplication, completenessFull) - } - c.reportDeprecationWarn(kindApplication, userAP.Namespace, userAP.Name, userAP.ResourceVersion, - "user-authored ApplicationProfile merged into ContainerProfile") - } - if userNN != nil { - if _, partial := partialByKind[kindNetwork]; !partial { - c.metricsManager.ReportContainerProfileLegacyLoad(kindNetwork, completenessFull) - } - c.reportDeprecationWarn(kindNetwork, userNN.Namespace, userNN.Name, userNN.ResourceVersion, - "user-authored NetworkNeighborhood merged into ContainerProfile") - } -} diff --git a/pkg/objectcache/containerprofilecache/projection.go b/pkg/objectcache/containerprofilecache/projection.go index da1e45fb2..5ff53944b 100644 --- a/pkg/objectcache/containerprofilecache/projection.go +++ b/pkg/objectcache/containerprofilecache/projection.go @@ -3,91 +3,38 @@ package containerprofilecache import ( "github.com/kubescape/node-agent/pkg/utils" "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" - corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// partialProfileWarning describes a user-authored legacy CRD that couldn't be -// fully merged into the ContainerProfile (e.g. the user CRD is missing entries -// for containers that exist in the pod spec). Emitted by the cache at merge -// time for deprecation observability. -type partialProfileWarning struct { - Kind string // "application" | "network" - Namespace string - Name string - ResourceVersion string - MissingContainers []string -} - -// projectUserProfiles overlays a user-authored ApplicationProfile and/or -// NetworkNeighborhood onto a base ContainerProfile for a single container. -// Returns a DeepCopy of the base with user fields merged in and a list of -// partial-merge warnings when the user CRD doesn't cover every container in -// the pod spec. +// projectUserManagedCP overlays a user-authored ContainerProfile (the migrated +// "ug-" user-managed overlay) onto a base ContainerProfile and +// returns a DeepCopy of the base with the user fields unioned in. // -// cp MUST be non-nil. Either (or both) of userAP / userNN may be nil; nil -// user inputs contribute no merge but also no warning. pod may be nil, in -// which case the missing-container check is skipped (but the name-based -// per-container merge still runs). -func projectUserProfiles( - cp *v1beta1.ContainerProfile, - userAP *v1beta1.ApplicationProfile, - userNN *v1beta1.NetworkNeighborhood, - pod *corev1.Pod, - containerName string, -) (projected *v1beta1.ContainerProfile, warnings []partialProfileWarning) { - projected = cp.DeepCopy() - - if userAP != nil { - if missing := mergeApplicationProfile(projected, userAP, pod, containerName); len(missing) > 0 { - warnings = append(warnings, partialProfileWarning{ - Kind: kindApplication, - Namespace: userAP.Namespace, - Name: userAP.Name, - ResourceVersion: userAP.ResourceVersion, - MissingContainers: missing, - }) - } - } - - if userNN != nil { - if missing := mergeNetworkNeighborhood(projected, userNN, pod, containerName); len(missing) > 0 { - warnings = append(warnings, partialProfileWarning{ - Kind: kindNetwork, - Namespace: userNN.Namespace, - Name: userNN.Name, - ResourceVersion: userNN.ResourceVersion, - MissingContainers: missing, - }) - } - } - - return projected, warnings -} - -// mergeApplicationProfile finds the container entry in userAP matching -// containerName (across Spec.Containers / InitContainers / EphemeralContainers) -// and merges its fields into projected.Spec. Returns the list of pod-spec -// container names that are not present anywhere in userAP.Spec. -// -// ported from pkg/objectcache/applicationprofilecache/applicationprofilecache.go:660-673 -// (mergeContainer), applied here to a single-container ContainerProfile -// instead of a full ApplicationProfile. -func mergeApplicationProfile(projected *v1beta1.ContainerProfile, userAP *v1beta1.ApplicationProfile, pod *corev1.Pod, containerName string) []string { - // Defensive copy: slices inside matched (e.g. Execs[i].Args, Opens[i].Flags, - // Endpoints[i].Methods) would otherwise alias the caller's CRD object and - // could change if the CRD is refreshed concurrently. - userAP = userAP.DeepCopy() - if matched := findUserAPContainer(userAP, containerName); matched != nil { - projected.Spec.Capabilities = append(projected.Spec.Capabilities, matched.Capabilities...) - projected.Spec.Execs = append(projected.Spec.Execs, matched.Execs...) - projected.Spec.Opens = append(projected.Spec.Opens, matched.Opens...) - projected.Spec.Syscalls = append(projected.Spec.Syscalls, matched.Syscalls...) - projected.Spec.Endpoints = append(projected.Spec.Endpoints, matched.Endpoints...) - if projected.Spec.PolicyByRuleId == nil && len(matched.PolicyByRuleId) > 0 { - projected.Spec.PolicyByRuleId = make(map[string]v1beta1.RulePolicy, len(matched.PolicyByRuleId)) - } - for k, v := range matched.PolicyByRuleId { +// The migrated overlay is a single ContainerProfile whose spec is already flat +// for one container, so the merge is a direct field union with no per-container +// lookup. userCP may be nil (no overlay); cp MUST be non-nil. +func projectUserManagedCP(cp *v1beta1.ContainerProfile, userCP *v1beta1.ContainerProfile) *v1beta1.ContainerProfile { + projected := cp.DeepCopy() + if userCP == nil { + return projected + } + // Defensive copy: appended slices (Execs[i].Args, Opens[i].Flags, …) and the + // LabelSelector would otherwise alias the caller's cached CRD object. + u := userCP.DeepCopy() + + projected.Spec.Capabilities = append(projected.Spec.Capabilities, u.Spec.Capabilities...) + projected.Spec.Execs = append(projected.Spec.Execs, u.Spec.Execs...) + projected.Spec.Opens = append(projected.Spec.Opens, u.Spec.Opens...) + projected.Spec.Syscalls = append(projected.Spec.Syscalls, u.Spec.Syscalls...) + projected.Spec.Endpoints = append(projected.Spec.Endpoints, u.Spec.Endpoints...) + projected.Spec.Ingress = mergeNetworkNeighbors(projected.Spec.Ingress, u.Spec.Ingress) + projected.Spec.Egress = mergeNetworkNeighbors(projected.Spec.Egress, u.Spec.Egress) + + if len(u.Spec.PolicyByRuleId) > 0 { + if projected.Spec.PolicyByRuleId == nil { + projected.Spec.PolicyByRuleId = make(map[string]v1beta1.RulePolicy, len(u.Spec.PolicyByRuleId)) + } + for k, v := range u.Spec.PolicyByRuleId { if existing, ok := projected.Spec.PolicyByRuleId[k]; ok { projected.Spec.PolicyByRuleId[k] = utils.MergePolicies(existing, v) } else { @@ -96,144 +43,21 @@ func mergeApplicationProfile(projected *v1beta1.ContainerProfile, userAP *v1beta } } - return missingPodContainers(pod, userAPNames(userAP)) -} - -// mergeNetworkNeighborhood finds the container entry in userNN matching -// containerName and merges its Ingress/Egress into projected.Spec, then -// overlays the user CRD's pod LabelSelector onto projected's embedded -// LabelSelector. Returns missing-from-userNN pod container names. -// -// ported from pkg/objectcache/networkneighborhoodcache/networkneighborhoodcache.go:560-636 -// (performMerge, mergeContainer, mergeNetworkNeighbors) applied to a single -// container's rules on a ContainerProfile. -func mergeNetworkNeighborhood(projected *v1beta1.ContainerProfile, userNN *v1beta1.NetworkNeighborhood, pod *corev1.Pod, containerName string) []string { - // Defensive copy: neighbor slices (DNSNames, Ports, MatchExpressions) and - // LabelSelector.MatchExpressions would otherwise alias the caller's CRD. - userNN = userNN.DeepCopy() - if matched := findUserNNContainer(userNN, containerName); matched != nil { - projected.Spec.Ingress = mergeNetworkNeighbors(projected.Spec.Ingress, matched.Ingress) - projected.Spec.Egress = mergeNetworkNeighbors(projected.Spec.Egress, matched.Egress) - } - - // Merge LabelSelector (ContainerProfileSpec embeds metav1.LabelSelector). - if userNN.Spec.LabelSelector.MatchLabels != nil { + // Merge the embedded LabelSelector (ContainerProfileSpec embeds it). + if u.Spec.LabelSelector.MatchLabels != nil { if projected.Spec.LabelSelector.MatchLabels == nil { projected.Spec.LabelSelector.MatchLabels = make(map[string]string) } - for k, v := range userNN.Spec.LabelSelector.MatchLabels { + for k, v := range u.Spec.LabelSelector.MatchLabels { projected.Spec.LabelSelector.MatchLabels[k] = v } } projected.Spec.LabelSelector.MatchExpressions = append( projected.Spec.LabelSelector.MatchExpressions, - userNN.Spec.LabelSelector.MatchExpressions..., + u.Spec.LabelSelector.MatchExpressions..., ) - return missingPodContainers(pod, userNNNames(userNN)) -} - -func findUserAPContainer(userAP *v1beta1.ApplicationProfile, containerName string) *v1beta1.ApplicationProfileContainer { - if userAP == nil { - return nil - } - for i := range userAP.Spec.Containers { - if userAP.Spec.Containers[i].Name == containerName { - return &userAP.Spec.Containers[i] - } - } - for i := range userAP.Spec.InitContainers { - if userAP.Spec.InitContainers[i].Name == containerName { - return &userAP.Spec.InitContainers[i] - } - } - for i := range userAP.Spec.EphemeralContainers { - if userAP.Spec.EphemeralContainers[i].Name == containerName { - return &userAP.Spec.EphemeralContainers[i] - } - } - return nil -} - -func findUserNNContainer(userNN *v1beta1.NetworkNeighborhood, containerName string) *v1beta1.NetworkNeighborhoodContainer { - if userNN == nil { - return nil - } - for i := range userNN.Spec.Containers { - if userNN.Spec.Containers[i].Name == containerName { - return &userNN.Spec.Containers[i] - } - } - for i := range userNN.Spec.InitContainers { - if userNN.Spec.InitContainers[i].Name == containerName { - return &userNN.Spec.InitContainers[i] - } - } - for i := range userNN.Spec.EphemeralContainers { - if userNN.Spec.EphemeralContainers[i].Name == containerName { - return &userNN.Spec.EphemeralContainers[i] - } - } - return nil -} - -func userAPNames(userAP *v1beta1.ApplicationProfile) map[string]struct{} { - names := map[string]struct{}{} - if userAP == nil { - return names - } - for _, c := range userAP.Spec.Containers { - names[c.Name] = struct{}{} - } - for _, c := range userAP.Spec.InitContainers { - names[c.Name] = struct{}{} - } - for _, c := range userAP.Spec.EphemeralContainers { - names[c.Name] = struct{}{} - } - return names -} - -func userNNNames(userNN *v1beta1.NetworkNeighborhood) map[string]struct{} { - names := map[string]struct{}{} - if userNN == nil { - return names - } - for _, c := range userNN.Spec.Containers { - names[c.Name] = struct{}{} - } - for _, c := range userNN.Spec.InitContainers { - names[c.Name] = struct{}{} - } - for _, c := range userNN.Spec.EphemeralContainers { - names[c.Name] = struct{}{} - } - return names -} - -// missingPodContainers returns the set of pod-spec container names that are -// not present in the given set. If pod is nil, returns nil (check skipped). -func missingPodContainers(pod *corev1.Pod, have map[string]struct{}) []string { - if pod == nil { - return nil - } - var missing []string - for _, c := range pod.Spec.Containers { - if _, ok := have[c.Name]; !ok { - missing = append(missing, c.Name) - } - } - for _, c := range pod.Spec.InitContainers { - if _, ok := have[c.Name]; !ok { - missing = append(missing, c.Name) - } - } - for _, c := range pod.Spec.EphemeralContainers { - if _, ok := have[c.Name]; !ok { - missing = append(missing, c.Name) - } - } - return missing + return projected } // mergeNetworkNeighbors merges user neighbors into a normal-neighbor list, diff --git a/pkg/objectcache/containerprofilecache/projection_test.go b/pkg/objectcache/containerprofilecache/projection_test.go index 85b106ee0..96d993de1 100644 --- a/pkg/objectcache/containerprofilecache/projection_test.go +++ b/pkg/objectcache/containerprofilecache/projection_test.go @@ -6,7 +6,6 @@ import ( "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -28,37 +27,32 @@ func baseCP() *v1beta1.ContainerProfile { } } -func podWith(containers ...string) *corev1.Pod { - var cs []corev1.Container - for _, n := range containers { - cs = append(cs, corev1.Container{Name: n}) +// userManagedCPWith builds a "ug-" user-managed ContainerProfile overlay from a +// flat spec. This is the migrated replacement for the legacy per-container +// ApplicationProfile + NetworkNeighborhood overlay pair. +func userManagedCPWith(spec v1beta1.ContainerProfileSpec) *v1beta1.ContainerProfile { + return &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "ug-nginx", Namespace: "default", ResourceVersion: "u1"}, + Spec: spec, } - return &corev1.Pod{Spec: corev1.PodSpec{Containers: cs}} } -// TestProjection_UserAPOnly_Match verifies the happy-path merge of a matching -// user AP container: capabilities / execs / policies merged, no warnings. -func TestProjection_UserAPOnly_Match(t *testing.T) { +// TestProjection_UserCPOnly_Merge verifies the happy-path merge of a +// user-managed ContainerProfile overlay: capabilities / execs / policies +// unioned into the base. +func TestProjection_UserCPOnly_Merge(t *testing.T) { cp := baseCP() - userAP := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "ua", Namespace: "default", ResourceVersion: "u1"}, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "nginx", - Capabilities: []string{"NET_BIND_SERVICE"}, - Execs: []v1beta1.ExecCalls{{Path: "/bin/cat"}}, - PolicyByRuleId: map[string]v1beta1.RulePolicy{ - "R0901": {AllowedProcesses: []string{"cat"}}, - "R0902": {AllowedProcesses: []string{"echo"}}, - }, - }}, + userCP := userManagedCPWith(v1beta1.ContainerProfileSpec{ + Capabilities: []string{"NET_BIND_SERVICE"}, + Execs: []v1beta1.ExecCalls{{Path: "/bin/cat"}}, + PolicyByRuleId: map[string]v1beta1.RulePolicy{ + "R0901": {AllowedProcesses: []string{"cat"}}, + "R0902": {AllowedProcesses: []string{"echo"}}, }, - } - pod := podWith("nginx") + }) - projected, warnings := projectUserProfiles(cp, userAP, nil, pod, "nginx") + projected := projectUserManagedCP(cp, userCP) require.NotNil(t, projected) - assert.Empty(t, warnings) assert.NotSame(t, cp, projected, "projected must be a distinct DeepCopy") assert.ElementsMatch(t, []string{"SYS_PTRACE", "NET_BIND_SERVICE"}, projected.Spec.Capabilities) assert.Len(t, projected.Spec.Execs, 2) @@ -67,31 +61,21 @@ func TestProjection_UserAPOnly_Match(t *testing.T) { assert.Contains(t, projected.Spec.PolicyByRuleId, "R0902") } -// TestProjection_UserNNOnly_Match verifies merge of matching NN container: -// ingress merged by Identifier, LabelSelector MatchLabels overlaid. -func TestProjection_UserNNOnly_Match(t *testing.T) { +// TestProjection_UserCP_Network verifies merge of the network surface: ingress +// merged by Identifier (DNSNames unioned), LabelSelector MatchLabels overlaid. +func TestProjection_UserCP_Network(t *testing.T) { cp := baseCP() cp.Spec.LabelSelector = metav1.LabelSelector{MatchLabels: map[string]string{"app": "nginx"}} - userNN := &v1beta1.NetworkNeighborhood{ - ObjectMeta: metav1.ObjectMeta{Name: "un", Namespace: "default", ResourceVersion: "n1"}, - Spec: v1beta1.NetworkNeighborhoodSpec{ - LabelSelector: metav1.LabelSelector{ - MatchLabels: map[string]string{"env": "prod"}, - }, - Containers: []v1beta1.NetworkNeighborhoodContainer{{ - Name: "nginx", - Ingress: []v1beta1.NetworkNeighbor{ - {Identifier: "ing-1", DNSNames: []string{"b.svc.local"}}, - {Identifier: "ing-2", DNSNames: []string{"c.svc.local"}}, - }, - }}, + userCP := userManagedCPWith(v1beta1.ContainerProfileSpec{ + Ingress: []v1beta1.NetworkNeighbor{ + {Identifier: "ing-1", DNSNames: []string{"b.svc.local"}}, + {Identifier: "ing-2", DNSNames: []string{"c.svc.local"}}, }, - } - pod := podWith("nginx") + }) + userCP.Spec.LabelSelector = metav1.LabelSelector{MatchLabels: map[string]string{"env": "prod"}} - projected, warnings := projectUserProfiles(cp, nil, userNN, pod, "nginx") + projected := projectUserManagedCP(cp, userCP) require.NotNil(t, projected) - assert.Empty(t, warnings) require.Len(t, projected.Spec.Ingress, 2) // ing-1 merged (DNSNames union) var merged v1beta1.NetworkNeighbor @@ -107,116 +91,29 @@ func TestProjection_UserNNOnly_Match(t *testing.T) { assert.Equal(t, "prod", projected.Spec.LabelSelector.MatchLabels["env"]) } -// TestProjection_Both verifies both AP and NN can overlay in a single call. -func TestProjection_Both(t *testing.T) { +// TestProjection_UserCP_Both verifies capabilities and ingress overlay together +// in a single merge. +func TestProjection_UserCP_Both(t *testing.T) { cp := baseCP() - userAP := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "ua", Namespace: "default", ResourceVersion: "u1"}, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "nginx", - Capabilities: []string{"NET_ADMIN"}, - }}, - }, - } - userNN := &v1beta1.NetworkNeighborhood{ - ObjectMeta: metav1.ObjectMeta{Name: "un", Namespace: "default", ResourceVersion: "n1"}, - Spec: v1beta1.NetworkNeighborhoodSpec{ - Containers: []v1beta1.NetworkNeighborhoodContainer{{ - Name: "nginx", - Ingress: []v1beta1.NetworkNeighbor{{Identifier: "ing-new"}}, - }}, - }, - } - pod := podWith("nginx") + userCP := userManagedCPWith(v1beta1.ContainerProfileSpec{ + Capabilities: []string{"NET_ADMIN"}, + Ingress: []v1beta1.NetworkNeighbor{{Identifier: "ing-new"}}, + }) - projected, warnings := projectUserProfiles(cp, userAP, userNN, pod, "nginx") + projected := projectUserManagedCP(cp, userCP) require.NotNil(t, projected) - assert.Empty(t, warnings) assert.Contains(t, projected.Spec.Capabilities, "NET_ADMIN") // Original ing-1 plus appended ing-new assert.Len(t, projected.Spec.Ingress, 2) } -// TestProjection_UserAP_NonMatchingContainer verifies that when the user CRD -// doesn't include the target container name, no merge happens — but missing -// pod containers still produce a warning. -func TestProjection_UserAP_NonMatchingContainer(t *testing.T) { +// TestProjection_NilUserCP verifies projection with no overlay returns a +// DeepCopy (distinct pointer) preserving the base. +func TestProjection_NilUserCP(t *testing.T) { cp := baseCP() - userAP := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "ua", Namespace: "default", ResourceVersion: "u1"}, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "other", // not "nginx" - Capabilities: []string{"NET_BIND_SERVICE"}, - }}, - }, - } - pod := podWith("nginx", "sidecar") - projected, warnings := projectUserProfiles(cp, userAP, nil, pod, "nginx") + projected := projectUserManagedCP(cp, nil) require.NotNil(t, projected) - // No merge because no container matched "nginx" - assert.ElementsMatch(t, []string{"SYS_PTRACE"}, projected.Spec.Capabilities) - require.Len(t, warnings, 1) - assert.Equal(t, kindApplication, warnings[0].Kind) - assert.ElementsMatch(t, []string{"nginx", "sidecar"}, warnings[0].MissingContainers) -} - -// TestProjection_UserAP_PartialContainers verifies that when the user AP has -// one container but the pod has two, we emit a partial warning naming the -// missing pod container. -func TestProjection_UserAP_PartialContainers(t *testing.T) { - cp := baseCP() - userAP := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "ua", Namespace: "default", ResourceVersion: "u1"}, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "nginx", - Capabilities: []string{"NET_BIND_SERVICE"}, - }}, - }, - } - pod := podWith("nginx", "sidecar") - - projected, warnings := projectUserProfiles(cp, userAP, nil, pod, "nginx") - require.NotNil(t, projected) - // Target container merged. - assert.Contains(t, projected.Spec.Capabilities, "NET_BIND_SERVICE") - require.Len(t, warnings, 1) - assert.Equal(t, kindApplication, warnings[0].Kind) - assert.Equal(t, []string{"sidecar"}, warnings[0].MissingContainers) -} - -// TestProjection_NoUserCRDs verifies projection with neither user CRD returns -// a DeepCopy (distinct pointer) and no warnings. -func TestProjection_NoUserCRDs(t *testing.T) { - cp := baseCP() - pod := podWith("nginx") - - projected, warnings := projectUserProfiles(cp, nil, nil, pod, "nginx") - require.NotNil(t, projected) - assert.Empty(t, warnings) assert.NotSame(t, cp, projected) assert.Equal(t, cp.Spec.Capabilities, projected.Spec.Capabilities) } - -// TestProjection_NilPod verifies the merge still runs when pod is nil; the -// missing-container check is skipped (no warning emitted for partial). -func TestProjection_NilPod(t *testing.T) { - cp := baseCP() - userAP := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "ua", Namespace: "default", ResourceVersion: "u1"}, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "nginx", - Capabilities: []string{"NET_BIND_SERVICE"}, - }}, - }, - } - - projected, warnings := projectUserProfiles(cp, userAP, nil, nil, "nginx") - require.NotNil(t, projected) - assert.Empty(t, warnings) - assert.Contains(t, projected.Spec.Capabilities, "NET_BIND_SERVICE") -} diff --git a/pkg/objectcache/containerprofilecache/reconciler.go b/pkg/objectcache/containerprofilecache/reconciler.go index e89f5982d..55ac8f44a 100644 --- a/pkg/objectcache/containerprofilecache/reconciler.go +++ b/pkg/objectcache/containerprofilecache/reconciler.go @@ -330,40 +330,25 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri helpers.String("status", cp.Annotations[helpersv1.StatusMetadataKey])) return } - var userManagedAP *v1beta1.ApplicationProfile - var userManagedNN *v1beta1.NetworkNeighborhood + // Re-fetch the user-managed "ug-" ContainerProfile overlay (migrated + // from the legacy ug- AP/NN pair). A transient fetch error keeps the entry. + var userManagedCP *v1beta1.ContainerProfile if e.WorkloadName != "" { - ugAPName := helpersv1.UserApplicationProfilePrefix + e.WorkloadName - var userManagedAPErr error + ugCPName := helpersv1.UserApplicationProfilePrefix + e.WorkloadName + var userManagedCPErr error _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userManagedAP, userManagedAPErr = c.storageClient.GetApplicationProfile(rctx, ns, ugAPName) - return userManagedAPErr + userManagedCP, userManagedCPErr = c.storageClient.GetContainerProfile(rctx, ns, ugCPName) + return userManagedCPErr }) - if userManagedAPErr != nil && e.UserManagedAPRV != "" { - logger.L().Debug("refreshOneEntry: user-managed AP fetch failed; keeping cached entry", + if userManagedCPErr != nil && e.UserManagedCPRV != "" { + logger.L().Debug("refreshOneEntry: user-managed CP fetch failed; keeping cached entry", helpers.String("containerID", id), - helpers.String("name", ugAPName), - helpers.Error(userManagedAPErr)) + helpers.String("name", ugCPName), + helpers.Error(userManagedCPErr)) return } - if userManagedAPErr != nil { - userManagedAP = nil // k8s client returns non-nil zero-value on 404; treat as absent - } - ugNNName := helpersv1.UserNetworkNeighborhoodPrefix + e.WorkloadName - var userManagedNNErr error - _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userManagedNN, userManagedNNErr = c.storageClient.GetNetworkNeighborhood(rctx, ns, ugNNName) - return userManagedNNErr - }) - if userManagedNNErr != nil && e.UserManagedNNRV != "" { - logger.L().Debug("refreshOneEntry: user-managed NN fetch failed; keeping cached entry", - helpers.String("containerID", id), - helpers.String("name", ugNNName), - helpers.Error(userManagedNNErr)) - return - } - if userManagedNNErr != nil { - userManagedNN = nil + if userManagedCPErr != nil { + userManagedCP = nil // k8s client returns non-nil zero-value on 404; treat as absent } } // Re-fetch the user-defined ContainerProfile (migrated "new way") when the @@ -400,36 +385,23 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri } if rvsMatchCP(cp, e.RV) && rvsMatchCP(userDefinedCP, e.UserCPRV) && - rvsMatchAP(userManagedAP, e.UserManagedAPRV) && - rvsMatchNN(userManagedNN, e.UserManagedNNRV) && + rvsMatchCP(userManagedCP, e.UserManagedCPRV) && e.SpecHash == currentSpecHash { return } - c.rebuildEntryFromSources(id, e, cp, userDefinedCP, userManagedAP, userManagedNN) + c.rebuildEntryFromSources(id, e, cp, userDefinedCP, userManagedCP) } -// rvsMatchCP, rvsMatchAP, rvsMatchNN return true when either (a) the object is -// absent and the stored RV is empty, or (b) the object is present and its RV -// matches the stored RV. This lets fast-skip treat "still missing" as a match. +// rvsMatchCP returns true when either (a) the object is absent and the stored RV +// is empty, or (b) the object is present and its RV matches the stored RV. This +// lets fast-skip treat "still missing" as a match. func rvsMatchCP(obj *v1beta1.ContainerProfile, rv string) bool { if obj == nil { return rv == "" } return obj.ResourceVersion == rv } -func rvsMatchAP(obj *v1beta1.ApplicationProfile, rv string) bool { - if obj == nil { - return rv == "" - } - return obj.ResourceVersion == rv -} -func rvsMatchNN(obj *v1beta1.NetworkNeighborhood, rv string) bool { - if obj == nil { - return rv == "" - } - return obj.ResourceVersion == rv -} // rebuildEntryFromSources constructs a fresh CachedContainerProfile from the // given sources and stores it under `id`. Applies the projection ladder from @@ -442,8 +414,7 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( prev *CachedContainerProfile, cp *v1beta1.ContainerProfile, userDefinedCP *v1beta1.ContainerProfile, - userManagedAP *v1beta1.ApplicationProfile, - userManagedNN *v1beta1.NetworkNeighborhood, + userManagedCP *v1beta1.ContainerProfile, ) { pod := c.k8sObjectCache.GetPod(prev.Namespace, prev.PodName) @@ -487,13 +458,11 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( } projected := effectiveCP - // User-managed "ug-" AP + NN overlay merge. (The label-referenced - // user-defined overlay is a whole ContainerProfile adopted directly as - // effectiveCP above — there is no separate AP/NN merge pass anymore.) - if userManagedAP != nil || userManagedNN != nil { - p, warnings := projectUserProfiles(projected, userManagedAP, userManagedNN, pod, prev.ContainerName) - projected = p - c.emitOverlayMetrics(userManagedAP, userManagedNN, warnings) + // User-managed "ug-" ContainerProfile overlay merge (migrated from + // the legacy ug- AP/NN pair). The label-referenced user-defined overlay is a + // whole ContainerProfile adopted directly as effectiveCP above. + if userManagedCP != nil { + projected = projectUserManagedCP(projected, userManagedCP) } // Rebuild the call-stack search tree from the projected profile. @@ -524,8 +493,7 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( CPName: prev.CPName, WorkloadName: prev.WorkloadName, RV: rvOfCP(cp), - UserManagedAPRV: rvOfAP(userManagedAP), - UserManagedNNRV: rvOfNN(userManagedNN), + UserManagedCPRV: rvOfCP(userManagedCP), UserCPRV: rvOfCP(userDefinedCP), } if userDefinedCP != nil { @@ -547,27 +515,15 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( c.entries.Set(id, newEntry) } -// rvOfCP / rvOfAP / rvOfNN return the object's ResourceVersion or "" when nil. -// Separate typed versions avoid the Go nil-interface trap where a typed-nil -// pointer wrapped in an interface is not == nil. +// rvOfCP returns the object's ResourceVersion or "" when nil. Using a typed +// helper avoids the Go nil-interface trap where a typed-nil pointer wrapped in +// an interface is not == nil. func rvOfCP(o *v1beta1.ContainerProfile) string { if o == nil { return "" } return o.ResourceVersion } -func rvOfAP(o *v1beta1.ApplicationProfile) string { - if o == nil { - return "" - } - return o.ResourceVersion -} -func rvOfNN(o *v1beta1.NetworkNeighborhood) string { - if o == nil { - return "" - } - return o.ResourceVersion -} // observeMemoryMetrics records per-field entry counts, retention ratios, and // total byte sizes for the raw vs projected profile. Called only when diff --git a/pkg/objectcache/containerprofilecache/reconciler_test.go b/pkg/objectcache/containerprofilecache/reconciler_test.go index 6db8c1e45..92011baf2 100644 --- a/pkg/objectcache/containerprofilecache/reconciler_test.go +++ b/pkg/objectcache/containerprofilecache/reconciler_test.go @@ -2,6 +2,7 @@ package containerprofilecache import ( "context" + "strings" "sync" "sync/atomic" "testing" @@ -65,12 +66,8 @@ func (k *controllableK8sCache) DeleteSharedContainerData(_ string) {} // fast-skip behavior. type countingProfileClient struct { cp *v1beta1.ContainerProfile - ap *v1beta1.ApplicationProfile - nn *v1beta1.NetworkNeighborhood cpCalls atomic.Int64 - apCalls atomic.Int64 - nnCalls atomic.Int64 } var _ storage.ProfileClient = (*countingProfileClient)(nil) @@ -79,20 +76,6 @@ func (f *countingProfileClient) GetContainerProfile(_ context.Context, _, _ stri f.cpCalls.Add(1) return f.cp, nil } -func (f *countingProfileClient) GetApplicationProfile(_ context.Context, _, _ string) (*v1beta1.ApplicationProfile, error) { - f.apCalls.Add(1) - return f.ap, nil -} -func (f *countingProfileClient) GetNetworkNeighborhood(_ context.Context, _, _ string) (*v1beta1.NetworkNeighborhood, error) { - f.nnCalls.Add(1) - return f.nn, nil -} -func (f *countingProfileClient) ListApplicationProfiles(_ context.Context, _ string, _ int64, _ string) (*v1beta1.ApplicationProfileList, error) { - return &v1beta1.ApplicationProfileList{}, nil -} -func (f *countingProfileClient) ListNetworkNeighborhoods(_ context.Context, _ string, _ int64, _ string) (*v1beta1.NetworkNeighborhoodList, error) { - return &v1beta1.NetworkNeighborhoodList{}, nil -} // countingMetrics tallies ReportContainerProfileLegacyLoad calls so the T8 // end-to-end test can assert the overlay refresh re-emits the full-load signal. @@ -390,113 +373,69 @@ func TestRefreshNoEntryWhenCPGetFails(t *testing.T) { } // TestRefreshPreservesEntryOnTransientOverlayError — overlay fetch errors must -// not strip overlay data from the cache. If a user-managed or user-defined -// AP/NN GET returns an error while the entry already has a non-empty cached RV -// for that overlay, refreshOneEntry must keep the old entry unchanged (same -// pointer) rather than rebuilding without the overlay and clearing its RV. +// not strip overlay data from the cache. If the user-managed "ug-" +// ContainerProfile GET returns an error while the entry already has a non-empty +// cached RV for that overlay, refreshOneEntry must keep the old entry unchanged +// (same pointer) rather than rebuilding without the overlay and clearing its RV. // Regression test for the refreshRPC timeout → silent nil → spurious rebuild path. func TestRefreshPreservesEntryOnTransientOverlayError(t *testing.T) { + // Base CP is terminal (Completed) so refreshOneEntry passes the status gate + // and actually reaches the user-managed overlay fetch. cp := &v1beta1.ContainerProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "cp", Namespace: "default", ResourceVersion: "100"}, - Spec: v1beta1.ContainerProfileSpec{Capabilities: []string{"SYS_PTRACE"}}, - } - - type overlayFields struct { - workloadName string - userManagedAPRV string - userManagedNNRV string - } - tests := []struct { - name string - apErr bool - nnErr bool - overlay overlayFields - }{ - { - name: "user-managed AP timeout preserves entry", - apErr: true, - overlay: overlayFields{ - workloadName: "nginx", - userManagedAPRV: "9", - }, - }, - { - name: "user-managed NN timeout preserves entry", - nnErr: true, - overlay: overlayFields{ - workloadName: "nginx", - userManagedNNRV: "7", + ObjectMeta: metav1.ObjectMeta{ + Name: "cp", Namespace: "default", ResourceVersion: "100", + Annotations: map[string]string{ + helpersv1.CompletionMetadataKey: helpersv1.Full, + helpersv1.StatusMetadataKey: helpersv1.Completed, }, }, + Spec: v1beta1.ContainerProfileSpec{Capabilities: []string{"SYS_PTRACE"}}, } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - apErr := error(nil) - if tc.apErr { - apErr = assertErr{} - } - nnErr := error(nil) - if tc.nnErr { - nnErr = assertErr{} - } - client := &overlayErrorClient{cp: cp, apErr: apErr, nnErr: nnErr} - k8s := newControllableK8sCache() - c := newReconcilerCache(t, client, k8s, nil) - - id := "c1" - entry := &CachedContainerProfile{ - Projected: Apply(nil, cp, nil), - State: &objectcache.ProfileState{Name: cp.Name}, - ContainerName: "nginx", - PodName: "nginx-abc", - Namespace: "default", - PodUID: "uid-1", - CPName: "cp", - RV: "100", - WorkloadName: tc.overlay.workloadName, - UserManagedAPRV: tc.overlay.userManagedAPRV, - UserManagedNNRV: tc.overlay.userManagedNNRV, - } - c.entries.Set(id, entry) - - c.refreshAllEntries(context.Background()) - - stored, ok := c.entries.Load(id) - require.True(t, ok, "overlay error must not delete the entry") - assert.Same(t, entry, stored, "entry pointer must not change when overlay fetch fails transiently") - // Overlay RVs must be unchanged (not cleared to ""). - assert.Equal(t, tc.overlay.userManagedAPRV, stored.UserManagedAPRV) - assert.Equal(t, tc.overlay.userManagedNNRV, stored.UserManagedNNRV) - }) + client := &overlayErrorClient{cp: cp, ugCPErr: assertErr{}} + k8s := newControllableK8sCache() + c := newReconcilerCache(t, client, k8s, nil) + + id := "c1" + entry := &CachedContainerProfile{ + Projected: Apply(nil, cp, nil), + State: &objectcache.ProfileState{Name: cp.Name}, + ContainerName: "nginx", + PodName: "nginx-abc", + Namespace: "default", + PodUID: "uid-1", + CPName: "cp", + RV: "100", + WorkloadName: "nginx", + UserManagedCPRV: "9", } + c.entries.Set(id, entry) + + c.refreshAllEntries(context.Background()) + + stored, ok := c.entries.Load(id) + require.True(t, ok, "overlay error must not delete the entry") + assert.Same(t, entry, stored, "entry pointer must not change when overlay fetch fails transiently") + // The overlay RV must be unchanged (not cleared to ""). + assert.Equal(t, "9", stored.UserManagedCPRV, "UserManagedCPRV must be unchanged after a transient overlay-fetch error") } -// overlayErrorClient returns a valid CP but fails AP/NN calls with the -// configured errors. Used to test overlay error-preservation logic. +// overlayErrorClient returns a valid base CP but fails the user-managed +// "ug-" ContainerProfile fetch with the configured error. Used to +// test overlay error-preservation logic. type overlayErrorClient struct { - cp *v1beta1.ContainerProfile - apErr error - nnErr error + cp *v1beta1.ContainerProfile + ugCPErr error } var _ storage.ProfileClient = (*overlayErrorClient)(nil) -func (o *overlayErrorClient) GetContainerProfile(_ context.Context, _, _ string) (*v1beta1.ContainerProfile, error) { +func (o *overlayErrorClient) GetContainerProfile(_ context.Context, _, name string) (*v1beta1.ContainerProfile, error) { + if strings.HasPrefix(name, helpersv1.UserApplicationProfilePrefix) { + return nil, o.ugCPErr + } return o.cp, nil } -func (o *overlayErrorClient) GetApplicationProfile(_ context.Context, _, _ string) (*v1beta1.ApplicationProfile, error) { - return nil, o.apErr -} -func (o *overlayErrorClient) GetNetworkNeighborhood(_ context.Context, _, _ string) (*v1beta1.NetworkNeighborhood, error) { - return nil, o.nnErr -} -func (o *overlayErrorClient) ListApplicationProfiles(_ context.Context, _ string, _ int64, _ string) (*v1beta1.ApplicationProfileList, error) { - return &v1beta1.ApplicationProfileList{}, nil -} -func (o *overlayErrorClient) ListNetworkNeighborhoods(_ context.Context, _ string, _ int64, _ string) (*v1beta1.NetworkNeighborhoodList, error) { - return &v1beta1.NetworkNeighborhoodList{}, nil -} // --- helpers --- @@ -539,18 +478,6 @@ var _ storage.ProfileClient = (*failingProfileClient)(nil) func (f *failingProfileClient) GetContainerProfile(_ context.Context, _, _ string) (*v1beta1.ContainerProfile, error) { return nil, f.cpErr } -func (f *failingProfileClient) GetApplicationProfile(_ context.Context, _, _ string) (*v1beta1.ApplicationProfile, error) { - return nil, nil -} -func (f *failingProfileClient) GetNetworkNeighborhood(_ context.Context, _, _ string) (*v1beta1.NetworkNeighborhood, error) { - return nil, nil -} -func (f *failingProfileClient) ListApplicationProfiles(_ context.Context, _ string, _ int64, _ string) (*v1beta1.ApplicationProfileList, error) { - return &v1beta1.ApplicationProfileList{}, nil -} -func (f *failingProfileClient) ListNetworkNeighborhoods(_ context.Context, _ string, _ int64, _ string) (*v1beta1.NetworkNeighborhoodList, error) { - return &v1beta1.NetworkNeighborhoodList{}, nil -} // silence unused-import linter: helpersv1 is referenced only via the const in // containerprofilecache.go (used by some entries). Import explicitly so the @@ -631,18 +558,6 @@ func (b *blockingProfileClient) GetContainerProfile(ctx context.Context, _, _ st return nil, ctx.Err() } } -func (b *blockingProfileClient) GetApplicationProfile(_ context.Context, _, _ string) (*v1beta1.ApplicationProfile, error) { - return nil, nil -} -func (b *blockingProfileClient) GetNetworkNeighborhood(_ context.Context, _, _ string) (*v1beta1.NetworkNeighborhood, error) { - return nil, nil -} -func (b *blockingProfileClient) ListApplicationProfiles(_ context.Context, _ string, _ int64, _ string) (*v1beta1.ApplicationProfileList, error) { - return &v1beta1.ApplicationProfileList{}, nil -} -func (b *blockingProfileClient) ListNetworkNeighborhoods(_ context.Context, _ string, _ int64, _ string) (*v1beta1.NetworkNeighborhoodList, error) { - return &v1beta1.NetworkNeighborhoodList{}, nil -} // TestRetryPendingEntries_CPCreatedAfterAdd exercises the bug that slipped // through PR #788 component tests: at EventTypeAddContainer the CP may not @@ -682,8 +597,11 @@ func TestRetryPendingEntries_CPCreatedAfterAdd(t *testing.T) { assert.NotNil(t, c.GetProjectedContainerProfile(id), "entry promoted after CP appears") assert.Equal(t, 0, c.pending.Len(), "pending drained on successful promotion") - // Exactly two GETs: one from addContainer (404), one from retry (200). - assert.Equal(t, 2, client.getCPCalls, "retry should only re-GET once per tick") + // Four GETs total: each populate attempt issues two GetContainerProfile + // calls — the base CP plus the user-managed "ug-" overlay CP + // (the migrated replacement for the legacy ug- AP/NN pair). addContainer + // performs one attempt (base 404), the retry performs the second (base 200). + assert.Equal(t, 4, client.getCPCalls, "each tick re-GETs the base CP and the ug- overlay CP exactly once") } // TestPendingEntriesAreNotGCedBeforeRetry verifies we no longer drop pending @@ -1014,11 +932,11 @@ func TestNotifyContainerTerminal_Completed(t *testing.T) { // TestUserManagedProfileMerged exercises the user-managed merge path // (Test_12_MergingProfilesTest / Test_13_MergingNetworkNeighborhoodTest): -// a user-managed AP published at "ug-" is merged on top of -// the base CP. Anomalies NOT in the union of base + user-managed should +// a user-managed ContainerProfile published at "ug-" is merged on +// top of the base CP. Anomalies NOT in the union of base + user-managed should // produce alerts; anomalies present in either source should not. func TestUserManagedProfileMerged(t *testing.T) { - // Base CP has exec "/bin/X"; user-managed AP adds "/bin/Y". + // Base CP has exec "/bin/X"; user-managed CP adds "/bin/Y". cp := &v1beta1.ContainerProfile{ ObjectMeta: metav1.ObjectMeta{ Name: "cp-base", @@ -1033,7 +951,7 @@ func TestUserManagedProfileMerged(t *testing.T) { Execs: []v1beta1.ExecCalls{{Path: "/bin/X"}}, }, } - userManagedAP := &v1beta1.ApplicationProfile{ + userManagedCP := &v1beta1.ContainerProfile{ ObjectMeta: metav1.ObjectMeta{ Name: "ug-nginx", Namespace: "default", @@ -1043,16 +961,13 @@ func TestUserManagedProfileMerged(t *testing.T) { helpersv1.StatusMetadataKey: helpersv1.Completed, }, }, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "nginx", - Execs: []v1beta1.ExecCalls{{Path: "/bin/Y"}}, - }}, + Spec: v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/bin/Y"}}, }, } client := &fakeProfileClient{ cp: cp, - userManagedAP: userManagedAP, + userManagedCP: userManagedCP, } c, k8s := newTestCache(t, client) @@ -1069,14 +984,14 @@ func TestUserManagedProfileMerged(t *testing.T) { require.NotNil(t, cached, "entry populated") _, hasX := cached.Execs.Values["/bin/X"] _, hasY := cached.Execs.Values["/bin/Y"] - assert.True(t, hasX, "base workload AP exec must be present") - assert.True(t, hasY, "user-managed (ug-) AP exec must be merged in") + assert.True(t, hasX, "base CP exec must be present") + assert.True(t, hasY, "user-managed (ug-) CP exec must be merged in") // Verify the RV was captured so a later user-managed update would trigger // a refresh rebuild. entry, ok := c.entries.Load(id) require.True(t, ok) - assert.Equal(t, "9", entry.UserManagedAPRV, "UserManagedAPRV recorded at add time") + assert.Equal(t, "9", entry.UserManagedCPRV, "UserManagedCPRV recorded at add time") } // TestSpecChange_TriggersReprojection — T5 nudge integration. diff --git a/pkg/storage/storage_interface.go b/pkg/storage/storage_interface.go index e8f3e80dc..3c84c016e 100644 --- a/pkg/storage/storage_interface.go +++ b/pkg/storage/storage_interface.go @@ -10,11 +10,7 @@ import ( ) type ProfileClient interface { - GetApplicationProfile(ctx context.Context, namespace, name string) (*v1beta1.ApplicationProfile, error) - GetNetworkNeighborhood(ctx context.Context, namespace, name string) (*v1beta1.NetworkNeighborhood, error) GetContainerProfile(ctx context.Context, namespace, name string) (*v1beta1.ContainerProfile, error) - ListApplicationProfiles(ctx context.Context, namespace string, limit int64, cont string) (*v1beta1.ApplicationProfileList, error) - ListNetworkNeighborhoods(ctx context.Context, namespace string, limit int64, cont string) (*v1beta1.NetworkNeighborhoodList, error) } // ProfileCreator defines the interface for creating container profiles diff --git a/pkg/storage/storage_mock.go b/pkg/storage/storage_mock.go index 955431d28..55401ed4f 100644 --- a/pkg/storage/storage_mock.go +++ b/pkg/storage/storage_mock.go @@ -47,15 +47,6 @@ func (sc *StorageHttpClientMock) GetContainerProfile(_ context.Context, namespac return nil, nil } -func (sc *StorageHttpClientMock) GetApplicationProfile(_ context.Context, _, _ string) (*spdxv1beta1.ApplicationProfile, error) { - //TODO implement me - panic("implement me") -} - -func (sc *StorageHttpClientMock) GetNetworkNeighborhood(_ context.Context, _, _ string) (*spdxv1beta1.NetworkNeighborhood, error) { - //TODO implement me - panic("implement me") -} func (sc *StorageHttpClientMock) GetSBOMMeta(_ string) (*v1beta1.SBOMSyft, error) { return sc.mockSBOM, nil } @@ -64,16 +55,6 @@ func (sc *StorageHttpClientMock) GetStorageClient() beta1.SpdxV1beta1Interface { return nil } -func (sc *StorageHttpClientMock) ListApplicationProfiles(_ context.Context, namespace string, limit int64, cont string) (*spdxv1beta1.ApplicationProfileList, error) { - //TODO implement me - panic("implement me") -} - -func (sc *StorageHttpClientMock) ListNetworkNeighborhoods(_ context.Context, namespace string, limit int64, cont string) (*spdxv1beta1.NetworkNeighborhoodList, error) { - //TODO implement me - panic("implement me") -} - func (sc *StorageHttpClientMock) ReplaceSBOM(SBOM *v1beta1.SBOMSyft) (*v1beta1.SBOMSyft, error) { sc.SyftSBOMs = append(sc.SyftSBOMs, SBOM) return SBOM, nil diff --git a/pkg/storage/v1/applicationprofile.go b/pkg/storage/v1/applicationprofile.go deleted file mode 100644 index 39f054328..000000000 --- a/pkg/storage/v1/applicationprofile.go +++ /dev/null @@ -1,19 +0,0 @@ -package storage - -import ( - "context" - - "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func (sc *Storage) GetApplicationProfile(ctx context.Context, namespace, name string) (*v1beta1.ApplicationProfile, error) { - return sc.storageClient.ApplicationProfiles(namespace).Get(ctx, name, metav1.GetOptions{}) -} - -func (sc *Storage) ListApplicationProfiles(ctx context.Context, namespace string, limit int64, cont string) (*v1beta1.ApplicationProfileList, error) { - return sc.storageClient.ApplicationProfiles(namespace).List(ctx, metav1.ListOptions{ - Limit: limit, - Continue: cont, - }) -} diff --git a/pkg/storage/v1/networkneighborhood.go b/pkg/storage/v1/networkneighborhood.go deleted file mode 100644 index cec12b97e..000000000 --- a/pkg/storage/v1/networkneighborhood.go +++ /dev/null @@ -1,19 +0,0 @@ -package storage - -import ( - "context" - - "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func (sc *Storage) GetNetworkNeighborhood(ctx context.Context, namespace, name string) (*v1beta1.NetworkNeighborhood, error) { - return sc.storageClient.NetworkNeighborhoods(namespace).Get(ctx, name, metav1.GetOptions{}) -} - -func (sc *Storage) ListNetworkNeighborhoods(ctx context.Context, namespace string, limit int64, cont string) (*v1beta1.NetworkNeighborhoodList, error) { - return sc.storageClient.NetworkNeighborhoods(namespace).List(ctx, metav1.ListOptions{ - Limit: limit, - Continue: cont, - }) -} From c1242c41013a437d49894c31907f378e11b0c335 Mon Sep 17 00:00:00 2001 From: entlein Date: Tue, 28 Jul 2026 19:16:59 +0200 Subject: [PATCH 16/29] next step :now removing the previous crds completely, trying out mutlicontainer labels, tests not reviewed yet Signed-off-by: entlein --- .../containerprofilecache.go | 86 ++++- .../containerprofilecache_test.go | 337 +++++++++++++++++- .../containerprofilecache/reconciler.go | 76 ++-- .../containerprofilecache/reconciler_test.go | 36 +- pkg/storage/v1/storage.go | 5 +- pkg/watcher/dynamicwatcher/watch.go | 25 -- 6 files changed, 489 insertions(+), 76 deletions(-) diff --git a/pkg/objectcache/containerprofilecache/containerprofilecache.go b/pkg/objectcache/containerprofilecache/containerprofilecache.go index 1dee771f2..79a864d1e 100644 --- a/pkg/objectcache/containerprofilecache/containerprofilecache.go +++ b/pkg/objectcache/containerprofilecache/containerprofilecache.go @@ -371,27 +371,100 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( // drives the reconciler to retry the CP on every tick until it materialises. var userDefinedCP *v1beta1.ContainerProfile overlayName, hasOverlay := container.K8s.PodLabels[helpersv1.UserDefinedProfileMetadataKey] + // resolvedOverlayName is the ContainerProfile name the label ultimately + // resolves to; it is recorded in entry.UserCPRef so refreshOneEntry re-fetches + // the SAME object every tick. It defaults to the bare label value — the + // single-container convention and the safest retry target when the + // per-container fetch does not cleanly succeed. + resolvedOverlayName := overlayName if hasOverlay && overlayName != "" { + // Per-container binding (review finding on node-agent#864): a + // multi-container pod shares one label value but each container is + // profiled independently, so its authored ContainerProfile is published + // at "-". Try that per-container name first; on a + // genuine NotFound fall back to the bare "" (single-container + // pods). A transient error is NOT a fallback trigger — the CP is left nil + // for this tick and the bare name stays the retry target. + perContainerName := overlayName + "-" + container.Runtime.ContainerName var userCPErr error _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userDefinedCP, userCPErr = c.storageClient.GetContainerProfile(rctx, ns, overlayName) + userDefinedCP, userCPErr = c.storageClient.GetContainerProfile(rctx, ns, perContainerName) return userCPErr }) - if userCPErr != nil { + switch { + case userCPErr == nil && userDefinedCP != nil: + resolvedOverlayName = perContainerName + case apierrors.IsNotFound(userCPErr): + // Fall back to the bare overlay name (single-container convention). + userDefinedCP = nil + var bareErr error + _ = c.refreshRPC(ctx, func(rctx context.Context) error { + userDefinedCP, bareErr = c.storageClient.GetContainerProfile(rctx, ns, overlayName) + return bareErr + }) + if bareErr != nil { + logger.L().Debug("user-defined ContainerProfile not available", + helpers.String("containerID", containerID), + helpers.String("namespace", ns), + helpers.String("name", overlayName), + helpers.Error(bareErr)) + userDefinedCP = nil + } + default: + // Transient error on the per-container fetch: keep probing the bare + // name on later ticks (the common single-container recovery target). logger.L().Debug("user-defined ContainerProfile not available", helpers.String("containerID", containerID), helpers.String("namespace", ns), - helpers.String("name", overlayName), + helpers.String("name", perContainerName), helpers.Error(userCPErr)) userDefinedCP = nil } } + // A label-referenced ContainerProfile must be USER-AUTHORED, not a learned + // one. A learned CP carries lifecycle annotations (status/completion); an + // authored one carries none. If the label resolves to a learned CP, ignore + // it — otherwise its real state is overwritten with Completed/Full below and + // a still-learning profile would be enforced as complete (false positives). + if userDefinedCP != nil { + if _, learned := userDefinedCP.Annotations[helpersv1.StatusMetadataKey]; learned { + logger.L().Warning("user-defined-profile label resolves to a learned ContainerProfile; ignoring it", + helpers.String("containerID", containerID), + helpers.String("namespace", ns), + helpers.String("name", overlayName)) + userDefinedCP = nil + } + } + // Need SOMETHING to cache. If we have nothing, stay pending and retry. if cp == nil && userDefinedCP == nil && userManagedCP == nil { + // Visibility for the upgrade path: a workload whose user-defined-profile + // label is set but resolves to nothing (e.g. still-legacy AP/NN that are + // no longer read) would otherwise pend forever with only a Debug trace. + // Warn once — before the container enters `pending` — so the periodic + // retry doesn't spam. + if hasOverlay && overlayName != "" && !c.pending.Has(containerID) { + logger.L().Warning("user-defined-profile label set but no ContainerProfile resolved; container has no profile (legacy ApplicationProfile/NetworkNeighborhood are no longer read)", + helpers.String("containerID", containerID), + helpers.String("namespace", ns), + helpers.String("name", overlayName)) + } return false } + // Capture the LEARNED CP's ResourceVersion before cp is repointed at the + // authored profile. entry.RV must track the object entry.CPName points at + // (the learned slug). If it held the authored RV instead, refreshOneEntry + // would compare it against a GET on the learned slug — which 404s for a + // user-defined container (learning is suppressed) — and read that 404 as a + // transient error, freezing the entry so authored-CP edits are never picked + // up (review finding on node-agent#864). + learnedRV := "" + if cp != nil { + learnedRV = cp.ResourceVersion + } + // A user-defined ContainerProfile is authoritative for this container: it is // the migrated replacement for the AP+NN overlay, so it becomes the base // (the ug- user-managed pass may still union on top). Learning is suppressed @@ -442,6 +515,11 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( // refresh queries the synthetic name, always 404s, and the fast-skip // keeps the synthetic entry forever (stored RV is "" == absent-match). entry.CPName = cpName + // buildEntry derives RV from whatever it projected — the authored CP when one + // was adopted. refreshOneEntry compares entry.RV against a GET on entry.CPName + // (the learned slug), so leaving the authored RV here makes the permanent 404 + // on that slug look transient and freezes the entry. Track the learned RV. + entry.RV = learnedRV // Fill in user-managed bookkeeping so refreshOneEntry can re-fetch these // sources on every tick. WorkloadName is the "ug-" lookup prefix. entry.WorkloadName = workloadName @@ -458,7 +536,7 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( // until it restarts. There is no legacy AP/NN fallback anymore — the CP is // the only user-defined source. if hasOverlay && overlayName != "" { - entry.UserCPRef = &namespacedName{Namespace: ns, Name: overlayName} + entry.UserCPRef = &namespacedName{Namespace: ns, Name: resolvedOverlayName} if userDefinedCP != nil { entry.UserCPRV = userDefinedCP.ResourceVersion // A user-authored profile is authoritative and complete by diff --git a/pkg/objectcache/containerprofilecache/containerprofilecache_test.go b/pkg/objectcache/containerprofilecache/containerprofilecache_test.go index 1f024e47b..b42487846 100644 --- a/pkg/objectcache/containerprofilecache/containerprofilecache_test.go +++ b/pkg/objectcache/containerprofilecache/containerprofilecache_test.go @@ -34,6 +34,13 @@ type fakeProfileClient struct { userCP *v1beta1.ContainerProfile cpErr error + // userCPsByName, when non-empty, is consulted before the cp/userCP + // fallbacks: a name present in the map returns its CP (nil error), a name + // absent returns cp/cpErr. Lets tests publish DISTINCT authored + // ContainerProfiles per container name ("-") so the + // per-container binding path can be exercised end-to-end. + userCPsByName map[string]*v1beta1.ContainerProfile + // userManagedCP, when non-nil, is returned by GetContainerProfile for any // name starting with the "ug-" user-managed prefix. This is the migrated // replacement for the legacy ug- ApplicationProfile + NetworkNeighborhood @@ -72,6 +79,13 @@ func (f *fakeProfileClient) GetContainerProfile(_ context.Context, _, name strin if strings.HasPrefix(name, helpersv1.UserApplicationProfilePrefix) { return f.userManagedCP, nil } + // Name-keyed authored CPs take precedence: this is how a multi-container pod + // serves a different CP per "-" name. + if f.userCPsByName != nil { + if cp, ok := f.userCPsByName[name]; ok { + return cp, nil + } + } if f.userCP != nil && name == f.userCP.Name { return f.userCP, nil } @@ -171,13 +185,15 @@ func TestSharedFastPath_NoOverlay(t *testing.T) { // (managed-by: User), it becomes the authoritative base — UserCPRef is set and // the projection reflects the CP. func TestOverlayPath_UserDefinedCP_NewWay(t *testing.T) { + // A genuine authored CP carries NO learning-lifecycle annotations (no + // status/completion) — only managed-by: User. A CP that carried a status + // annotation would be treated as learned and ignored (see + // TestUserDefinedCP_LearnedProfileIgnored). userCP := &v1beta1.ContainerProfile{ ObjectMeta: metav1.ObjectMeta{ Name: "override", Namespace: "default", ResourceVersion: "uc1", Annotations: map[string]string{ - helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, - helpersv1.StatusMetadataKey: helpersv1.Completed, - helpersv1.CompletionMetadataKey: helpersv1.Full, + helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, }, }, Spec: v1beta1.ContainerProfileSpec{Capabilities: []string{"NET_BIND_SERVICE"}}, @@ -226,9 +242,12 @@ func TestOverlayPath_CPFetchTransientError_RecordsUserCPRef(t *testing.T) { }, Spec: v1beta1.ContainerProfileSpec{Capabilities: []string{"SYS_PTRACE"}}, } + // The per-container fetch ("override-nginx") errors transiently. A transient + // error is not a fallback trigger, so the bare "override" stays the recorded + // retry target — proving UserCPRef is set even when this fetch fails. client := &fakeProfileClient{ cp: baseCP, - overlayOnly: "override", + overlayOnly: "override-nginx", overlayCPErr: errors.New("etcdserver: request timed out"), // transient } c, k8s := newTestCache(t, client) @@ -366,6 +385,316 @@ func TestCallStackIndexBuiltFromProfile(t *testing.T) { assert.True(t, hasCallID, "call-stack tree must contain CallID 'r1' from CP") } +// authoredCP builds a genuine user-authored ContainerProfile: managed-by: User +// and, crucially, NO learning-lifecycle annotations (no status/completion), so +// the authored-validation gate does not treat it as a learned profile. Its spec +// carries a single distinctive Exec so per-container adoption is observable in +// the projection. +func authoredCP(name, execPath, rv string) *v1beta1.ContainerProfile { + return &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, Namespace: "default", ResourceVersion: rv, + Annotations: map[string]string{ + helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, + }, + }, + Spec: v1beta1.ContainerProfileSpec{Execs: []v1beta1.ExecCalls{{Path: execPath}}}, + } +} + +// execsAllSpec is a projection spec that retains every Exec path in Values so +// tests can assert per-container adoption via the projected allow-list. +func execsAllSpec(hash string) objectcache.RuleProjectionSpec { + return objectcache.RuleProjectionSpec{ + Execs: objectcache.FieldSpec{InUse: true, All: true}, + Hash: hash, + } +} + +// TestUserDefinedCP_PerContainerBinding proves blocker #2: in a multi-container +// pod that shares ONE user-defined-profile label value, each container must +// adopt its OWN authored ContainerProfile, resolved by the +// "-" naming convention — not the same CP for every +// container. +func TestUserDefinedCP_PerContainerBinding(t *testing.T) { + frontend := authoredCP("nw-20-multi-container-frontend", "/bin/frontend", "1") + sidecar := authoredCP("nw-20-multi-container-sidecar", "/bin/sidecar", "1") + client := &fakeProfileClient{ + cp: nil, + cpErr: apierrors.NewNotFound(schema.GroupResource{Resource: "containerprofiles"}, "learned"), + userCPsByName: map[string]*v1beta1.ContainerProfile{ + "nw-20-multi-container-frontend": frontend, + "nw-20-multi-container-sidecar": sidecar, + }, + } + c, k8s := newTestCache(t, client) + c.SetProjectionSpec(execsAllSpec("per-container")) + + cases := []struct { + id, cname, ownExec, otherExec, cpName string + }{ + {"cid-frontend", "frontend", "/bin/frontend", "/bin/sidecar", "nw-20-multi-container-frontend"}, + {"cid-sidecar", "sidecar", "/bin/sidecar", "/bin/frontend", "nw-20-multi-container-sidecar"}, + } + for _, tc := range cases { + primeSharedData(t, k8s, tc.id, "wlid://cluster-a/namespace-default/deployment-nginx") + ev := eventContainer(tc.id) + ev.Runtime.ContainerName = tc.cname + ev.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "nw-20-multi-container"} + require.NoError(t, c.addContainer(ev, context.Background())) + } + + for _, tc := range cases { + entry, ok := c.entries.Load(tc.id) + require.True(t, ok, "entry present for %s", tc.cname) + require.NotNil(t, entry.UserCPRef) + assert.Equal(t, tc.cpName, entry.UserCPRef.Name, + "%s must resolve to its per-container CP so the reconciler re-fetches the same object", tc.cname) + proj := c.GetProjectedContainerProfile(tc.id) + require.NotNil(t, proj) + _, hasOwn := proj.Execs.Values[tc.ownExec] + _, hasOther := proj.Execs.Values[tc.otherExec] + assert.True(t, hasOwn, "%s must adopt its OWN CP (%s present)", tc.cname, tc.ownExec) + assert.False(t, hasOther, "%s must NOT adopt the sibling container's CP (%s absent)", tc.cname, tc.otherExec) + } +} + +// TestUserDefinedCP_SingleContainerBareFallback proves the single-container +// fallback in blocker #2: when no "-" CP exists, the +// resolver falls back to the bare "" name. +func TestUserDefinedCP_SingleContainerBareFallback(t *testing.T) { + bare := authoredCP("override", "/bin/only", "1") + client := &fakeProfileClient{ + cp: nil, + cpErr: apierrors.NewNotFound(schema.GroupResource{Resource: "containerprofiles"}, "learned"), + userCPsByName: map[string]*v1beta1.ContainerProfile{"override": bare}, + } + c, k8s := newTestCache(t, client) + c.SetProjectionSpec(execsAllSpec("bare-fallback")) + + id := "cid-single" + primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") + ev := eventContainer(id) // ContainerName "nginx" → per-container "override-nginx" is absent + ev.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "override"} + require.NoError(t, c.addContainer(ev, context.Background())) + + entry, ok := c.entries.Load(id) + require.True(t, ok) + require.NotNil(t, entry.UserCPRef) + assert.Equal(t, "override", entry.UserCPRef.Name, "single-container pod falls back to the bare overlay name") + proj := c.GetProjectedContainerProfile(id) + require.NotNil(t, proj) + _, hasOnly := proj.Execs.Values["/bin/only"] + assert.True(t, hasOnly, "bare-name CP must be adopted") +} + +// TestUserDefinedCP_LearnedProfileIgnored proves blocker #3 on the add path: a +// CP published at the label name that carries a lifecycle status ("ready") is a +// LEARNED profile, not authored. It must be ignored — never adopted and never +// force-enforced as Completed/Full. With no other profile source, the container +// stays pending. +func TestUserDefinedCP_LearnedProfileIgnored(t *testing.T) { + learnedAtLabel := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ready-cp", Namespace: "default", ResourceVersion: "1", + Annotations: map[string]string{ + helpersv1.StatusMetadataKey: helpersv1.Learning, // status: ready → still learning + helpersv1.CompletionMetadataKey: helpersv1.Partial, + }, + }, + Spec: v1beta1.ContainerProfileSpec{Execs: []v1beta1.ExecCalls{{Path: "/bin/leaked"}}}, + } + client := &fakeProfileClient{ + cp: nil, + cpErr: apierrors.NewNotFound(schema.GroupResource{Resource: "containerprofiles"}, "learned"), + userCPsByName: map[string]*v1beta1.ContainerProfile{"ready-cp": learnedAtLabel}, + } + c, k8s := newTestCache(t, client) + + id := "cid-learned-label" + primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") + ev := eventContainer(id) + ev.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "ready-cp"} + require.NoError(t, c.addContainer(ev, context.Background())) + + _, ok := c.entries.Load(id) + assert.False(t, ok, "a learned CP at the label name must NOT be adopted/force-enforced") + assert.Equal(t, 1, c.pending.Len(), "container stays pending when the label resolves only to a learned CP") +} + +// TestRefreshReflectsAuthoredCPEdit_RVFreezeProof is the key proof for fix #1 +// (RV freeze). A user-defined-only container (NO learned CP — learning is +// suppressed) is added; the entry's learned RV must be empty. When the authored +// CP is later edited (RV bumped + spec changed), a single refresh MUST reflect +// the edit. This only holds because entry.RV tracks the LEARNED slug (empty), +// so the permanent 404 on that slug during refresh is not mistaken for a +// transient error that would freeze the entry. +func TestRefreshReflectsAuthoredCPEdit_RVFreezeProof(t *testing.T) { + authored := authoredCP("authored-cp-nginx", "/bin/init", "a1") + client := &fakeProfileClient{ + cp: nil, + cpErr: apierrors.NewNotFound(schema.GroupResource{Resource: "containerprofiles"}, "learned"), + userCPsByName: map[string]*v1beta1.ContainerProfile{"authored-cp-nginx": authored}, + } + c, k8s := newTestCache(t, client) + c.SetProjectionSpec(execsAllSpec("rv-freeze")) + + id := "cid-rvfreeze" + primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") + ev := eventContainer(id) + ev.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "authored-cp"} + require.NoError(t, c.addContainer(ev, context.Background())) + + entry, ok := c.entries.Load(id) + require.True(t, ok) + require.NotNil(t, entry.UserCPRef) + assert.Equal(t, "authored-cp-nginx", entry.UserCPRef.Name) + assert.Equal(t, "", entry.RV, "learned RV must be empty (no learned CP) — the freeze-proof invariant") + assert.Equal(t, "a1", entry.UserCPRV) + + before := c.GetProjectedContainerProfile(id) + require.NotNil(t, before) + _, hasInit := before.Execs.Values["/bin/init"] + assert.True(t, hasInit) + _, hasEditedYet := before.Execs.Values["/bin/edited"] + require.False(t, hasEditedYet, "edit not applied before it happens") + + // Edit the authored CP: bump RV and append an Exec. + authored.ResourceVersion = "a2" + authored.Spec.Execs = append(authored.Spec.Execs, v1beta1.ExecCalls{Path: "/bin/edited"}) + + c.refreshAllEntries(context.Background()) + + after := c.GetProjectedContainerProfile(id) + require.NotNil(t, after) + _, hasEditedNow := after.Execs.Values["/bin/edited"] + assert.True(t, hasEditedNow, "authored-CP edit MUST be reflected after one refresh (entry not frozen)") + updated, _ := c.entries.Load(id) + assert.Equal(t, "a2", updated.UserCPRV, "UserCPRV must track the edited authored CP") + assert.Equal(t, "", updated.RV, "learned RV stays empty across refresh") +} + +// TestRefreshUserCP_NoLearnedCP covers the user-defined-only refresh path: an +// authored CP present with NO learned CP is force-enforced Completed/Full at add +// time, and an unchanged refresh fast-skips (same entry pointer) while keeping +// the terminal state. +func TestRefreshUserCP_NoLearnedCP(t *testing.T) { + authored := authoredCP("authored-cp-nginx", "/bin/authored", "a1") + client := &fakeProfileClient{ + cp: nil, + cpErr: apierrors.NewNotFound(schema.GroupResource{Resource: "containerprofiles"}, "learned"), + userCPsByName: map[string]*v1beta1.ContainerProfile{"authored-cp-nginx": authored}, + } + c, k8s := newTestCache(t, client) + + id := "cid-nolearned" + primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") + ev := eventContainer(id) + ev.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "authored-cp"} + require.NoError(t, c.addContainer(ev, context.Background())) + + entry, ok := c.entries.Load(id) + require.True(t, ok) + assert.Equal(t, "", entry.RV, "no learned CP → learned RV empty") + assert.Equal(t, "a1", entry.UserCPRV) + require.NotNil(t, entry.State) + assert.Equal(t, helpersv1.Completed, entry.State.Status, "authored CP is force-enforced Completed") + assert.Equal(t, helpersv1.Full, entry.State.Completion, "authored CP is force-enforced Full") + + c.refreshAllEntries(context.Background()) + + stored, ok := c.entries.Load(id) + require.True(t, ok) + assert.Same(t, entry, stored, "no source changed → fast-skip keeps the same entry pointer") + assert.Equal(t, helpersv1.Completed, stored.State.Status) +} + +// TestRefreshUserCP_FastSkipWhenRVsMatch: with BOTH a learned base CP and an +// authored CP, an unchanged refresh (learned RV + authored RV both match) +// fast-skips and preserves the entry pointer. +func TestRefreshUserCP_FastSkipWhenRVsMatch(t *testing.T) { + learned := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "learned-base", Namespace: "default", ResourceVersion: "L1", + Annotations: map[string]string{ + helpersv1.CompletionMetadataKey: helpersv1.Full, + helpersv1.StatusMetadataKey: helpersv1.Completed, + }, + }, + Spec: v1beta1.ContainerProfileSpec{Capabilities: []string{"NET_ADMIN"}}, + } + authored := authoredCP("authored-cp-nginx", "/bin/authored", "a1") + client := &fakeProfileClient{ + cp: learned, + userCPsByName: map[string]*v1beta1.ContainerProfile{"authored-cp-nginx": authored}, + } + c, k8s := newTestCache(t, client) + + id := "cid-fastskip" + primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") + ev := eventContainer(id) + ev.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "authored-cp"} + require.NoError(t, c.addContainer(ev, context.Background())) + + entry, ok := c.entries.Load(id) + require.True(t, ok) + require.NotEmpty(t, entry.RV, "learned RV recorded") + require.Equal(t, "a1", entry.UserCPRV, "authored RV recorded") + + c.refreshAllEntries(context.Background()) + + stored, ok := c.entries.Load(id) + require.True(t, ok) + assert.Same(t, entry, stored, "matching learned RV + authored RV → fast-skip, same pointer") +} + +// TestRefreshUserCP_RebuildWhenUserCPRVChanges: with the learned RV unchanged +// but the authored CP's RV bumped, refresh rebuilds the entry and the edit is +// reflected. +func TestRefreshUserCP_RebuildWhenUserCPRVChanges(t *testing.T) { + learned := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "learned-base", Namespace: "default", ResourceVersion: "L1", + Annotations: map[string]string{ + helpersv1.CompletionMetadataKey: helpersv1.Full, + helpersv1.StatusMetadataKey: helpersv1.Completed, + }, + }, + } + authored := authoredCP("authored-cp-nginx", "/bin/v1", "a1") + client := &fakeProfileClient{ + cp: learned, + userCPsByName: map[string]*v1beta1.ContainerProfile{"authored-cp-nginx": authored}, + } + c, k8s := newTestCache(t, client) + c.SetProjectionSpec(execsAllSpec("usercp-rebuild")) + + id := "cid-usercp-rebuild" + primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") + ev := eventContainer(id) + ev.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "authored-cp"} + require.NoError(t, c.addContainer(ev, context.Background())) + + entry, ok := c.entries.Load(id) + require.True(t, ok) + require.Equal(t, "a1", entry.UserCPRV) + + // Bump ONLY the authored CP (learned RV stays L1). + authored.ResourceVersion = "a2" + authored.Spec.Execs = append(authored.Spec.Execs, v1beta1.ExecCalls{Path: "/bin/v2"}) + + c.refreshAllEntries(context.Background()) + + stored, ok := c.entries.Load(id) + require.True(t, ok) + assert.NotSame(t, entry, stored, "authored RV change → rebuild, new pointer") + assert.Equal(t, "a2", stored.UserCPRV, "UserCPRV updated to the edited authored CP") + proj := c.GetProjectedContainerProfile(id) + require.NotNil(t, proj) + _, hasV2 := proj.Execs.Values["/bin/v2"] + assert.True(t, hasV2, "the authored-CP edit is reflected after rebuild") +} + // TestGetContainerProfile_Miss sanity-checks the nil path returns nil and a // synthetic error ProfileState (no panic). func TestGetContainerProfile_Miss(t *testing.T) { diff --git a/pkg/objectcache/containerprofilecache/reconciler.go b/pkg/objectcache/containerprofilecache/reconciler.go index 55ac8f44a..c401c552d 100644 --- a/pkg/objectcache/containerprofilecache/reconciler.go +++ b/pkg/objectcache/containerprofilecache/reconciler.go @@ -323,7 +323,50 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri helpers.Error(cpErr)) cp = nil } - if cp != nil && !isTerminalCPStatus(cp.Annotations[helpersv1.StatusMetadataKey]) { + // Re-fetch the user-defined ContainerProfile (migrated "new way") FIRST, when + // the entry was built from one. It is the authoritative base and the only + // user-defined source (the legacy AP/NN overlay is no longer supported); a + // transient fetch error keeps the entry as-is. + // + // Ordering matters (review finding on node-agent#864): when an authored CP is + // present it REPLACES the learned CP as the base, so the learned-status gate + // below must not be allowed to early-return before the authored CP is + // fetched. Otherwise a learned CP stuck in a non-terminal status ("ready") + // would freeze authored-CP edits out of the cache forever. + var userDefinedCP *v1beta1.ContainerProfile + if e.UserCPRef != nil { + var userCPErr error + _ = c.refreshRPC(ctx, func(rctx context.Context) error { + userDefinedCP, userCPErr = c.storageClient.GetContainerProfile(rctx, e.UserCPRef.Namespace, e.UserCPRef.Name) + return userCPErr + }) + if userCPErr != nil && e.UserCPRV != "" { + logger.L().Debug("refreshOneEntry: user-defined CP fetch failed; keeping cached entry", + helpers.String("containerID", id), + helpers.String("name", e.UserCPRef.Name), + helpers.Error(userCPErr)) + return + } + if userCPErr != nil { + userDefinedCP = nil + } + } + // Authored-validation (mirror of the add path): a label-referenced CP that + // carries lifecycle annotations is a LEARNED profile, not an authored one. + // Ignore it so its real state is not overwritten with Completed/Full and a + // still-learning profile is not enforced as complete. + if userDefinedCP != nil { + if _, learned := userDefinedCP.Annotations[helpersv1.StatusMetadataKey]; learned { + logger.L().Debug("refreshOneEntry: user-defined-profile label resolves to a learned CP; ignoring it", + helpers.String("containerID", id), + helpers.String("name", e.UserCPRef.Name)) + userDefinedCP = nil + } + } + // Learned-status gate: only blocks when there is NO authored CP to adopt. + // With an authored CP present, the learned CP's status is irrelevant — the + // authored profile is the base and is enforced regardless. + if userDefinedCP == nil && cp != nil && !isTerminalCPStatus(cp.Annotations[helpersv1.StatusMetadataKey]) { logger.L().Debug("refreshOneEntry: CP status not terminal; keeping cached entry", helpers.String("containerID", id), helpers.String("cpName", e.CPName), @@ -351,28 +394,6 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri userManagedCP = nil // k8s client returns non-nil zero-value on 404; treat as absent } } - // Re-fetch the user-defined ContainerProfile (migrated "new way") when the - // entry was built from one. It is the authoritative base and the only - // user-defined source (the legacy AP/NN overlay is no longer supported); a - // transient fetch error keeps the entry as-is. - var userDefinedCP *v1beta1.ContainerProfile - if e.UserCPRef != nil { - var userCPErr error - _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userDefinedCP, userCPErr = c.storageClient.GetContainerProfile(rctx, e.UserCPRef.Namespace, e.UserCPRef.Name) - return userCPErr - }) - if userCPErr != nil && e.UserCPRV != "" { - logger.L().Debug("refreshOneEntry: user-defined CP fetch failed; keeping cached entry", - helpers.String("containerID", id), - helpers.String("name", e.UserCPRef.Name), - helpers.Error(userCPErr)) - return - } - if userCPErr != nil { - userDefinedCP = nil - } - } // Fast-skip when nothing changed. We match "absent" (nil) with empty RV: // this avoids spurious rebuilds when an optional source is still missing, @@ -416,6 +437,15 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( userDefinedCP *v1beta1.ContainerProfile, userManagedCP *v1beta1.ContainerProfile, ) { + // Authored-validation (mirror of the add path): a label-referenced CP that + // carries lifecycle annotations is a LEARNED profile, not an authored one. + // Ignore it here too so it is never force-set Completed/Full below. + if userDefinedCP != nil { + if _, learned := userDefinedCP.Annotations[helpersv1.StatusMetadataKey]; learned { + userDefinedCP = nil + } + } + pod := c.k8sObjectCache.GetPod(prev.Namespace, prev.PodName) // Backfill PodUID when the entry was originally added before the pod diff --git a/pkg/objectcache/containerprofilecache/reconciler_test.go b/pkg/objectcache/containerprofilecache/reconciler_test.go index 92011baf2..03248b0f5 100644 --- a/pkg/objectcache/containerprofilecache/reconciler_test.go +++ b/pkg/objectcache/containerprofilecache/reconciler_test.go @@ -17,7 +17,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" ) @@ -63,42 +65,45 @@ func (k *controllableK8sCache) GetSharedContainerData(_ string) *objectcache.Wat func (k *controllableK8sCache) DeleteSharedContainerData(_ string) {} // countingProfileClient tracks per-method RPC counts so tests can assert -// fast-skip behavior. +// fast-skip behavior. It is name-aware: the base/learned CP is served for its +// own name, an optional authored CP for its own name, and every other name +// returns NotFound. This lets refresh tests distinguish the learned slug from +// the authored/overlay CP instead of returning the same object for any name. type countingProfileClient struct { - cp *v1beta1.ContainerProfile + cp *v1beta1.ContainerProfile // learned/base CP, keyed by cp.Name + userCP *v1beta1.ContainerProfile // authored CP, keyed by userCP.Name cpCalls atomic.Int64 } var _ storage.ProfileClient = (*countingProfileClient)(nil) -func (f *countingProfileClient) GetContainerProfile(_ context.Context, _, _ string) (*v1beta1.ContainerProfile, error) { +func (f *countingProfileClient) GetContainerProfile(_ context.Context, _, name string) (*v1beta1.ContainerProfile, error) { f.cpCalls.Add(1) - return f.cp, nil + if f.userCP != nil && name == f.userCP.Name { + return f.userCP, nil + } + if f.cp != nil && name == f.cp.Name { + return f.cp, nil + } + return nil, apierrors.NewNotFound(schema.GroupResource{Resource: "containerprofiles"}, name) } -// countingMetrics tallies ReportContainerProfileLegacyLoad calls so the T8 -// end-to-end test can assert the overlay refresh re-emits the full-load signal. +// countingMetrics tallies reconciler eviction + entry-count signals so tests +// can assert eviction behavior. type countingMetrics struct { metricsmanager.MetricsMock mu sync.Mutex - legacyLoads map[string]int // key = kind+"|"+completeness evictions map[string]int entriesByKnd map[string]float64 } func newCountingMetrics() *countingMetrics { return &countingMetrics{ - legacyLoads: map[string]int{}, evictions: map[string]int{}, entriesByKnd: map[string]float64{}, } } -func (m *countingMetrics) ReportContainerProfileLegacyLoad(kind, completeness string) { - m.mu.Lock() - defer m.mu.Unlock() - m.legacyLoads[kind+"|"+completeness]++ -} func (m *countingMetrics) ReportContainerProfileReconcilerEviction(reason string) { m.mu.Lock() defer m.mu.Unlock() @@ -109,11 +114,6 @@ func (m *countingMetrics) SetContainerProfileCacheEntries(kind string, count flo defer m.mu.Unlock() m.entriesByKnd[kind] = count } -func (m *countingMetrics) legacyLoad(kind, completeness string) int { - m.mu.Lock() - defer m.mu.Unlock() - return m.legacyLoads[kind+"|"+completeness] -} func (m *countingMetrics) eviction(reason string) int { m.mu.Lock() defer m.mu.Unlock() diff --git a/pkg/storage/v1/storage.go b/pkg/storage/v1/storage.go index e9828df2f..e5b5e6585 100644 --- a/pkg/storage/v1/storage.go +++ b/pkg/storage/v1/storage.go @@ -61,9 +61,10 @@ func CreateStorage(namespace string) (*Storage, error) { return nil, fmt.Errorf("failed to create K8S Aggregated API Client with err: %v", err) } - // wait for storage to be ready + // wait for storage to be ready. Probe ContainerProfiles — the unified profile + // resource — not ApplicationProfiles, which no longer exists in storage. if err := backoff.RetryNotify(func() error { - _, err := clientset.SpdxV1beta1().ApplicationProfiles("default").List(context.Background(), metav1.ListOptions{}) + _, err := clientset.SpdxV1beta1().ContainerProfiles("default").List(context.Background(), metav1.ListOptions{}) return err }, backoff.WithMaxRetries(backoff.NewConstantBackOff(5*time.Second), 60), func(err error, d time.Duration) { logger.L().Info("waiting for storage to be ready", helpers.Error(err), helpers.String("retry in", d.String())) diff --git a/pkg/watcher/dynamicwatcher/watch.go b/pkg/watcher/dynamicwatcher/watch.go index c9a3fbc29..015bf713e 100644 --- a/pkg/watcher/dynamicwatcher/watch.go +++ b/pkg/watcher/dynamicwatcher/watch.go @@ -10,7 +10,6 @@ import ( "github.com/kubescape/node-agent/pkg/cooldownqueue" "github.com/kubescape/node-agent/pkg/k8sclient" "github.com/kubescape/node-agent/pkg/watcher" - "github.com/kubescape/storage/pkg/apis/softwarecomposition" spdxv1beta1 "github.com/kubescape/storage/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/tools/pager" @@ -150,18 +149,6 @@ func (wh *WatchHandler) Stop(_ context.Context) { func (wh *WatchHandler) chooseWatcher(res schema.GroupVersionResource, opts metav1.ListOptions) (watch.Interface, error) { switch res.Resource { - case "applicationprofiles": - if wh.storageClient == nil { - return nil, fmt.Errorf("storage client is nil: %w", errNotImplemented) - } - opts.ResourceVersion = softwarecomposition.ResourceVersionFullSpec - return wh.storageClient.ApplicationProfiles("").Watch(context.Background(), opts) - case "networkneighborhoods": - if wh.storageClient == nil { - return nil, fmt.Errorf("storage client is nil: %w", errNotImplemented) - } - opts.ResourceVersion = softwarecomposition.ResourceVersionFullSpec - return wh.storageClient.NetworkNeighborhoods("").Watch(context.Background(), opts) case "pods": return wh.k8sClient.GetKubernetesClient().CoreV1().Pods("").Watch(context.Background(), opts) case "runtimerulealertbindings": @@ -236,18 +223,6 @@ func (wh *WatchHandler) watchRetry(_ context.Context, res schema.GroupVersionRes func (wh *WatchHandler) chooseLister(res schema.GroupVersionResource, opts metav1.ListOptions) (runtime.Object, error) { switch res.Resource { - case "applicationprofiles": - if wh.storageClient == nil { - return nil, fmt.Errorf("storage client is nil: %w", errNotImplemented) - } - opts.ResourceVersion = softwarecomposition.ResourceVersionFullSpec - return wh.storageClient.ApplicationProfiles("").List(context.Background(), opts) - case "networkneighborhoods": - if wh.storageClient == nil { - return nil, fmt.Errorf("storage client is nil: %w", errNotImplemented) - } - opts.ResourceVersion = softwarecomposition.ResourceVersionFullSpec - return wh.storageClient.NetworkNeighborhoods("").List(context.Background(), opts) case "pods": return wh.k8sClient.GetKubernetesClient().CoreV1().Pods("").List(context.Background(), opts) case "runtimerulealertbindings": From f08681af43fe2999408d4e8a13d2e6668914d49d Mon Sep 17 00:00:00 2001 From: Duck <70207455+entlein@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:53:39 +0200 Subject: [PATCH 17/29] Apply suggestions from code review Co-authored-by: Matthias Bertschy Signed-off-by: Duck <70207455+entlein@users.noreply.github.com> --- .../containerprofilecache/containerprofilecache.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pkg/objectcache/containerprofilecache/containerprofilecache.go b/pkg/objectcache/containerprofilecache/containerprofilecache.go index 79a864d1e..26c7fcae5 100644 --- a/pkg/objectcache/containerprofilecache/containerprofilecache.go +++ b/pkg/objectcache/containerprofilecache/containerprofilecache.go @@ -469,6 +469,12 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( // the migrated replacement for the AP+NN overlay, so it becomes the base // (the ug- user-managed pass may still union on top). Learning is suppressed // for user-defined containers, so no consolidated CP competes with it. + // entry.RV must keep tracking the LEARNED CP (the object entry.CPName points + // at), so capture its RV before cp is repointed at the authored profile. + learnedRV := "" + if cp != nil { + learnedRV = cp.ResourceVersion + } if userDefinedCP != nil { cp = userDefinedCP } @@ -515,6 +521,11 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( // refresh queries the synthetic name, always 404s, and the fast-skip // keeps the synthetic entry forever (stored RV is "" == absent-match). entry.CPName = cpName + // buildEntry derives RV from whatever it projected, which is the authored CP + // when one was adopted. refreshOneEntry compares entry.RV against a GET on + // entry.CPName, so leaving the authored RV here makes the permanent 404 on + // the learned slug look like a transient error and freezes the entry. + entry.RV = learnedRV // buildEntry derives RV from whatever it projected — the authored CP when one // was adopted. refreshOneEntry compares entry.RV against a GET on entry.CPName // (the learned slug), so leaving the authored RV here makes the permanent 404 From a5b538ee96f8cf90290d2eb1abfdcebd51890e3a Mon Sep 17 00:00:00 2001 From: entlein Date: Tue, 28 Jul 2026 22:02:36 +0200 Subject: [PATCH 18/29] more decommissioning Signed-off-by: entlein --- .../metrics_manager_interface.go | 9 +- pkg/metricsmanager/metrics_manager_mock.go | 47 +- pkg/metricsmanager/metrics_manager_noop.go | 80 +-- .../otel/otel_metrics_manager.go | 25 +- pkg/metricsmanager/prometheus/prometheus.go | 64 ++- .../containerprofilecache.go | 112 ++-- .../containerprofilecache_test.go | 20 - .../containerprofilecache/projection.go | 176 ------ .../containerprofilecache/projection_apply.go | 1 - .../projection_apply_test.go | 1 + .../containerprofilecache/projection_test.go | 119 ---- .../containerprofilecache/reconciler.go | 107 ++-- .../containerprofilecache/reconciler_test.go | 149 ++---- tests/component_test.go | 506 +++--------------- tests/resources/mc35-cp-app.yaml | 18 + tests/resources/mc35-cp-sidecar.yaml | 18 + ...ulti-container-userdefined-deployment.yaml | 30 ++ 17 files changed, 387 insertions(+), 1095 deletions(-) delete mode 100644 pkg/objectcache/containerprofilecache/projection.go delete mode 100644 pkg/objectcache/containerprofilecache/projection_test.go create mode 100644 tests/resources/mc35-cp-app.yaml create mode 100644 tests/resources/mc35-cp-sidecar.yaml create mode 100644 tests/resources/mc35-multi-container-userdefined-deployment.yaml diff --git a/pkg/metricsmanager/metrics_manager_interface.go b/pkg/metricsmanager/metrics_manager_interface.go index fa090fc69..924153f34 100644 --- a/pkg/metricsmanager/metrics_manager_interface.go +++ b/pkg/metricsmanager/metrics_manager_interface.go @@ -28,10 +28,10 @@ type MetricsManager interface { ReportContainerProfileReconcilerEviction(reason string) // Profile-projection metrics — always-on. - IncMissingProfileDataRequired(ruleID string) // rule has profileDependency>0 but no profileDataRequired - IncProjectionUndeclaredLiteral(helper string) // literal evaluated against a projected field not in spec - SetProjectionStaleEntries(count float64) // cache entries whose SpecHash != currentSpecHash - SetProjectionUndeclaredRules(count float64) // rules loaded with no profileDataRequired + IncMissingProfileDataRequired(ruleID string) // rule has profileDependency>0 but no profileDataRequired + IncProjectionUndeclaredLiteral(helper string) // literal evaluated against a projected field not in spec + SetProjectionStaleEntries(count float64) // cache entries whose SpecHash != currentSpecHash + SetProjectionUndeclaredRules(count float64) // rules loaded with no profileDataRequired // Profile-projection metrics — detailed (gated by profileProjection.detailedMetricsEnabled). IncProjectionSpecCompile() @@ -41,6 +41,7 @@ type MetricsManager interface { ObserveProjectionApplyDuration(d time.Duration) IncProjectionReconcileTriggered(trigger string) IncHelperCall(helper string) + IncUserDefinedProfileUnresolved(namespace string) // user-defined-profile label set but no ContainerProfile resolved (silent-upgrade visibility) SetProjectionUndeclaredRulesDetail(ruleIDs []string) // Memory-savings metrics — detailed (gated by profileProjection.detailedMetricsEnabled). diff --git a/pkg/metricsmanager/metrics_manager_mock.go b/pkg/metricsmanager/metrics_manager_mock.go index 68261f5b2..8d6e77f8d 100644 --- a/pkg/metricsmanager/metrics_manager_mock.go +++ b/pkg/metricsmanager/metrics_manager_mock.go @@ -67,31 +67,32 @@ func (m *MetricsMock) ReportContainerStart() {} func (m *MetricsMock) ReportContainerStop() {} -func (m *MetricsMock) ReportDedupEvent(eventType utils.EventType, duplicate bool) {} +func (m *MetricsMock) ReportDedupEvent(eventType utils.EventType, duplicate bool) {} func (m *MetricsMock) ReportContainerProfileLegacyLoad(_, _ string) {} func (m *MetricsMock) SetContainerProfileCacheEntries(_ string, _ float64) {} func (m *MetricsMock) ReportContainerProfileCacheHit(_ bool) {} func (m *MetricsMock) ReportContainerProfileReconcilerDuration(_ string, _ time.Duration) {} func (m *MetricsMock) ReportContainerProfileReconcilerEviction(_ string) {} -func (m *MetricsMock) IncMissingProfileDataRequired(_ string) {} -func (m *MetricsMock) IncProjectionUndeclaredLiteral(_ string) {} -func (m *MetricsMock) SetProjectionStaleEntries(_ float64) {} -func (m *MetricsMock) SetProjectionUndeclaredRules(_ float64) {} -func (m *MetricsMock) IncProjectionSpecCompile() {} -func (m *MetricsMock) IncProjectionSpecHashChange() {} -func (m *MetricsMock) SetProjectionSpecPatterns(_, _ string, _ float64) {} -func (m *MetricsMock) SetProjectionSpecAllField(_ string, _ bool) {} -func (m *MetricsMock) ObserveProjectionApplyDuration(_ time.Duration) {} -func (m *MetricsMock) IncProjectionReconcileTriggered(_ string) {} -func (m *MetricsMock) IncHelperCall(_ string) {} -func (m *MetricsMock) SetProjectionUndeclaredRulesDetail(_ []string) {} -func (m *MetricsMock) ObserveProfileRawSize(_ float64) {} -func (m *MetricsMock) ObserveProfileProjectedSize(_ float64) {} -func (m *MetricsMock) ObserveProfileEntriesRaw(_ string, _ float64) {} -func (m *MetricsMock) ObserveProfileEntriesRetained(_ string, _ float64) {} -func (m *MetricsMock) ObserveProfileRetentionRatio(_ string, _ float64) {} -func (m *MetricsMock) ReportSBOMScan(_ string) {} -func (m *MetricsMock) ObserveSBOMScanDuration(_ string, _ time.Duration) {} -func (m *MetricsMock) ReportSBOMScannerRestart() {} -func (m *MetricsMock) SetSBOMScannerReady(_ bool) {} -func (m *MetricsMock) ReportAlertSuppressed(_, _ string) {} +func (m *MetricsMock) IncMissingProfileDataRequired(_ string) {} +func (m *MetricsMock) IncProjectionUndeclaredLiteral(_ string) {} +func (m *MetricsMock) SetProjectionStaleEntries(_ float64) {} +func (m *MetricsMock) SetProjectionUndeclaredRules(_ float64) {} +func (m *MetricsMock) IncProjectionSpecCompile() {} +func (m *MetricsMock) IncProjectionSpecHashChange() {} +func (m *MetricsMock) SetProjectionSpecPatterns(_, _ string, _ float64) {} +func (m *MetricsMock) SetProjectionSpecAllField(_ string, _ bool) {} +func (m *MetricsMock) ObserveProjectionApplyDuration(_ time.Duration) {} +func (m *MetricsMock) IncProjectionReconcileTriggered(_ string) {} +func (m *MetricsMock) IncHelperCall(_ string) {} +func (m *MetricsMock) IncUserDefinedProfileUnresolved(_ string) {} +func (m *MetricsMock) SetProjectionUndeclaredRulesDetail(_ []string) {} +func (m *MetricsMock) ObserveProfileRawSize(_ float64) {} +func (m *MetricsMock) ObserveProfileProjectedSize(_ float64) {} +func (m *MetricsMock) ObserveProfileEntriesRaw(_ string, _ float64) {} +func (m *MetricsMock) ObserveProfileEntriesRetained(_ string, _ float64) {} +func (m *MetricsMock) ObserveProfileRetentionRatio(_ string, _ float64) {} +func (m *MetricsMock) ReportSBOMScan(_ string) {} +func (m *MetricsMock) ObserveSBOMScanDuration(_ string, _ time.Duration) {} +func (m *MetricsMock) ReportSBOMScannerRestart() {} +func (m *MetricsMock) SetSBOMScannerReady(_ bool) {} +func (m *MetricsMock) ReportAlertSuppressed(_, _ string) {} diff --git a/pkg/metricsmanager/metrics_manager_noop.go b/pkg/metricsmanager/metrics_manager_noop.go index ffa9bac6a..157b208c9 100644 --- a/pkg/metricsmanager/metrics_manager_noop.go +++ b/pkg/metricsmanager/metrics_manager_noop.go @@ -11,42 +11,44 @@ var _ MetricsManager = (*MetricsNoop)(nil) type MetricsNoop struct{} -func NewMetricsNoop() *MetricsNoop { return &MetricsNoop{} } -func (m *MetricsNoop) Start() {} -func (m *MetricsNoop) Destroy() {} -func (m *MetricsNoop) ReportEvent(_ utils.EventType) {} -func (m *MetricsNoop) ReportFailedEvent() {} -func (m *MetricsNoop) ReportRuleProcessed(_ string) {} -func (m *MetricsNoop) ReportRulePrefiltered(_ string) {} -func (m *MetricsNoop) ReportRuleAlert(_ string) {} -func (m *MetricsNoop) ReportRuleEvaluationTime(_ context.Context, _ string, _ utils.EventType, _ time.Duration) {} -func (m *MetricsNoop) ReportContainerStart() {} -func (m *MetricsNoop) ReportContainerStop() {} -func (m *MetricsNoop) ReportDedupEvent(_ utils.EventType, _ bool) {} -func (m *MetricsNoop) ReportContainerProfileLegacyLoad(_, _ string) {} -func (m *MetricsNoop) SetContainerProfileCacheEntries(_ string, _ float64) {} -func (m *MetricsNoop) ReportContainerProfileCacheHit(_ bool) {} -func (m *MetricsNoop) ReportContainerProfileReconcilerDuration(_ string, _ time.Duration) {} -func (m *MetricsNoop) ReportContainerProfileReconcilerEviction(_ string) {} -func (m *MetricsNoop) IncMissingProfileDataRequired(_ string) {} -func (m *MetricsNoop) IncProjectionUndeclaredLiteral(_ string) {} -func (m *MetricsNoop) SetProjectionStaleEntries(_ float64) {} -func (m *MetricsNoop) SetProjectionUndeclaredRules(_ float64) {} -func (m *MetricsNoop) IncProjectionSpecCompile() {} -func (m *MetricsNoop) IncProjectionSpecHashChange() {} -func (m *MetricsNoop) SetProjectionSpecPatterns(_, _ string, _ float64) {} -func (m *MetricsNoop) SetProjectionSpecAllField(_ string, _ bool) {} -func (m *MetricsNoop) ObserveProjectionApplyDuration(_ time.Duration) {} -func (m *MetricsNoop) IncProjectionReconcileTriggered(_ string) {} -func (m *MetricsNoop) IncHelperCall(_ string) {} -func (m *MetricsNoop) SetProjectionUndeclaredRulesDetail(_ []string) {} -func (m *MetricsNoop) ObserveProfileRawSize(_ float64) {} -func (m *MetricsNoop) ObserveProfileProjectedSize(_ float64) {} -func (m *MetricsNoop) ObserveProfileEntriesRaw(_ string, _ float64) {} -func (m *MetricsNoop) ObserveProfileEntriesRetained(_ string, _ float64) {} -func (m *MetricsNoop) ObserveProfileRetentionRatio(_ string, _ float64) {} -func (m *MetricsNoop) ReportSBOMScan(_ string) {} -func (m *MetricsNoop) ObserveSBOMScanDuration(_ string, _ time.Duration) {} -func (m *MetricsNoop) ReportSBOMScannerRestart() {} -func (m *MetricsNoop) SetSBOMScannerReady(_ bool) {} -func (m *MetricsNoop) ReportAlertSuppressed(_, _ string) {} +func NewMetricsNoop() *MetricsNoop { return &MetricsNoop{} } +func (m *MetricsNoop) Start() {} +func (m *MetricsNoop) Destroy() {} +func (m *MetricsNoop) ReportEvent(_ utils.EventType) {} +func (m *MetricsNoop) ReportFailedEvent() {} +func (m *MetricsNoop) ReportRuleProcessed(_ string) {} +func (m *MetricsNoop) ReportRulePrefiltered(_ string) {} +func (m *MetricsNoop) ReportRuleAlert(_ string) {} +func (m *MetricsNoop) ReportRuleEvaluationTime(_ context.Context, _ string, _ utils.EventType, _ time.Duration) { +} +func (m *MetricsNoop) ReportContainerStart() {} +func (m *MetricsNoop) ReportContainerStop() {} +func (m *MetricsNoop) ReportDedupEvent(_ utils.EventType, _ bool) {} +func (m *MetricsNoop) ReportContainerProfileLegacyLoad(_, _ string) {} +func (m *MetricsNoop) SetContainerProfileCacheEntries(_ string, _ float64) {} +func (m *MetricsNoop) ReportContainerProfileCacheHit(_ bool) {} +func (m *MetricsNoop) ReportContainerProfileReconcilerDuration(_ string, _ time.Duration) {} +func (m *MetricsNoop) ReportContainerProfileReconcilerEviction(_ string) {} +func (m *MetricsNoop) IncMissingProfileDataRequired(_ string) {} +func (m *MetricsNoop) IncProjectionUndeclaredLiteral(_ string) {} +func (m *MetricsNoop) SetProjectionStaleEntries(_ float64) {} +func (m *MetricsNoop) SetProjectionUndeclaredRules(_ float64) {} +func (m *MetricsNoop) IncProjectionSpecCompile() {} +func (m *MetricsNoop) IncProjectionSpecHashChange() {} +func (m *MetricsNoop) SetProjectionSpecPatterns(_, _ string, _ float64) {} +func (m *MetricsNoop) SetProjectionSpecAllField(_ string, _ bool) {} +func (m *MetricsNoop) ObserveProjectionApplyDuration(_ time.Duration) {} +func (m *MetricsNoop) IncProjectionReconcileTriggered(_ string) {} +func (m *MetricsNoop) IncHelperCall(_ string) {} +func (m *MetricsNoop) IncUserDefinedProfileUnresolved(_ string) {} +func (m *MetricsNoop) SetProjectionUndeclaredRulesDetail(_ []string) {} +func (m *MetricsNoop) ObserveProfileRawSize(_ float64) {} +func (m *MetricsNoop) ObserveProfileProjectedSize(_ float64) {} +func (m *MetricsNoop) ObserveProfileEntriesRaw(_ string, _ float64) {} +func (m *MetricsNoop) ObserveProfileEntriesRetained(_ string, _ float64) {} +func (m *MetricsNoop) ObserveProfileRetentionRatio(_ string, _ float64) {} +func (m *MetricsNoop) ReportSBOMScan(_ string) {} +func (m *MetricsNoop) ObserveSBOMScanDuration(_ string, _ time.Duration) {} +func (m *MetricsNoop) ReportSBOMScannerRestart() {} +func (m *MetricsNoop) SetSBOMScannerReady(_ bool) {} +func (m *MetricsNoop) ReportAlertSuppressed(_, _ string) {} diff --git a/pkg/metricsmanager/otel/otel_metrics_manager.go b/pkg/metricsmanager/otel/otel_metrics_manager.go index f0771546e..231fbedf8 100644 --- a/pkg/metricsmanager/otel/otel_metrics_manager.go +++ b/pkg/metricsmanager/otel/otel_metrics_manager.go @@ -48,14 +48,15 @@ type OTELMetricsManager struct { projUndeclaredRules metric.Float64Gauge // Rule projection — detailed (gated by caller) - projSpecCompileTotal metric.Int64Counter - projSpecHashChangeTotal metric.Int64Counter - projSpecPatterns metric.Float64Gauge - projSpecAllField metric.Float64Gauge - projApplyDuration metric.Float64Histogram - projReconcileTriggeredTotal metric.Int64Counter - projHelperCallTotal metric.Int64Counter - projUndeclaredRulesDetail metric.Float64Gauge + projSpecCompileTotal metric.Int64Counter + projSpecHashChangeTotal metric.Int64Counter + projSpecPatterns metric.Float64Gauge + projSpecAllField metric.Float64Gauge + projApplyDuration metric.Float64Histogram + projReconcileTriggeredTotal metric.Int64Counter + projHelperCallTotal metric.Int64Counter + userDefinedProfileUnresolvedTotal metric.Int64Counter + projUndeclaredRulesDetail metric.Float64Gauge // Memory-savings metrics (dev-only, kept for interface compat; candidates for removal) profileRawSize metric.Float64Histogram @@ -193,6 +194,8 @@ func NewOTELMetricsManager(ownContainerID string) *OTELMetricsManager { "Projection reconcile triggers by type") m.projHelperCallTotal = mustCounter("node_agent.rule.projection.helper_call.total", "Profile-helper CEL function calls by helper name") + m.userDefinedProfileUnresolvedTotal = mustCounter("node_agent.container_profile.user_defined_unresolved.total", + "Times a pod's user-defined-profile label was set but no ContainerProfile resolved") // program runtime gauges intentionally omitted — dead code since initial implementation m.projUndeclaredRulesDetail = mustGauge("node_agent.rule.projection.undeclared_rules_detail", "Per-rule gauge for undeclared rules (high-cardinality; candidate for removal in Phase 3)") @@ -421,6 +424,12 @@ func (m *OTELMetricsManager) IncHelperCall(helper string) { )) } +func (m *OTELMetricsManager) IncUserDefinedProfileUnresolved(namespace string) { + m.userDefinedProfileUnresolvedTotal.Add(context.Background(), 1, metric.WithAttributes( + attribute.String("namespace", namespace), + )) +} + // SetProjectionUndeclaredRulesDetail records 1 for each rule currently undeclared // and 0 for rules that were in the previous call but are no longer undeclared. // OTEL synchronous gauges have no Reset(); zeroing removed entries is the equivalent. diff --git a/pkg/metricsmanager/prometheus/prometheus.go b/pkg/metricsmanager/prometheus/prometheus.go index ff0656414..5d1a45f67 100644 --- a/pkg/metricsmanager/prometheus/prometheus.go +++ b/pkg/metricsmanager/prometheus/prometheus.go @@ -25,27 +25,27 @@ const ( var _ metricsmanager.MetricsManager = (*PrometheusMetric)(nil) type PrometheusMetric struct { - ebpfExecCounter prometheus.Counter - ebpfOpenCounter prometheus.Counter - ebpfNetworkCounter prometheus.Counter - ebpfDNSCounter prometheus.Counter - ebpfSyscallCounter prometheus.Counter - ebpfCapabilityCounter prometheus.Counter - ebpfRandomXCounter prometheus.Counter - ebpfFailedCounter prometheus.Counter - ebpfSymlinkCounter prometheus.Counter - ebpfHardlinkCounter prometheus.Counter - ebpfSSHCounter prometheus.Counter - ebpfHTTPCounter prometheus.Counter - ebpfPtraceCounter prometheus.Counter - ebpfIoUringCounter prometheus.Counter - ebpfKmodCounter prometheus.Counter - ebpfUnshareCounter prometheus.Counter - ebpfBpfCounter prometheus.Counter + ebpfExecCounter prometheus.Counter + ebpfOpenCounter prometheus.Counter + ebpfNetworkCounter prometheus.Counter + ebpfDNSCounter prometheus.Counter + ebpfSyscallCounter prometheus.Counter + ebpfCapabilityCounter prometheus.Counter + ebpfRandomXCounter prometheus.Counter + ebpfFailedCounter prometheus.Counter + ebpfSymlinkCounter prometheus.Counter + ebpfHardlinkCounter prometheus.Counter + ebpfSSHCounter prometheus.Counter + ebpfHTTPCounter prometheus.Counter + ebpfPtraceCounter prometheus.Counter + ebpfIoUringCounter prometheus.Counter + ebpfKmodCounter prometheus.Counter + ebpfUnshareCounter prometheus.Counter + ebpfBpfCounter prometheus.Counter ruleCounter *prometheus.CounterVec rulePrefilteredCounter *prometheus.CounterVec alertCounter *prometheus.CounterVec - ruleEvaluationTime *prometheus.HistogramVec + ruleEvaluationTime *prometheus.HistogramVec // Program ID metrics programRuntimeGauge *prometheus.GaugeVec @@ -65,14 +65,14 @@ type PrometheusMetric struct { dedupEventCounter *prometheus.CounterVec // ContainerProfile cache metrics - cpCacheLegacyLoadsCounter *prometheus.CounterVec - cpCacheEntriesGauge *prometheus.GaugeVec - cpCacheHitCounter *prometheus.CounterVec - cpReconcilerDurationHistogram *prometheus.HistogramVec - cpReconcilerEvictionsCounter *prometheus.CounterVec + cpCacheLegacyLoadsCounter *prometheus.CounterVec + cpCacheEntriesGauge *prometheus.GaugeVec + cpCacheHitCounter *prometheus.CounterVec + cpReconcilerDurationHistogram *prometheus.HistogramVec + cpReconcilerEvictionsCounter *prometheus.CounterVec // Profile projection metrics — always-on - cpProjectionMissingDeclCounter *prometheus.CounterVec + cpProjectionMissingDeclCounter *prometheus.CounterVec cpProjectionUndeclaredLiteralCounter *prometheus.CounterVec cpProjectionStaleEntriesGauge prometheus.Gauge cpProjectionUndeclaredRulesGauge prometheus.Gauge @@ -84,6 +84,7 @@ type PrometheusMetric struct { cpProjectionSpecAllFieldsGauge *prometheus.GaugeVec cpProjectionApplyDurationHistogram prometheus.Histogram cpProjectionReconcileTriggeredCounter *prometheus.CounterVec + cpUserDefinedProfileUnresolvedCounter *prometheus.CounterVec cpHelperCallCounter *prometheus.CounterVec cpProjectionUndeclaredRulesListGauge *prometheus.GaugeVec @@ -104,10 +105,10 @@ type PrometheusMetric struct { alertSuppressedCounter *prometheus.CounterVec // Cache to avoid allocating Labels maps on every call - ruleCounterCache map[string]prometheus.Counter + ruleCounterCache map[string]prometheus.Counter rulePrefilteredCounterCache map[string]prometheus.Counter - alertCounterCache map[string]prometheus.Counter - counterCacheMutex sync.RWMutex + alertCounterCache map[string]prometheus.Counter + counterCacheMutex sync.RWMutex } func NewPrometheusMetric() *PrometheusMetric { @@ -326,6 +327,10 @@ func NewPrometheusMetric() *PrometheusMetric { Name: "rule_helper_call_total", Help: "Total number of profile-helper CEL function calls.", }, []string{"helper"}), + cpUserDefinedProfileUnresolvedCounter: promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "container_profile_user_defined_unresolved_total", + Help: "Total times a pod's user-defined-profile label was set but no ContainerProfile resolved (legacy AP/NN are no longer read).", + }, []string{"namespace"}), cpProjectionUndeclaredRulesListGauge: promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: "rule_projection_undeclared_rules_list", Help: "Per-rule gauge (1) for each rule currently loaded without a profileDataRequired declaration.", @@ -437,6 +442,7 @@ func (p *PrometheusMetric) Destroy() { prometheus.Unregister(p.cpProjectionSpecAllFieldsGauge) prometheus.Unregister(p.cpProjectionApplyDurationHistogram) prometheus.Unregister(p.cpProjectionReconcileTriggeredCounter) + prometheus.Unregister(p.cpUserDefinedProfileUnresolvedCounter) prometheus.Unregister(p.cpHelperCallCounter) prometheus.Unregister(p.cpProjectionUndeclaredRulesListGauge) prometheus.Unregister(p.cpProfileRawSizeHistogram) @@ -681,6 +687,10 @@ func (p *PrometheusMetric) SetProjectionSpecAllField(field string, isAll bool) { func (p *PrometheusMetric) ObserveProjectionApplyDuration(d time.Duration) { p.cpProjectionApplyDurationHistogram.Observe(d.Seconds()) } +func (p *PrometheusMetric) IncUserDefinedProfileUnresolved(namespace string) { + p.cpUserDefinedProfileUnresolvedCounter.WithLabelValues(namespace).Inc() +} + func (p *PrometheusMetric) IncProjectionReconcileTriggered(trigger string) { p.cpProjectionReconcileTriggeredCounter.WithLabelValues(trigger).Inc() } diff --git a/pkg/objectcache/containerprofilecache/containerprofilecache.go b/pkg/objectcache/containerprofilecache/containerprofilecache.go index 26c7fcae5..b79b859c2 100644 --- a/pkg/objectcache/containerprofilecache/containerprofilecache.go +++ b/pkg/objectcache/containerprofilecache/containerprofilecache.go @@ -73,15 +73,13 @@ type CachedContainerProfile struct { // shared data (which may have been evicted from K8sObjectCache by then). CPName string - // WorkloadName is the per-workload slug used to fetch the workload-level - // ApplicationProfile / NetworkNeighborhood (primary data source while the - // storage-side consolidated CP isn't publicly queryable) and, with the - // "ug-" prefix, the user-managed AP/NN. Populated at addContainer time. + // WorkloadName is the per-workload slug used to synthesize a CP name when the + // consolidated ContainerProfile is not yet queryable in storage. Populated at + // addContainer time. WorkloadName string - RV string // ContainerProfile resourceVersion at last load - UserManagedCPRV string // user-managed CP (ug-) RV at last projection, "" if absent - UserCPRV string // user-defined ContainerProfile (label-referenced) RV at last load, "" if not used + RV string // ContainerProfile resourceVersion at last load + UserCPRV string // user-defined ContainerProfile (label-referenced) RV at last load, "" if not used } // pendingContainer captures the minimum state needed to retry the initial @@ -107,8 +105,8 @@ type ContainerProfileCacheImpl struct { k8sObjectCache objectcache.K8sObjectCache metricsManager metricsmanager.MetricsManager - reconcileEvery time.Duration - rpcBudget time.Duration + reconcileEvery time.Duration + rpcBudget time.Duration refreshInProgress atomic.Bool // Projection spec — installed by SetProjectionSpec when rulemanager loads rules. @@ -154,10 +152,6 @@ func NewContainerProfileCache(cfg config.Config, storageClient storage.ProfileCl return c } -func shouldLogOptionalUserManagedFetchError(err error) bool { - return err != nil && !apierrors.IsNotFound(err) -} - // refreshRPC calls fn with a context bounded by c.rpcBudget, enforcing a // per-call SLO so a slow API server cannot stall a full reconciler burst. func (c *ContainerProfileCacheImpl) refreshRPC(ctx context.Context, fn func(context.Context) error) error { @@ -323,44 +317,6 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( cp = nil } - - // User-managed overlay: the migrated "ug-" ContainerProfile - // (annotated managed-by: User), unioned on top of the base. This replaces the - // legacy ug- ApplicationProfile + NetworkNeighborhood pair. Optional: nil on 404. - var userManagedCP *v1beta1.ContainerProfile - if workloadName != "" { - // UserApplicationProfilePrefix is the shared "ug-" user-managed prefix. - ugCPName := helpersv1.UserApplicationProfilePrefix + workloadName - var ugCPErr error - _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userManagedCP, ugCPErr = c.storageClient.GetContainerProfile(rctx, ns, ugCPName) - return ugCPErr - }) - if ugCPErr != nil { - if shouldLogOptionalUserManagedFetchError(ugCPErr) { - logger.L().Debug("failed to fetch user-managed ContainerProfile", - helpers.String("containerID", containerID), - helpers.String("namespace", ns), - helpers.String("name", ugCPName), - helpers.Error(ugCPErr)) - } - userManagedCP = nil - } - } - - // Only cache profiles whose status is terminal (Completed or TooLarge). - // Learning/ready profiles are still being written; caching them would let - // rules fire against incomplete data. TooLarge is terminal: the manager - // stopped collecting but the truncated data is still valid for detection. - // Return false so the synthetic-CP fallback below does not bypass the gate. - if cp != nil && !isTerminalCPStatus(cp.Annotations[helpersv1.StatusMetadataKey]) { - logger.L().Debug("tryPopulateEntry: CP status not terminal; keeping pending", - helpers.String("containerID", containerID), - helpers.String("namespace", ns), - helpers.String("status", cp.Annotations[helpersv1.StatusMetadataKey])) - return false - } - // Fetch the user-authored ContainerProfile when the pod carries the // UserDefinedProfileMetadataKey label. Migration (#862/#864) is a HARD // cutover: the label now names a single user-authored ContainerProfile — @@ -437,8 +393,24 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( } } + // Only cache profiles whose status is terminal (Completed or TooLarge). + // Learning/ready profiles are still being written; caching them would let + // rules fire against incomplete data. TooLarge is terminal: the manager + // stopped collecting but the truncated data is still valid for detection. + // This gate runs AFTER the authored-CP fetch (review finding on + // node-agent#864): a user-authored CP replaces the learned one outright, so + // if one was adopted the learned CP's non-terminal status must not block it. + // Only gate when no authored CP will be used. + if userDefinedCP == nil && cp != nil && !isTerminalCPStatus(cp.Annotations[helpersv1.StatusMetadataKey]) { + logger.L().Debug("tryPopulateEntry: CP status not terminal; keeping pending", + helpers.String("containerID", containerID), + helpers.String("namespace", ns), + helpers.String("status", cp.Annotations[helpersv1.StatusMetadataKey])) + return false + } + // Need SOMETHING to cache. If we have nothing, stay pending and retry. - if cp == nil && userDefinedCP == nil && userManagedCP == nil { + if cp == nil && userDefinedCP == nil { // Visibility for the upgrade path: a workload whose user-defined-profile // label is set but resolves to nothing (e.g. still-legacy AP/NN that are // no longer read) would otherwise pend forever with only a Debug trace. @@ -449,6 +421,7 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( helpers.String("containerID", containerID), helpers.String("namespace", ns), helpers.String("name", overlayName)) + c.metricsManager.IncUserDefinedProfileUnresolved(ns) } return false } @@ -466,15 +439,9 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( } // A user-defined ContainerProfile is authoritative for this container: it is - // the migrated replacement for the AP+NN overlay, so it becomes the base - // (the ug- user-managed pass may still union on top). Learning is suppressed - // for user-defined containers, so no consolidated CP competes with it. - // entry.RV must keep tracking the LEARNED CP (the object entry.CPName points - // at), so capture its RV before cp is repointed at the authored profile. - learnedRV := "" - if cp != nil { - learnedRV = cp.ResourceVersion - } + // the migrated replacement for the AP+NN overlay pair, so it becomes the + // base. Learning is suppressed for user-defined containers, so no + // consolidated CP competes with it. if userDefinedCP != nil { cp = userDefinedCP } @@ -507,12 +474,6 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( helpers.String("podName", container.K8s.PodName)) } - // User-managed "ug-" overlay pass: union the migrated ug- ContainerProfile - // on top of the base. (Legacy AP/NN overlay merge removed.) - if userManagedCP != nil { - cp = projectUserManagedCP(cp, userManagedCP) - } - entry := c.buildEntry(cp, pod, container, sharedData) // Override CPName with the real consolidated-CP slug. buildEntry sets // CPName from cp.Name, but when cp was synthesized above (no consolidated @@ -521,22 +482,14 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( // refresh queries the synthetic name, always 404s, and the fast-skip // keeps the synthetic entry forever (stored RV is "" == absent-match). entry.CPName = cpName - // buildEntry derives RV from whatever it projected, which is the authored CP - // when one was adopted. refreshOneEntry compares entry.RV against a GET on - // entry.CPName, so leaving the authored RV here makes the permanent 404 on - // the learned slug look like a transient error and freezes the entry. - entry.RV = learnedRV // buildEntry derives RV from whatever it projected — the authored CP when one // was adopted. refreshOneEntry compares entry.RV against a GET on entry.CPName // (the learned slug), so leaving the authored RV here makes the permanent 404 // on that slug look transient and freezes the entry. Track the learned RV. entry.RV = learnedRV - // Fill in user-managed bookkeeping so refreshOneEntry can re-fetch these - // sources on every tick. WorkloadName is the "ug-" lookup prefix. + // WorkloadName is the synthesize-name source refreshOneEntry uses when it + // rebuilds an entry whose consolidated CP is not yet in storage. entry.WorkloadName = workloadName - if userManagedCP != nil { - entry.UserManagedCPRV = userManagedCP.ResourceVersion - } // When the overlay label is set, ALWAYS record UserCPRef so the reconciler // keeps probing for the user-authored ContainerProfile on every tick — even @@ -598,9 +551,8 @@ func (c *ContainerProfileCacheImpl) buildEntry( entry.PodUID = string(pod.UID) } - // The base is authoritative as-is: the user-defined overlay is now a whole - // ContainerProfile adopted directly as `cp` (no AP/NN merge), and the - // user-managed "ug-" merge already ran on `cp` before buildEntry is called. + // The base is authoritative as-is: a user-defined profile is a whole + // ContainerProfile adopted directly as `cp` (no AP/NN merge). userMerged := cp // Build call-stack search tree. diff --git a/pkg/objectcache/containerprofilecache/containerprofilecache_test.go b/pkg/objectcache/containerprofilecache/containerprofilecache_test.go index b42487846..d2eef1364 100644 --- a/pkg/objectcache/containerprofilecache/containerprofilecache_test.go +++ b/pkg/objectcache/containerprofilecache/containerprofilecache_test.go @@ -3,7 +3,6 @@ package containerprofilecache import ( "context" "errors" - "strings" "testing" "time" @@ -41,12 +40,6 @@ type fakeProfileClient struct { // per-container binding path can be exercised end-to-end. userCPsByName map[string]*v1beta1.ContainerProfile - // userManagedCP, when non-nil, is returned by GetContainerProfile for any - // name starting with the "ug-" user-managed prefix. This is the migrated - // replacement for the legacy ug- ApplicationProfile + NetworkNeighborhood - // overlay pair and lets tests exercise the user-managed merge path. - userManagedCP *v1beta1.ContainerProfile - // overlayOnly, if non-empty, scopes the overlay name whose GetContainerProfile // returns a genuine NotFound (or overlayCPErr). Tests use this to keep the // user-defined-CP fixture scoped. @@ -64,21 +57,8 @@ type fakeProfileClient struct { var _ storage.ProfileClient = (*fakeProfileClient)(nil) -func TestShouldLogOptionalUserManagedFetchError(t *testing.T) { - assert.False(t, shouldLogOptionalUserManagedFetchError(nil)) - assert.False(t, shouldLogOptionalUserManagedFetchError( - apierrors.NewNotFound(schema.GroupResource{Group: "softwarecomposition.kubescape.io", Resource: "containerprofiles"}, "ug-nginx"), - )) - assert.True(t, shouldLogOptionalUserManagedFetchError(errors.New("boom"))) -} - func (f *fakeProfileClient) GetContainerProfile(_ context.Context, _, name string) (*v1beta1.ContainerProfile, error) { f.getCPCalls++ - // User-managed "ug-" overlay: a single ContainerProfile, the - // migrated replacement for the legacy ug- AP/NN pair. - if strings.HasPrefix(name, helpersv1.UserApplicationProfilePrefix) { - return f.userManagedCP, nil - } // Name-keyed authored CPs take precedence: this is how a multi-container pod // serves a different CP per "-" name. if f.userCPsByName != nil { diff --git a/pkg/objectcache/containerprofilecache/projection.go b/pkg/objectcache/containerprofilecache/projection.go deleted file mode 100644 index 5ff53944b..000000000 --- a/pkg/objectcache/containerprofilecache/projection.go +++ /dev/null @@ -1,176 +0,0 @@ -package containerprofilecache - -import ( - "github.com/kubescape/node-agent/pkg/utils" - "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// projectUserManagedCP overlays a user-authored ContainerProfile (the migrated -// "ug-" user-managed overlay) onto a base ContainerProfile and -// returns a DeepCopy of the base with the user fields unioned in. -// -// The migrated overlay is a single ContainerProfile whose spec is already flat -// for one container, so the merge is a direct field union with no per-container -// lookup. userCP may be nil (no overlay); cp MUST be non-nil. -func projectUserManagedCP(cp *v1beta1.ContainerProfile, userCP *v1beta1.ContainerProfile) *v1beta1.ContainerProfile { - projected := cp.DeepCopy() - if userCP == nil { - return projected - } - // Defensive copy: appended slices (Execs[i].Args, Opens[i].Flags, …) and the - // LabelSelector would otherwise alias the caller's cached CRD object. - u := userCP.DeepCopy() - - projected.Spec.Capabilities = append(projected.Spec.Capabilities, u.Spec.Capabilities...) - projected.Spec.Execs = append(projected.Spec.Execs, u.Spec.Execs...) - projected.Spec.Opens = append(projected.Spec.Opens, u.Spec.Opens...) - projected.Spec.Syscalls = append(projected.Spec.Syscalls, u.Spec.Syscalls...) - projected.Spec.Endpoints = append(projected.Spec.Endpoints, u.Spec.Endpoints...) - projected.Spec.Ingress = mergeNetworkNeighbors(projected.Spec.Ingress, u.Spec.Ingress) - projected.Spec.Egress = mergeNetworkNeighbors(projected.Spec.Egress, u.Spec.Egress) - - if len(u.Spec.PolicyByRuleId) > 0 { - if projected.Spec.PolicyByRuleId == nil { - projected.Spec.PolicyByRuleId = make(map[string]v1beta1.RulePolicy, len(u.Spec.PolicyByRuleId)) - } - for k, v := range u.Spec.PolicyByRuleId { - if existing, ok := projected.Spec.PolicyByRuleId[k]; ok { - projected.Spec.PolicyByRuleId[k] = utils.MergePolicies(existing, v) - } else { - projected.Spec.PolicyByRuleId[k] = v - } - } - } - - // Merge the embedded LabelSelector (ContainerProfileSpec embeds it). - if u.Spec.LabelSelector.MatchLabels != nil { - if projected.Spec.LabelSelector.MatchLabels == nil { - projected.Spec.LabelSelector.MatchLabels = make(map[string]string) - } - for k, v := range u.Spec.LabelSelector.MatchLabels { - projected.Spec.LabelSelector.MatchLabels[k] = v - } - } - projected.Spec.LabelSelector.MatchExpressions = append( - projected.Spec.LabelSelector.MatchExpressions, - u.Spec.LabelSelector.MatchExpressions..., - ) - - return projected -} - -// mergeNetworkNeighbors merges user neighbors into a normal-neighbor list, -// keyed by Identifier. ported from -// pkg/objectcache/networkneighborhoodcache/networkneighborhoodcache.go:617-636. -func mergeNetworkNeighbors(normalNeighbors, userNeighbors []v1beta1.NetworkNeighbor) []v1beta1.NetworkNeighbor { - neighborMap := make(map[string]int, len(normalNeighbors)) - for i, neighbor := range normalNeighbors { - neighborMap[neighbor.Identifier] = i - } - for _, userNeighbor := range userNeighbors { - if idx, exists := neighborMap[userNeighbor.Identifier]; exists { - normalNeighbors[idx] = mergeNetworkNeighbor(normalNeighbors[idx], userNeighbor) - } else { - normalNeighbors = append(normalNeighbors, userNeighbor) - } - } - return normalNeighbors -} - -// mergeNetworkNeighbor merges a user-managed neighbor into an existing one. -// ported from -// pkg/objectcache/networkneighborhoodcache/networkneighborhoodcache.go:638-706. -func mergeNetworkNeighbor(normal, user v1beta1.NetworkNeighbor) v1beta1.NetworkNeighbor { - merged := normal.DeepCopy() - - dnsNamesSet := make(map[string]struct{}) - for _, dns := range normal.DNSNames { - dnsNamesSet[dns] = struct{}{} - } - for _, dns := range user.DNSNames { - dnsNamesSet[dns] = struct{}{} - } - merged.DNSNames = make([]string, 0, len(dnsNamesSet)) - for dns := range dnsNamesSet { - merged.DNSNames = append(merged.DNSNames, dns) - } - - merged.Ports = mergeNetworkPorts(merged.Ports, user.Ports) - - if user.PodSelector != nil { - if merged.PodSelector == nil { - merged.PodSelector = &metav1.LabelSelector{} - } - if user.PodSelector.MatchLabels != nil { - if merged.PodSelector.MatchLabels == nil { - merged.PodSelector.MatchLabels = make(map[string]string) - } - for k, v := range user.PodSelector.MatchLabels { - merged.PodSelector.MatchLabels[k] = v - } - } - merged.PodSelector.MatchExpressions = append( - merged.PodSelector.MatchExpressions, - user.PodSelector.MatchExpressions..., - ) - } - - if user.NamespaceSelector != nil { - if merged.NamespaceSelector == nil { - merged.NamespaceSelector = &metav1.LabelSelector{} - } - if user.NamespaceSelector.MatchLabels != nil { - if merged.NamespaceSelector.MatchLabels == nil { - merged.NamespaceSelector.MatchLabels = make(map[string]string) - } - for k, v := range user.NamespaceSelector.MatchLabels { - merged.NamespaceSelector.MatchLabels[k] = v - } - } - merged.NamespaceSelector.MatchExpressions = append( - merged.NamespaceSelector.MatchExpressions, - user.NamespaceSelector.MatchExpressions..., - ) - } - - if user.IPAddress != "" { - merged.IPAddress = user.IPAddress - } - if len(user.IPAddresses) > 0 { - ipSet := make(map[string]struct{}) - for _, ip := range merged.IPAddresses { - ipSet[ip] = struct{}{} - } - for _, ip := range user.IPAddresses { - ipSet[ip] = struct{}{} - } - merged.IPAddresses = make([]string, 0, len(ipSet)) - for ip := range ipSet { - merged.IPAddresses = append(merged.IPAddresses, ip) - } - } - if user.Type != "" { - merged.Type = user.Type - } - - return *merged -} - -// mergeNetworkPorts merges user ports into a normal-ports list, keyed by Name. -// ported from -// pkg/objectcache/networkneighborhoodcache/networkneighborhoodcache.go:708-727. -func mergeNetworkPorts(normalPorts, userPorts []v1beta1.NetworkPort) []v1beta1.NetworkPort { - portMap := make(map[string]int, len(normalPorts)) - for i, port := range normalPorts { - portMap[port.Name] = i - } - for _, userPort := range userPorts { - if idx, exists := portMap[userPort.Name]; exists { - normalPorts[idx] = userPort - } else { - normalPorts = append(normalPorts, userPort) - } - } - return normalPorts -} diff --git a/pkg/objectcache/containerprofilecache/projection_apply.go b/pkg/objectcache/containerprofilecache/projection_apply.go index 711ac7311..7f34bf535 100644 --- a/pkg/objectcache/containerprofilecache/projection_apply.go +++ b/pkg/objectcache/containerprofilecache/projection_apply.go @@ -261,4 +261,3 @@ func extractIngressAddresses(cp *v1beta1.ContainerProfile) []string { } return addrs } - diff --git a/pkg/objectcache/containerprofilecache/projection_apply_test.go b/pkg/objectcache/containerprofilecache/projection_apply_test.go index 13b0d2818..f342e3273 100644 --- a/pkg/objectcache/containerprofilecache/projection_apply_test.go +++ b/pkg/objectcache/containerprofilecache/projection_apply_test.go @@ -417,6 +417,7 @@ func TestApply_ExactFilter_NoMatchYieldsNilValues(t *testing.T) { // - Path with a populated Args slice — projected as a CLONED slice // - Path with nil Args — projected as an empty (non-nil) slice // - Two ExecCalls with the same Path — last write wins +// // The cloned-slice invariant is checked by mutating the projected slice // and asserting the source is unchanged. func TestApply_ExecsByPath_PopulatesFromSpec(t *testing.T) { diff --git a/pkg/objectcache/containerprofilecache/projection_test.go b/pkg/objectcache/containerprofilecache/projection_test.go deleted file mode 100644 index 96d993de1..000000000 --- a/pkg/objectcache/containerprofilecache/projection_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package containerprofilecache - -import ( - "testing" - - "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func baseCP() *v1beta1.ContainerProfile { - return &v1beta1.ContainerProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "cp", Namespace: "default", ResourceVersion: "1"}, - Spec: v1beta1.ContainerProfileSpec{ - Capabilities: []string{"SYS_PTRACE"}, - Execs: []v1beta1.ExecCalls{ - {Path: "/bin/ls", Args: []string{"-la"}}, - }, - PolicyByRuleId: map[string]v1beta1.RulePolicy{ - "R0901": {AllowedProcesses: []string{"ls"}}, - }, - Ingress: []v1beta1.NetworkNeighbor{ - {Identifier: "ing-1", DNSNames: []string{"a.svc.local"}}, - }, - }, - } -} - -// userManagedCPWith builds a "ug-" user-managed ContainerProfile overlay from a -// flat spec. This is the migrated replacement for the legacy per-container -// ApplicationProfile + NetworkNeighborhood overlay pair. -func userManagedCPWith(spec v1beta1.ContainerProfileSpec) *v1beta1.ContainerProfile { - return &v1beta1.ContainerProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "ug-nginx", Namespace: "default", ResourceVersion: "u1"}, - Spec: spec, - } -} - -// TestProjection_UserCPOnly_Merge verifies the happy-path merge of a -// user-managed ContainerProfile overlay: capabilities / execs / policies -// unioned into the base. -func TestProjection_UserCPOnly_Merge(t *testing.T) { - cp := baseCP() - userCP := userManagedCPWith(v1beta1.ContainerProfileSpec{ - Capabilities: []string{"NET_BIND_SERVICE"}, - Execs: []v1beta1.ExecCalls{{Path: "/bin/cat"}}, - PolicyByRuleId: map[string]v1beta1.RulePolicy{ - "R0901": {AllowedProcesses: []string{"cat"}}, - "R0902": {AllowedProcesses: []string{"echo"}}, - }, - }) - - projected := projectUserManagedCP(cp, userCP) - require.NotNil(t, projected) - assert.NotSame(t, cp, projected, "projected must be a distinct DeepCopy") - assert.ElementsMatch(t, []string{"SYS_PTRACE", "NET_BIND_SERVICE"}, projected.Spec.Capabilities) - assert.Len(t, projected.Spec.Execs, 2) - // R0901 merged, R0902 added - assert.Contains(t, projected.Spec.PolicyByRuleId, "R0901") - assert.Contains(t, projected.Spec.PolicyByRuleId, "R0902") -} - -// TestProjection_UserCP_Network verifies merge of the network surface: ingress -// merged by Identifier (DNSNames unioned), LabelSelector MatchLabels overlaid. -func TestProjection_UserCP_Network(t *testing.T) { - cp := baseCP() - cp.Spec.LabelSelector = metav1.LabelSelector{MatchLabels: map[string]string{"app": "nginx"}} - userCP := userManagedCPWith(v1beta1.ContainerProfileSpec{ - Ingress: []v1beta1.NetworkNeighbor{ - {Identifier: "ing-1", DNSNames: []string{"b.svc.local"}}, - {Identifier: "ing-2", DNSNames: []string{"c.svc.local"}}, - }, - }) - userCP.Spec.LabelSelector = metav1.LabelSelector{MatchLabels: map[string]string{"env": "prod"}} - - projected := projectUserManagedCP(cp, userCP) - require.NotNil(t, projected) - require.Len(t, projected.Spec.Ingress, 2) - // ing-1 merged (DNSNames union) - var merged v1beta1.NetworkNeighbor - for _, ing := range projected.Spec.Ingress { - if ing.Identifier == "ing-1" { - merged = ing - break - } - } - assert.ElementsMatch(t, []string{"a.svc.local", "b.svc.local"}, merged.DNSNames) - // LabelSelector overlaid - assert.Equal(t, "nginx", projected.Spec.LabelSelector.MatchLabels["app"]) - assert.Equal(t, "prod", projected.Spec.LabelSelector.MatchLabels["env"]) -} - -// TestProjection_UserCP_Both verifies capabilities and ingress overlay together -// in a single merge. -func TestProjection_UserCP_Both(t *testing.T) { - cp := baseCP() - userCP := userManagedCPWith(v1beta1.ContainerProfileSpec{ - Capabilities: []string{"NET_ADMIN"}, - Ingress: []v1beta1.NetworkNeighbor{{Identifier: "ing-new"}}, - }) - - projected := projectUserManagedCP(cp, userCP) - require.NotNil(t, projected) - assert.Contains(t, projected.Spec.Capabilities, "NET_ADMIN") - // Original ing-1 plus appended ing-new - assert.Len(t, projected.Spec.Ingress, 2) -} - -// TestProjection_NilUserCP verifies projection with no overlay returns a -// DeepCopy (distinct pointer) preserving the base. -func TestProjection_NilUserCP(t *testing.T) { - cp := baseCP() - - projected := projectUserManagedCP(cp, nil) - require.NotNil(t, projected) - assert.NotSame(t, cp, projected) - assert.Equal(t, cp.Spec.Capabilities, projected.Spec.Capabilities) -} diff --git a/pkg/objectcache/containerprofilecache/reconciler.go b/pkg/objectcache/containerprofilecache/reconciler.go index c401c552d..fd5961a1e 100644 --- a/pkg/objectcache/containerprofilecache/reconciler.go +++ b/pkg/objectcache/containerprofilecache/reconciler.go @@ -5,16 +5,15 @@ // 1. reconcileOnce: evicts cache entries whose pod is gone or whose // container is no longer Running. // 2. refreshAllEntries (single-flight via atomic flag): re-fetches the -// consolidated CP, the workload-level AP+NN, the user-managed -// "ug-" AP+NN, and any label-referenced user AP/NN overlay, -// then rebuilds the projection iff any resourceVersion changed. Fast-skip -// when every RV matches what's already cached. +// consolidated ContainerProfile and any label-referenced user-defined +// ContainerProfile, then rebuilds the projection iff any resourceVersion +// changed. Fast-skip when every RV matches what's already cached. // -// RPC cost @ 300 containers / 30s cadence steady-state: up to 7 gets per -// entry per tick (CP + 3×AP + 3×NN). At 300 entries that's 70 RPC/s in the -// worst case, dropping close to 0 once fast-skip catches on. Most entries -// carry only workload-level AP+NN, so the common case is 3 RPC/tick per -// entry = 30 RPC/s. +// RPC cost @ 300 containers / 30s cadence steady-state: up to 2 gets per entry +// per tick (consolidated CP + label-referenced user-defined CP). At 300 entries +// that's ~20 RPC/s worst case, dropping close to 0 once fast-skip catches on. +// Most entries carry only the consolidated CP, so the common case is 1 RPC/tick +// per entry. package containerprofilecache import ( @@ -267,19 +266,17 @@ func (c *ContainerProfileCacheImpl) refreshAllEntries(ctx context.Context) { } // refreshOneEntry refreshes a single cache entry under the per-container lock. -// Re-fetches ALL sources the entry was originally built from (consolidated CP, -// workload-level AP/NN, user-managed AP/NN at "ug-", and any -// label-referenced user AP/NN overlay) and rebuilds the projection if ANY -// ResourceVersion changed. Keeping the existing entry on fetch errors is fine: -// the next tick will retry. +// Re-fetches ALL sources the entry was originally built from (the consolidated +// ContainerProfile and any label-referenced user-defined ContainerProfile) and +// rebuilds the projection if ANY ResourceVersion changed. Keeping the existing +// entry on fetch errors is fine: the next tick will retry. // -// Rebuild on refresh applies the same projection ladder as tryPopulateEntry: +// Rebuild on refresh mirrors tryPopulateEntry: a label-referenced user-defined +// CP, when present, REPLACES the learned CP as the authoritative base. // -// base CP → workload AP+NN → user-managed (ug-) AP+NN → user overlay AP+NN. -// -// The completed-only gate is re-applied here: if the CP regresses to a -// non-Completed status we keep the existing cached entry rather than -// projecting stale/incomplete data. +// The completed-only gate is re-applied here (only when no authored CP is +// adopted): if the learned CP regresses to a non-Completed status we keep the +// existing cached entry rather than projecting stale/incomplete data. func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id string, e *CachedContainerProfile) { // Resurrection guard (reviewer #1): refreshAllEntries snapshots entries // without holding containerLocks, so a concurrent deleteContainer / @@ -373,28 +370,6 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri helpers.String("status", cp.Annotations[helpersv1.StatusMetadataKey])) return } - // Re-fetch the user-managed "ug-" ContainerProfile overlay (migrated - // from the legacy ug- AP/NN pair). A transient fetch error keeps the entry. - var userManagedCP *v1beta1.ContainerProfile - if e.WorkloadName != "" { - ugCPName := helpersv1.UserApplicationProfilePrefix + e.WorkloadName - var userManagedCPErr error - _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userManagedCP, userManagedCPErr = c.storageClient.GetContainerProfile(rctx, ns, ugCPName) - return userManagedCPErr - }) - if userManagedCPErr != nil && e.UserManagedCPRV != "" { - logger.L().Debug("refreshOneEntry: user-managed CP fetch failed; keeping cached entry", - helpers.String("containerID", id), - helpers.String("name", ugCPName), - helpers.Error(userManagedCPErr)) - return - } - if userManagedCPErr != nil { - userManagedCP = nil // k8s client returns non-nil zero-value on 404; treat as absent - } - } - // Fast-skip when nothing changed. We match "absent" (nil) with empty RV: // this avoids spurious rebuilds when an optional source is still missing, // as long as it was also missing at the last build. Also skip when the @@ -406,12 +381,11 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri } if rvsMatchCP(cp, e.RV) && rvsMatchCP(userDefinedCP, e.UserCPRV) && - rvsMatchCP(userManagedCP, e.UserManagedCPRV) && e.SpecHash == currentSpecHash { return } - c.rebuildEntryFromSources(id, e, cp, userDefinedCP, userManagedCP) + c.rebuildEntryFromSources(id, e, cp, userDefinedCP) } // rvsMatchCP returns true when either (a) the object is absent and the stored RV @@ -425,9 +399,9 @@ func rvsMatchCP(obj *v1beta1.ContainerProfile, rv string) bool { } // rebuildEntryFromSources constructs a fresh CachedContainerProfile from the -// given sources and stores it under `id`. Applies the projection ladder from -// tryPopulateEntry: base CP (or synthesized) → user-managed (ug-) AP+NN → -// label-referenced user overlay AP+NN. +// given sources and stores it under `id`. Mirrors tryPopulateEntry: a +// label-referenced user-defined CP, when present, REPLACES the learned CP (or +// the synthesized base) as the authoritative base. // // Called by the reconciler when any input ResourceVersion has changed. func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( @@ -435,7 +409,6 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( prev *CachedContainerProfile, cp *v1beta1.ContainerProfile, userDefinedCP *v1beta1.ContainerProfile, - userManagedCP *v1beta1.ContainerProfile, ) { // Authored-validation (mirror of the add path): a label-referenced CP that // carries lifecycle annotations is a LEARNED profile, not an authored one. @@ -467,9 +440,8 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( effectiveCP = userDefinedCP } - // When neither a learned nor a user-defined CP is available but we still - // have user-managed overlays to project, synthesize an empty base so - // downstream state display is sensible. + // When neither a learned nor a user-defined CP is available, synthesize an + // empty base so downstream state display is sensible. if effectiveCP == nil { syntheticName := prev.WorkloadName if syntheticName == "" { @@ -488,12 +460,6 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( } projected := effectiveCP - // User-managed "ug-" ContainerProfile overlay merge (migrated from - // the legacy ug- AP/NN pair). The label-referenced user-defined overlay is a - // whole ContainerProfile adopted directly as effectiveCP above. - if userManagedCP != nil { - projected = projectUserManagedCP(projected, userManagedCP) - } // Rebuild the call-stack search tree from the projected profile. tree := callstackcache.NewCallStackSearchTree() @@ -511,20 +477,19 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( } newEntry := &CachedContainerProfile{ - Projected: projectedCP, - SpecHash: projectedCP.SpecHash, - State: &objectcache.ProfileState{Completion: effectiveCP.Annotations[helpersv1.CompletionMetadataKey], Status: effectiveCP.Annotations[helpersv1.StatusMetadataKey], Name: effectiveCP.Name}, - CallStackTree: tree, - ContainerName: prev.ContainerName, - PodName: prev.PodName, - Namespace: prev.Namespace, - PodUID: podUID, - WorkloadID: prev.WorkloadID, - CPName: prev.CPName, - WorkloadName: prev.WorkloadName, - RV: rvOfCP(cp), - UserManagedCPRV: rvOfCP(userManagedCP), - UserCPRV: rvOfCP(userDefinedCP), + Projected: projectedCP, + SpecHash: projectedCP.SpecHash, + State: &objectcache.ProfileState{Completion: effectiveCP.Annotations[helpersv1.CompletionMetadataKey], Status: effectiveCP.Annotations[helpersv1.StatusMetadataKey], Name: effectiveCP.Name}, + CallStackTree: tree, + ContainerName: prev.ContainerName, + PodName: prev.PodName, + Namespace: prev.Namespace, + PodUID: podUID, + WorkloadID: prev.WorkloadID, + CPName: prev.CPName, + WorkloadName: prev.WorkloadName, + RV: rvOfCP(cp), + UserCPRV: rvOfCP(userDefinedCP), } if userDefinedCP != nil { // The user-authored CP is authoritative and complete by definition (no diff --git a/pkg/objectcache/containerprofilecache/reconciler_test.go b/pkg/objectcache/containerprofilecache/reconciler_test.go index 03248b0f5..5e89e9ac7 100644 --- a/pkg/objectcache/containerprofilecache/reconciler_test.go +++ b/pkg/objectcache/containerprofilecache/reconciler_test.go @@ -2,7 +2,6 @@ package containerprofilecache import ( "context" - "strings" "sync" "sync/atomic" "testing" @@ -372,15 +371,19 @@ func TestRefreshNoEntryWhenCPGetFails(t *testing.T) { assert.Same(t, entry, stored, "entry pointer must not change when CP fetch fails") } -// TestRefreshPreservesEntryOnTransientOverlayError — overlay fetch errors must -// not strip overlay data from the cache. If the user-managed "ug-" -// ContainerProfile GET returns an error while the entry already has a non-empty -// cached RV for that overlay, refreshOneEntry must keep the old entry unchanged -// (same pointer) rather than rebuilding without the overlay and clearing its RV. -// Regression test for the refreshRPC timeout → silent nil → spurious rebuild path. -func TestRefreshPreservesEntryOnTransientOverlayError(t *testing.T) { - // Base CP is terminal (Completed) so refreshOneEntry passes the status gate - // and actually reaches the user-managed overlay fetch. +// TestRefreshPreservesEntryOnTransientUserCPError — a transient error fetching +// the user-defined (label-referenced) ContainerProfile must not strip the +// authored overlay from the cache. When refreshOneEntry re-fetches the +// user-defined CP (because entry.UserCPRef is set) and the GET returns an error +// while the entry already holds a non-empty UserCPRV, refreshOneEntry must keep +// the old entry unchanged (same pointer) rather than rebuilding without the +// authored profile and clearing its RV. Regression test for the refreshRPC +// timeout → silent nil → spurious rebuild path, migrated from the removed +// legacy "ug-" user-managed overlay to the user-defined CP mechanism. +func TestRefreshPreservesEntryOnTransientUserCPError(t *testing.T) { + // Base (learned) CP is terminal (Completed) and its RV matches the entry, so + // the base fetch succeeds without an early return and refreshOneEntry reaches + // the user-defined CP fetch. cp := &v1beta1.ContainerProfile{ ObjectMeta: metav1.ObjectMeta{ Name: "cp", Namespace: "default", ResourceVersion: "100", @@ -392,47 +395,51 @@ func TestRefreshPreservesEntryOnTransientOverlayError(t *testing.T) { Spec: v1beta1.ContainerProfileSpec{Capabilities: []string{"SYS_PTRACE"}}, } - client := &overlayErrorClient{cp: cp, ugCPErr: assertErr{}} + // The user-defined CP fetch (by UserCPRef.Name) fails transiently. + client := &userCPErrorClient{cp: cp, userName: "override", userCPErr: assertErr{}} k8s := newControllableK8sCache() c := newReconcilerCache(t, client, k8s, nil) id := "c1" entry := &CachedContainerProfile{ - Projected: Apply(nil, cp, nil), - State: &objectcache.ProfileState{Name: cp.Name}, - ContainerName: "nginx", - PodName: "nginx-abc", - Namespace: "default", - PodUID: "uid-1", - CPName: "cp", - RV: "100", - WorkloadName: "nginx", - UserManagedCPRV: "9", + Projected: Apply(nil, cp, nil), + State: &objectcache.ProfileState{Name: cp.Name}, + ContainerName: "nginx", + PodName: "nginx-abc", + Namespace: "default", + PodUID: "uid-1", + CPName: "cp", + RV: "100", + WorkloadName: "nginx", + UserCPRef: &namespacedName{Namespace: "default", Name: "override"}, + UserCPRV: "9", } c.entries.Set(id, entry) c.refreshAllEntries(context.Background()) stored, ok := c.entries.Load(id) - require.True(t, ok, "overlay error must not delete the entry") - assert.Same(t, entry, stored, "entry pointer must not change when overlay fetch fails transiently") - // The overlay RV must be unchanged (not cleared to ""). - assert.Equal(t, "9", stored.UserManagedCPRV, "UserManagedCPRV must be unchanged after a transient overlay-fetch error") + require.True(t, ok, "user-defined CP error must not delete the entry") + assert.Same(t, entry, stored, "entry pointer must not change when user-defined CP fetch fails transiently") + // The authored RV must be unchanged (not cleared to ""). + assert.Equal(t, "9", stored.UserCPRV, "UserCPRV must be unchanged after a transient user-defined CP fetch error") } -// overlayErrorClient returns a valid base CP but fails the user-managed -// "ug-" ContainerProfile fetch with the configured error. Used to -// test overlay error-preservation logic. -type overlayErrorClient struct { - cp *v1beta1.ContainerProfile - ugCPErr error +// userCPErrorClient returns a valid base CP for any name except userName, whose +// fetch fails with userCPErr. Used to test user-defined CP error-preservation: +// the base/learned CP fetch succeeds while the label-referenced authored CP GET +// fails transiently. +type userCPErrorClient struct { + cp *v1beta1.ContainerProfile + userName string + userCPErr error } -var _ storage.ProfileClient = (*overlayErrorClient)(nil) +var _ storage.ProfileClient = (*userCPErrorClient)(nil) -func (o *overlayErrorClient) GetContainerProfile(_ context.Context, _, name string) (*v1beta1.ContainerProfile, error) { - if strings.HasPrefix(name, helpersv1.UserApplicationProfilePrefix) { - return nil, o.ugCPErr +func (o *userCPErrorClient) GetContainerProfile(_ context.Context, _, name string) (*v1beta1.ContainerProfile, error) { + if name == o.userName { + return nil, o.userCPErr } return o.cp, nil } @@ -597,11 +604,11 @@ func TestRetryPendingEntries_CPCreatedAfterAdd(t *testing.T) { assert.NotNil(t, c.GetProjectedContainerProfile(id), "entry promoted after CP appears") assert.Equal(t, 0, c.pending.Len(), "pending drained on successful promotion") - // Four GETs total: each populate attempt issues two GetContainerProfile - // calls — the base CP plus the user-managed "ug-" overlay CP - // (the migrated replacement for the legacy ug- AP/NN pair). addContainer + // Two GETs total: this container carries no user-defined-profile label, so + // each populate attempt issues exactly one GetContainerProfile call for the + // base CP (there is no legacy "ug-" overlay fetch anymore). addContainer // performs one attempt (base 404), the retry performs the second (base 200). - assert.Equal(t, 4, client.getCPCalls, "each tick re-GETs the base CP and the ug- overlay CP exactly once") + assert.Equal(t, 2, client.getCPCalls, "each tick re-GETs the base CP exactly once") } // TestPendingEntriesAreNotGCedBeforeRetry verifies we no longer drop pending @@ -930,70 +937,6 @@ func TestNotifyContainerTerminal_Completed(t *testing.T) { assert.Equal(t, helpersv1.Completed, stored.State.Status) } -// TestUserManagedProfileMerged exercises the user-managed merge path -// (Test_12_MergingProfilesTest / Test_13_MergingNetworkNeighborhoodTest): -// a user-managed ContainerProfile published at "ug-" is merged on -// top of the base CP. Anomalies NOT in the union of base + user-managed should -// produce alerts; anomalies present in either source should not. -func TestUserManagedProfileMerged(t *testing.T) { - // Base CP has exec "/bin/X"; user-managed CP adds "/bin/Y". - cp := &v1beta1.ContainerProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cp-base", - Namespace: "default", - ResourceVersion: "1", - Annotations: map[string]string{ - helpersv1.CompletionMetadataKey: helpersv1.Full, - helpersv1.StatusMetadataKey: helpersv1.Completed, - }, - }, - Spec: v1beta1.ContainerProfileSpec{ - Execs: []v1beta1.ExecCalls{{Path: "/bin/X"}}, - }, - } - userManagedCP := &v1beta1.ContainerProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: "ug-nginx", - Namespace: "default", - ResourceVersion: "9", - Annotations: map[string]string{ - helpersv1.CompletionMetadataKey: helpersv1.Full, - helpersv1.StatusMetadataKey: helpersv1.Completed, - }, - }, - Spec: v1beta1.ContainerProfileSpec{ - Execs: []v1beta1.ExecCalls{{Path: "/bin/Y"}}, - }, - } - client := &fakeProfileClient{ - cp: cp, - userManagedCP: userManagedCP, - } - c, k8s := newTestCache(t, client) - - c.SetProjectionSpec(objectcache.RuleProjectionSpec{ - Execs: objectcache.FieldSpec{InUse: true, All: true}, - Hash: "user-managed-test", - }) - - id := "container-user-managed" - primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") - require.NoError(t, c.addContainer(eventContainer(id), context.Background())) - - cached := c.GetProjectedContainerProfile(id) - require.NotNil(t, cached, "entry populated") - _, hasX := cached.Execs.Values["/bin/X"] - _, hasY := cached.Execs.Values["/bin/Y"] - assert.True(t, hasX, "base CP exec must be present") - assert.True(t, hasY, "user-managed (ug-) CP exec must be merged in") - - // Verify the RV was captured so a later user-managed update would trigger - // a refresh rebuild. - entry, ok := c.entries.Load(id) - require.True(t, ok) - assert.Equal(t, "9", entry.UserManagedCPRV, "UserManagedCPRV recorded at add time") -} - // TestSpecChange_TriggersReprojection — T5 nudge integration. // // After SetProjectionSpec is called with a new spec, RefreshAllEntriesForTest diff --git a/tests/component_test.go b/tests/component_test.go index 06abe6af7..ea64f6c6a 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -34,7 +34,6 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/dynamic" - "k8s.io/utils/ptr" "sigs.k8s.io/yaml" ) @@ -659,429 +658,6 @@ func Test_11_EndpointTest(t *testing.T) { } } -func Test_12_MergingProfilesTest(t *testing.T) { - start := time.Now() - defer tearDownTest(t, start) - - // PHASE 1: Setup workload and initial profile - ns := testutils.NewRandomNamespace() - wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/deployment-multiple-containers.yaml")) - require.NoError(t, err, "Failed to create workload") - require.NoError(t, wl.WaitForReady(80), "Workload failed to be ready") - // require.NoError(t, wl.WaitForContainerProfile(80, "ready"), "Application profile not ready") - time.Sleep(10 * time.Second) - - // Generate initial profile data - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "nginx") - require.NoError(t, err, "Failed to exec into nginx container") - _, _, err = wl.ExecIntoPod([]string{"wget", "ebpf.io", "-T", "2", "-t", "1"}, "server") - require.NoError(t, err, "Failed to exec into server container") - - require.NoError(t, wl.WaitForContainerProfileCompletion(160), "Profile failed to complete") - time.Sleep(10 * time.Second) // Allow profile processing - - // Log initial profile state - initialProfiles, err := wl.GetContainerProfiles() - require.NoError(t, err, "Failed to get initial profiles") - initialProfilesJSON, _ := json.Marshal(initialProfiles) - t.Logf("Initial container profiles:\n%s", string(initialProfilesJSON)) - - // PHASE 2: Verify initial alerts - t.Log("Testing initial alert generation...") - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "nginx") // Expected: no alert - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "server") // Expected: alert - // time.Sleep(2 * time.Minute) // Wait for alert generation - time.Sleep(30 * time.Second) // Wait for alert generation - - initialAlerts, err := testutils.GetAlerts(wl.Namespace) - require.NoError(t, err, "Failed to get initial alerts") - - // Record initial alert count - initialAlertCount := 0 - for _, alert := range initialAlerts { - if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "Unexpected process launched" { - initialAlertCount++ - } - } - - testutils.AssertContains(t, initialAlerts, "Unexpected process launched", "ls", "server", []bool{true}) - testutils.AssertNotContains(t, initialAlerts, "Unexpected process launched", "ls", "nginx", []bool{true, false}) - - // PHASE 3: Apply user-managed profiles - t.Log("Applying user-managed profiles...") - // The unified ContainerProfile is authored per container, so a user-managed - // override is one ContainerProfile per container (managed-by: User) carrying - // the allowed surfaces on its flat spec. - userProfiles := []*v1beta1.ContainerProfile{ - { - ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("ug-%s-nginx", wl.WorkloadObj.GetName()), - Namespace: ns.Name, - Annotations: map[string]string{ - "kubescape.io/managed-by": "User", - }, - Labels: map[string]string{ - "kubescape.io/workload-container-name": "nginx", - }, - }, - Spec: v1beta1.ContainerProfileSpec{ - Architectures: []string{"amd64"}, - Execs: []v1beta1.ExecCalls{ - { - Path: "/usr/bin/ls", - Args: []string{"/usr/bin/ls", "-l"}, - }, - }, - }, - }, - { - ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("ug-%s-server", wl.WorkloadObj.GetName()), - Namespace: ns.Name, - Annotations: map[string]string{ - "kubescape.io/managed-by": "User", - }, - Labels: map[string]string{ - "kubescape.io/workload-container-name": "server", - }, - }, - Spec: v1beta1.ContainerProfileSpec{ - Architectures: []string{"amd64"}, - Execs: []v1beta1.ExecCalls{ - { - Path: "/bin/ls", - Args: []string{"/bin/ls", "-l"}, - }, - { - Path: "/bin/grpc_health_probe", - Args: []string{"-addr=:9555"}, - }, - }, - }, - }, - } - - // Log the profiles we're about to create - userProfilesJSON, err := json.MarshalIndent(userProfiles, "", " ") - require.NoError(t, err, "Failed to marshal user profiles") - t.Logf("Creating user profiles:\n%s", string(userProfilesJSON)) - - // Get k8s client - k8sClient := k8sinterface.NewKubernetesApi() - - // Create the user-managed profiles - storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - for _, up := range userProfiles { - _, err = storageClient.ContainerProfiles(ns.Name).Create(context.Background(), up, metav1.CreateOptions{}) - require.NoError(t, err, "Failed to create user profile %s", up.Name) - } - - // PHASE 4: Verify merged profile behavior - t.Log("Verifying merged profile behavior...") - time.Sleep(1 * time.Minute) // Allow merge to complete - - // Test merged profile behavior - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "nginx") // Expected: no alert - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "server") // Expected: no alert (user profile should suppress alert) - time.Sleep(1 * time.Minute) // Wait for potential alerts - - // Verify alert counts - finalAlerts, err := testutils.GetAlerts(wl.Namespace) - require.NoError(t, err, "Failed to get final alerts") - - // Only count new alerts (after the initial count) - newAlertCount := 0 - for _, alert := range finalAlerts { - if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "Unexpected process launched" { - newAlertCount++ - } - } - - t.Logf("Alert counts - Initial: %d, Final: %d", initialAlertCount, newAlertCount) - - if newAlertCount > initialAlertCount { - t.Logf("Full alert details:") - for _, alert := range finalAlerts { - if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "Unexpected process launched" { - t.Logf("Alert: %+v", alert) - } - } - t.Errorf("New alerts were generated after merge (Initial: %d, Final: %d)", initialAlertCount, newAlertCount) - } - - // The new cache doesn't listen to patches - // PHASE 5: Check PATCH (removing the ls command from the user profile of the server container and triggering an alert) - // t.Log("Patching user profile to remove ls command from server container...") - // patchOperations := []utils.PatchOperation{ - // {Op: "remove", Path: "/spec/containers/1/execs/0"}, - // } - - // patch, err := json.Marshal(patchOperations) - // require.NoError(t, err, "Failed to marshal patch operations") - - // _, err = storageClient.ApplicationProfiles(ns.Name).Patch(context.Background(), userProfile.Name, types.JSONPatchType, patch, metav1.PatchOptions{}) - // require.NoError(t, err, "Failed to patch user profile") - - // // Verify patched profile behavior - // time.Sleep(15 * time.Second) // Allow merge to complete - - // // Log the profile that was patched - // patchedProfile, err := wl.GetApplicationProfile() - // require.NoError(t, err, "Failed to get patched profile") - // t.Logf("Patched application profile:\n%v", patchedProfile) - - // // Test patched profile behavior - // wl.ExecIntoPod([]string{"ls", "-l"}, "nginx") // Expected: no alert - // wl.ExecIntoPod([]string{"ls", "-l"}, "server") // Expected: alert (ls command removed from user profile) - // time.Sleep(10 * time.Second) // Wait for potential alerts - - // // Verify alert counts - // finalAlerts, err = testutils.GetAlerts(wl.Namespace) - // require.NoError(t, err, "Failed to get final alerts") - - // // Only count new alerts (after the initial count) - // newAlertCount = 0 - // for _, alert := range finalAlerts { - // if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "Unexpected process launched" { - // newAlertCount++ - // } - // } - - // t.Logf("Alert counts - Initial: %d, Final: %d", initialAlertCount, newAlertCount) - - // if newAlertCount <= initialAlertCount { - // t.Logf("Full alert details:") - // for _, alert := range finalAlerts { - // if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "Unexpected process launched" { - // t.Logf("Alert: %+v", alert) - // } - // } - // t.Errorf("New alerts were not generated after patch (Initial: %d, Final: %d)", initialAlertCount, newAlertCount) - // } -} - -func Test_13_MergingNetworkNeighborhoodTest(t *testing.T) { - start := time.Now() - defer tearDownTest(t, start) - - // PHASE 1: Setup workload and initial network neighborhood - ns := testutils.NewRandomNamespace() - wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/deployment-multiple-containers.yaml")) - require.NoError(t, err, "Failed to create workload") - require.NoError(t, wl.WaitForReady(80), "Workload failed to be ready") - require.NoError(t, wl.WaitForContainerProfile(80, "ready"), "Network neighborhood not ready") - - // Generate initial network data - _, _, err = wl.ExecIntoPod([]string{"wget", "ebpf.io", "-T", "2", "-t", "1"}, "server") - require.NoError(t, err, "Failed to exec wget in server container") - _, _, err = wl.ExecIntoPod([]string{"curl", "kubernetes.io", "-m", "2"}, "nginx") - require.NoError(t, err, "Failed to exec curl in nginx container") - - require.NoError(t, wl.WaitForContainerProfileCompletion(80), "Network neighborhood failed to complete") - time.Sleep(10 * time.Second) // Allow network neighborhood processing - - // Log initial network surface state (one ContainerProfile per container) - initialProfiles, err := wl.GetContainerProfiles() - require.NoError(t, err, "Failed to get initial container profiles") - initialProfilesJSON, _ := json.Marshal(initialProfiles) - t.Logf("Initial container profiles:\n%s", string(initialProfilesJSON)) - - // PHASE 2: Verify initial alerts - t.Log("Testing initial alert generation...") - _, _, err = wl.ExecIntoPod([]string{"wget", "ebpf.io", "-T", "2", "-t", "1"}, "server") // Expected: no alert (original rule) - _, _, err = wl.ExecIntoPod([]string{"wget", "httpforever.com", "-T", "2", "-t", "1"}, "server") // Expected: alert (not allowed) - _, _, err = wl.ExecIntoPod([]string{"wget", "httpforever.com", "-T", "2", "-t", "1"}, "server") // Expected: alert (not allowed) - _, _, err = wl.ExecIntoPod([]string{"wget", "httpforever.com", "-T", "2", "-t", "1"}, "server") // Expected: alert (not allowed) - _, _, err = wl.ExecIntoPod([]string{"curl", "kubernetes.io", "-m", "2"}, "nginx") // Expected: no alert (original rule) - _, _, err = wl.ExecIntoPod([]string{"curl", "github.com", "-m", "2"}, "nginx") // Expected: alert (not allowed) - time.Sleep(30 * time.Second) // Wait for alert generation - - initialAlerts, err := testutils.GetAlerts(wl.Namespace) - require.NoError(t, err, "Failed to get initial alerts") - - // Record initial alert count - initialAlertCount := 0 - for _, alert := range initialAlerts { - if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "DNS Anomalies in container" && alert.Labels["container_name"] == "server" { - initialAlertCount++ - } - } - - // Verify initial alerts - testutils.AssertContains(t, initialAlerts, "DNS Anomalies in container", "wget", "server", []bool{true}) - testutils.AssertContains(t, initialAlerts, "DNS Anomalies in container", "curl", "nginx", []bool{true}) - - // PHASE 3: Apply user-managed network surface (one ContainerProfile per container) - t.Log("Applying user-managed container profiles...") - selector := metav1.LabelSelector{ - MatchLabels: map[string]string{ - "app": "multiple-containers-app", - }, - } - userNginxCP := &v1beta1.ContainerProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("ug-%s-nginx", wl.WorkloadObj.GetName()), - Namespace: ns.Name, - Annotations: map[string]string{ - "kubescape.io/managed-by": "User", - }, - Labels: map[string]string{ - "kubescape.io/workload-container-name": "nginx", - }, - }, - Spec: v1beta1.ContainerProfileSpec{ - LabelSelector: selector, - Egress: []v1beta1.NetworkNeighbor{ - { - Identifier: "nginx-github", - Type: "external", - DNSNames: []string{"github.com."}, - Ports: []v1beta1.NetworkPort{ - { - Name: "TCP-80", - Protocol: "TCP", - Port: ptr.To(int32(80)), - }, - { - Name: "TCP-443", - Protocol: "TCP", - Port: ptr.To(int32(443)), - }, - }, - }, - }, - }, - } - userServerCP := &v1beta1.ContainerProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("ug-%s-server", wl.WorkloadObj.GetName()), - Namespace: ns.Name, - Annotations: map[string]string{ - "kubescape.io/managed-by": "User", - }, - Labels: map[string]string{ - "kubescape.io/workload-container-name": "server", - }, - }, - Spec: v1beta1.ContainerProfileSpec{ - LabelSelector: selector, - Egress: []v1beta1.NetworkNeighbor{ - { - Identifier: "server-example", - Type: "external", - DNSNames: []string{"info.cern.ch."}, - Ports: []v1beta1.NetworkPort{ - { - Name: "TCP-80", - Protocol: "TCP", - Port: ptr.To(int32(80)), - }, - { - Name: "TCP-443", - Protocol: "TCP", - Port: ptr.To(int32(443)), - }, - }, - }, - }, - }, - } - - // Create user-managed container profiles - k8sClient := k8sinterface.NewKubernetesApi() - storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - _, err = storageClient.ContainerProfiles(ns.Name).Create(context.Background(), userNginxCP, metav1.CreateOptions{}) - require.NoError(t, err, "Failed to create user nginx container profile") - _, err = storageClient.ContainerProfiles(ns.Name).Create(context.Background(), userServerCP, metav1.CreateOptions{}) - require.NoError(t, err, "Failed to create user server container profile") - - // PHASE 4: Verify merged behavior (no new alerts) - t.Log("Verifying merged network neighborhood behavior...") - time.Sleep(60 * time.Second) // Allow merge to complete - - _, _, err = wl.ExecIntoPod([]string{"wget", "ebpf.io", "-T", "2", "-t", "1"}, "server") // Expected: no alert (original) - // Try multiple times to ensure alert is removed - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: no alert (user added) - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: no alert (user added) - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: no alert (user added) - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: no alert (user added) - _, _, err = wl.ExecIntoPod([]string{"curl", "kubernetes.io", "-m", "2"}, "nginx") // Expected: no alert (original) - _, _, err = wl.ExecIntoPod([]string{"curl", "github.com", "-m", "2"}, "nginx") // Expected: no alert (user added) - time.Sleep(30 * time.Second) // Wait for potential alerts - - mergedAlerts, err := testutils.GetAlerts(wl.Namespace) - require.NoError(t, err, "Failed to get alerts after merge") - - // Count new alerts after merge - newAlertCount := 0 - for _, alert := range mergedAlerts { - if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "DNS Anomalies in container" && alert.Labels["container_name"] == "server" { - newAlertCount++ - } - } - - t.Logf("Alert counts - Initial: %d, After merge: %d", initialAlertCount, newAlertCount) - - if newAlertCount > initialAlertCount { - t.Logf("Full alert details:") - for _, alert := range mergedAlerts { - if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "DNS Anomalies in container" && alert.Labels["container_name"] == "server" { - t.Logf("Alert: %+v", alert) - } - } - t.Errorf("New alerts were generated after merge (Initial: %d, After merge: %d)", initialAlertCount, newAlertCount) - } - - // PHASE 5: Remove permission via patch and verify alerts return - t.Log("Patching user server container profile to remove info.cern.ch egress...") - patchOperations := []utils.PatchOperation{ - {Op: "remove", Path: "/spec/egress/0"}, - } - - patch, err := json.Marshal(patchOperations) - require.NoError(t, err, "Failed to marshal patch operations") - - _, err = storageClient.ContainerProfiles(ns.Name).Patch(context.Background(), userServerCP.Name, types.JSONPatchType, patch, metav1.PatchOptions{}) - require.NoError(t, err, "Failed to patch user server container profile") - - time.Sleep(60 * time.Second) // Allow merge to complete - - // Test alerts after patch - _, _, err = wl.ExecIntoPod([]string{"wget", "ebpf.io", "-T", "2", "-t", "1"}, "server") // Expected: no alert - // Try multiple times to ensure alert is removed - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: alert (removed) - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: alert (removed) - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: alert (removed) - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: alert (removed) - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: alert (removed) - _, _, err = wl.ExecIntoPod([]string{"curl", "kubernetes.io", "-m", "2"}, "nginx") // Expected: no alert - _, _, err = wl.ExecIntoPod([]string{"curl", "github.com", "-m", "2"}, "nginx") // Expected: no alert - time.Sleep(30 * time.Second) // Wait for alerts - - finalAlerts, err := testutils.GetAlerts(wl.Namespace) - require.NoError(t, err, "Failed to get final alerts") - - // Count final alerts - finalAlertCount := 0 - for _, alert := range finalAlerts { - if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "DNS Anomalies in container" && alert.Labels["container_name"] == "server" { - finalAlertCount++ - } - } - - t.Logf("Alert counts - Initial: %d, Final: %d", initialAlertCount, finalAlertCount) - - if finalAlertCount <= initialAlertCount { - t.Logf("Full alert details:") - for _, alert := range finalAlerts { - if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "DNS Anomalies in container" && alert.Labels["container_name"] == "server" { - t.Logf("Alert: %+v", alert) - } - } - t.Errorf("New alerts were not generated after patch (Initial: %d, Final: %d)", initialAlertCount, finalAlertCount) - } -} - func Test_14_RulePoliciesTest(t *testing.T) { ns := testutils.NewRandomNamespace() @@ -3368,3 +2944,85 @@ func Test_34_NetworkNeighborsCIDRCollapse(t *testing.T) { t.Logf("collapse validated on real cloud ranges: S3=%v spread=%v scattered=%v split=%v", withPrefixes(s3CIDRs, "52.216.1."), withPrefixes(spreadCIDRs, "52.216."), got, withPrefixes(splitCIDRs, "52.216.2.")) } + +// Test_35_MultiContainerPerContainerBinding pins the per-container binding of +// user-defined ContainerProfiles (review finding on node-agent#864). A +// multi-container pod shares ONE `kubescape.io/user-defined-profile` label +// value, but each container is profiled independently: node-agent resolves each +// container's authored CP as ".⋯." row — "DynamicIdentifier — exactly one label" -# Why this exists: -# RFC 4592 only standardises LEADING wildcards. Mid-label `*` is non-standard -# (cilium uses regex; bind/coredns reject it). v0.0.2 uses `⋯` (our token, -# from path/argv wildcards) for mid positions so the wire format never -# claims false RFC 4592 compliance. -# Token reminder: -# `⋯` is U+22EF (MIDLINE HORIZONTAL ELLIPSIS) — ONE Unicode codepoint. -# It is NOT three ASCII periods (`...`). -# -apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: ContainerProfile -metadata: - name: nw-11-dns-mid-ellipsis -spec: - matchLabels: - app: nw-11 - egress: - - identifier: cluster-svc-resolution - type: internal - dnsNames: - - "svc.⋯.cluster.local." - ports: - - {name: UDP-53, protocol: UDP, port: 53} diff --git a/tests/resources/network-wildcards-cp/12-dns-trailing-star.yaml b/tests/resources/network-wildcards-cp/12-dns-trailing-star.yaml deleted file mode 100644 index a4c8d613e..000000000 --- a/tests/resources/network-wildcards-cp/12-dns-trailing-star.yaml +++ /dev/null @@ -1,41 +0,0 @@ -# Fixture 12 — DNS trailing wildcard `.*` -# -# Edge case: one OR MORE labels after the prefix (NEVER zero) -# Expects: -# observed "mycorp.com.api." → match (one label after) -# observed "mycorp.com.api.v1." → match (two labels after) -# observed "mycorp.com.api.v1.eu-west-1." → match (three labels after) -# observed "mycorp.com." → NO match (apex — zero labels; -# trailing `*` requires ≥1) -# Match path: label-split + recursive matcher with one-or-more-segment -# semantic on trailing `*`. Same defensive arity rule as paths -# (§5.1) — closes the apex blind spot. -# Spec ref: §5.8 ".*" row, "one or more labels (never zero)" -# -# IMPORTANT clarification on label order: -# DNS names are read LEFT-TO-RIGHT but their label hierarchy goes -# RIGHT-TO-LEFT (the rightmost label is the TLD). So for `mycorp.com.*`, -# the `*` sits in the LEFTMOST positions of any matching name. This -# is opposite to the path convention. Both conventions agree that the -# `*` consumes "1+ tokens at the variable end" — they just differ on -# which end is variable. -# -# Producers should usually prefer `*.mycorp.com.` (leading-`*` per -# RFC 4592) for "any subdomain" intent, since that's the standardised -# form. The trailing form documented here is for cases where the -# variable hierarchy is on the LEFT of a fixed registry suffix. -# -apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: ContainerProfile -metadata: - name: nw-12-dns-trailing-star -spec: - matchLabels: - app: nw-12 - egress: - - identifier: mycorp-anything-deeper - type: external - dnsNames: - - "mycorp.com.*" - ports: - - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards-cp/13-dns-trailing-dot-normalisation.yaml b/tests/resources/network-wildcards-cp/13-dns-trailing-dot-normalisation.yaml deleted file mode 100644 index 2bd56d58f..000000000 --- a/tests/resources/network-wildcards-cp/13-dns-trailing-dot-normalisation.yaml +++ /dev/null @@ -1,34 +0,0 @@ -# Fixture 13 — trailing-dot normalisation -# -# Edge case: DNS literals MUST compare equal whether or not the trailing -# dot is present, on either side -# Expects (with profile entry "api.stripe.com." — WITH dot): -# observed "api.stripe.com." → match -# observed "api.stripe.com" → match (verifier normalises) -# Expects (with profile entry "api.stripe.com" — WITHOUT dot): -# observed "api.stripe.com." → match -# observed "api.stripe.com" → match -# Match path: verifier MUST canonicalise both sides before comparison -# (e.g. always append "." if missing) -# Spec ref: §5.8 "Trailing-dot normalisation" paragraph -# Producer guidance: emit the trailing dot — it's the FQDN-canonical form per -# RFC 1035. But verifiers MUST accept either. -# -# This fixture deliberately mixes both forms in dnsNames[] to ensure the -# normalisation runs on profile-side entries, not just observed names. -# -apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: ContainerProfile -metadata: - name: nw-13-dns-trailing-dot -spec: - matchLabels: - app: nw-13 - egress: - - identifier: mixed-trailing-dot-forms - type: external - dnsNames: - - "api.stripe.com." # canonical FQDN form - - "api.stripe.com" # same host, without trailing dot — must compare equal - ports: - - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards-cp/14-recursive-star-rejected.yaml b/tests/resources/network-wildcards-cp/14-recursive-star-rejected.yaml deleted file mode 100644 index 64d2a0e24..000000000 --- a/tests/resources/network-wildcards-cp/14-recursive-star-rejected.yaml +++ /dev/null @@ -1,32 +0,0 @@ -# Fixture 14 — `**` recursive wildcard MUST be rejected -# -# Edge case: a producer attempts to use the recursive `**` wildcard -# Expects: apiserver admission strategy REJECTS the document at write time -# (kubectl apply returns an error; nothing is persisted) -# Match path: N/A — never reaches a runtime matcher -# Spec ref: §5.8 last row "** (recursive zero-or-more) — NOT in v0.0.2" -# and "Empty / ** rejection" paragraph -# Why deferred to v0.0.3: -# `**` semantics need careful design — should it match zero labels? -# how does it interact with leading/trailing `*`? Reserve the syntax now -# so producers don't accidentally rely on a future behaviour change. -# -# This fixture is INTENTIONALLY INVALID. The component test should: -# 1. Attempt `kubectl apply -f 14-recursive-star-rejected.yaml` -# 2. Assert the command fails with a validation error -# 3. Assert no NetworkNeighborhood named `nw-14-recursive-rejected` exists -# -apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: ContainerProfile -metadata: - name: nw-14-recursive-rejected -spec: - matchLabels: - app: nw-14 - egress: - - identifier: invalid-recursive - type: external - dnsNames: - - "**.example.com." # INVALID — admission MUST reject - ports: - - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards-cp/15-egress-and-ingress.yaml b/tests/resources/network-wildcards-cp/15-egress-and-ingress.yaml deleted file mode 100644 index 0558681cb..000000000 --- a/tests/resources/network-wildcards-cp/15-egress-and-ingress.yaml +++ /dev/null @@ -1,41 +0,0 @@ -# Fixture 15 — egress AND ingress on the same container -# -# Edge case: both directions populated; matchers MUST be independently scoped -# Expects: -# pktType=='OUTGOING' to "10.1.2.3" → match in egress (CIDR 10.0.0.0/8) -# pktType=='OUTGOING' to "192.0.2.1" → NO match in egress (NOT in CIDR) -# pktType=='INCOMING' from "192.168.1.42" → match in ingress (CIDR 192.168.0.0/16) -# pktType=='INCOMING' from "10.0.0.42" → NO match in ingress (NOT in 192.168/16) -# (even though 10.0.0.0/8 IS in egress — -# direction isolation is the contract) -# Match path: cp.was_address_in_egress() walks Spec.Egress only; -# cp.was_address_in_ingress() walks Spec.Ingress only -# Spec ref: §4.7 "egress and ingress" — direction isolation contract -# -# Note on current rule coverage: -# The default kubescape rule set (R0005, R0011, etc.) only fires on -# pktType=='OUTGOING'. The ingress block is fully matchable via the -# cp.was_address_in_ingress / cp.is_domain_in_ingress CEL functions, -# but no built-in rule consumes them as of v0.0.2. Custom rules MAY. -# -apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: ContainerProfile -metadata: - name: nw-15-egress-and-ingress -spec: - matchLabels: - app: nw-15 - egress: - - identifier: outbound-class-a - type: internal - ipAddresses: - - "10.0.0.0/8" - ports: - - {name: TCP-443, protocol: TCP, port: 443} - ingress: - - identifier: inbound-rfc1918-class-c - type: internal - ipAddresses: - - "192.168.0.0/16" - ports: - - {name: TCP-8080, protocol: TCP, port: 8080} diff --git a/tests/resources/network-wildcards-cp/16-egress-none.yaml b/tests/resources/network-wildcards-cp/16-egress-none.yaml deleted file mode 100644 index 5687bcb37..000000000 --- a/tests/resources/network-wildcards-cp/16-egress-none.yaml +++ /dev/null @@ -1,33 +0,0 @@ -# Fixture 16 — NONE egress (declared zero-egress traffic) -# -# Edge case: egress: [] explicit empty list — declares "this workload -# makes ZERO outbound network connections" -# Expects: verifier emits net.egress_unexpected on the FIRST observed -# outgoing connection (any IP, any DNS, any port) -# Spec ref: §5.4 NONE semantic — "explicit empty list = declared -# zero-activity, hard violation on first observation" -# Distinction from absent: -# `egress:` MISSING from the doc = NULL (verifier-defined posture) -# `egress: []` = NONE (zero-traffic contract) -# This fixture pins the latter. -# -# Producer use case: -# A worker pod that should ONLY accept inbound work and never reach out. -# A locked-down database whose only legitimate traffic is the ingress -# replication stream. -# -apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: ContainerProfile -metadata: - name: nw-16-egress-none -spec: - matchLabels: - app: nw-16 - egress: [] # NONE — any outbound traffic is a violation - ingress: - - identifier: control-plane-only - type: internal - ipAddresses: - - "10.0.0.1" - ports: - - {name: TCP-9000, protocol: TCP, port: 9000} diff --git a/tests/resources/network-wildcards-cp/17-realistic-stripe-api.yaml b/tests/resources/network-wildcards-cp/17-realistic-stripe-api.yaml deleted file mode 100644 index fc839ca07..000000000 --- a/tests/resources/network-wildcards-cp/17-realistic-stripe-api.yaml +++ /dev/null @@ -1,53 +0,0 @@ -# Fixture 17 — realistic Stripe API integration -# -# Edge case: end-to-end realistic profile for a workload that calls -# Stripe (well-known external SaaS) plus cluster DNS -# Demonstrates: -# - egress[] with multiple entries (external + internal) -# - ipAddresses[] with both literal and CIDR -# - dnsNames[] with literal AND leading wildcard (RFC 4592) -# - selectors-based internal entry (auto-translated to NetworkPolicy; -# not consulted by R0005/R0011 runtime — see §4.7 caveat) -# - port specifications -# Expects: -# POST https://api.stripe.com (resolved to one of Stripe's IPs) → match -# POST https://files.stripe.com (matches *.stripe.com.) → match -# POST https://api.example.com → NO match -# UDP to kube-dns:53 → match (NetworkPolicy) -# but R0011/R0005 -# don't consult selectors -# — see §4.7 note -# -apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: ContainerProfile -metadata: - name: nw-17-realistic-stripe -spec: - matchLabels: - app: payment-service - egress: - - identifier: stripe-api - type: external - ipAddresses: - - "162.0.217.171" # Stripe public IP example - - "163.0.0.0/16" # Stripe routing range — for completeness - dnsNames: - - "api.stripe.com." - - "*.stripe.com." # leading-* RFC 4592 — covers files.stripe.com., - # webhooks.stripe.com., billing.stripe.com. - # but NOT v1.api.stripe.com. (two labels deep) - ports: - - {name: TCP-443, protocol: TCP, port: 443} - - identifier: cluster-dns - type: internal - # Selector-based entry — auto-translates to a NetworkPolicy egress rule - # that K8s enforces. Note: R0005/R0011 runtime matchers do NOT consult - # selectors as of v0.0.2 — they only walk ipAddresses/dnsNames. - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: kube-system - podSelector: - matchLabels: - k8s-app: kube-dns - ports: - - {name: UDP-53, protocol: UDP, port: 53} diff --git a/tests/resources/network-wildcards-cp/18-cluster-dns-via-mid-ellipsis.yaml b/tests/resources/network-wildcards-cp/18-cluster-dns-via-mid-ellipsis.yaml deleted file mode 100644 index 15cdfcec7..000000000 --- a/tests/resources/network-wildcards-cp/18-cluster-dns-via-mid-ellipsis.yaml +++ /dev/null @@ -1,50 +0,0 @@ -# Fixture 18 — Kubernetes service-FQDN resolution via mid-`⋯` -# -# Edge case: The user's specific case from the v0.0.2 design discussion. -# In Kubernetes, services are resolved as -# ..svc.cluster.local. -# A workload that wants to permit "any namespace's -# service" should match exactly one label between fixed -# anchors. -# Expects: -# observed "redis.production.svc.cluster.local." → NO match (we anchored on `redis`, -# and only the namespace label is -# wildcarded) -# observed "redis.staging.svc.cluster.local." → NO match (same — we'd need -# a different fixture for "any svc -# in any ns") -# observed "kubernetes.default.svc.cluster.local." → match (one label "default") -# Match path: label-split + recursive matcher; `⋯` consumes exactly one -# label between two fixed segments -# Spec ref: §5.8 ".⋯." row, the example uses this exact pattern -# -# Why `⋯` and not `*`: -# RFC 4592 only standardises *.. Mid-label `*` is non-standard -# (cilium uses regex; bind/coredns reject it). v0.0.2 uses `⋯` (DynamicIdentifier, -# the project's existing token from path/argv wildcards) for mid positions. -# -# Hardcoded short-circuit removal candidate: -# The default rule R0005 currently has a hardcoded -# `!event.name.endsWith('.svc.cluster.local.')` short-circuit. With this -# fixture's mid-⋯ form, that hardcode becomes profile-expressible — a -# future PR can REMOVE the rule-side short-circuit and let producers -# declare the equivalent via this NN. -# -apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: ContainerProfile -metadata: - name: nw-18-cluster-dns-mid-ellipsis -spec: - matchLabels: - app: nw-18 - egress: - - identifier: any-namespace-kubernetes-svc - type: internal - dnsNames: - - "kubernetes.⋯.svc.cluster.local." - # ↑ matches kubernetes.default.svc.cluster.local. exactly, - # parametric on the namespace label. Use one entry per - # service the workload calls; the wildcard is on the - # namespace position, not the service name. - ports: - - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards-cp/19-port-protocol-with-cidr.yaml b/tests/resources/network-wildcards-cp/19-port-protocol-with-cidr.yaml deleted file mode 100644 index 869838314..000000000 --- a/tests/resources/network-wildcards-cp/19-port-protocol-with-cidr.yaml +++ /dev/null @@ -1,36 +0,0 @@ -# Fixture 19 — port + protocol + CIDR composed match -# -# Edge case: cp.was_address_port_protocol_in_egress matcher — the granular -# variant that requires IP+port+protocol all to match within -# the same NetworkNeighbor entry -# Expects: -# observed (10.1.2.3, 443, TCP) → match (both CIDR and port match within entry) -# observed (10.1.2.3, 80, TCP) → NO match (CIDR ok but port mismatch) -# observed (192.168.1.1, 443, TCP)→ NO match (port ok but CIDR mismatch) -# observed (10.1.2.3, 443, UDP) → NO match (CIDR + port ok but protocol mismatch) -# Match path: for each NetworkNeighbor: -# if MatchIP(entry.IPs, observed) && entry contains matching -# (port, protocol) tuple → true -# Spec ref: §4.7 ports[] row — name + protocol + port (uint16 nullable) -# -# This fixture validates that the new IP-matcher integration preserves the -# port-protocol grouping contract — a CIDR match alone isn't sufficient -# unless the entry's ports list also contains the (port, protocol) pair. -# -apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: ContainerProfile -metadata: - name: nw-19-port-proto-cidr -spec: - matchLabels: - app: nw-19 - egress: - - identifier: tls-only-class-a - type: internal - ipAddresses: - - "10.0.0.0/8" - ports: - - {name: TCP-443, protocol: TCP, port: 443} - # Note: no UDP entry, no port-80 entry — only TCP/443 within this CIDR. - # A request to (10.1.2.3, 80, TCP) should NOT match because the - # port-protocol filter is per-NetworkNeighbor-entry, not global. diff --git a/tests/resources/network-wildcards-cp/20-multi-container-mixed-wildcards.yaml b/tests/resources/network-wildcards-cp/20-multi-container-mixed-wildcards.yaml deleted file mode 100644 index 371700b23..000000000 --- a/tests/resources/network-wildcards-cp/20-multi-container-mixed-wildcards.yaml +++ /dev/null @@ -1,58 +0,0 @@ -# Fixture 20 — multi-container pod with different rules per container -# -# Edge case: a single NetworkNeighborhood applies to a multi-container pod; -# each container has its own egress/ingress block; the verifier -# MUST scope matching by container ID (not pod ID) -# Expects: -# container "frontend" can hit *.example.com. but NOT 10.0.0.0/8; -# container "sidecar" can hit 10.0.0.0/8 but NOT *.example.com.; -# if the verifier conflates containers, both restrictions collapse to "either" -# and the test fails -# Match path: nn.* CEL functions resolve the ContainerProfile by containerID, -# so the matchers operate on the ALREADY-scoped Spec.Egress slice -# Spec ref: §4.2 container entry — each container is independently profiled -# -# This is also the most realistic deployment shape: a frontend that calls -# external APIs plus an in-cluster sidecar that talks to DBs/caches. -# -# container: frontend -apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: ContainerProfile -metadata: - name: nw-20-multi-container-frontend -spec: - matchLabels: - app: nw-20 - egress: - - identifier: external-api - type: external - dnsNames: - - "*.example.com." # leading-* RFC 4592 - - "api.partner.io." # literal - ports: - - {name: TCP-443, protocol: TCP, port: 443} ---- -# container: sidecar -apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: ContainerProfile -metadata: - name: nw-20-multi-container-sidecar -spec: - matchLabels: - app: nw-20 - egress: - - identifier: in-cluster-services - type: internal - ipAddresses: - - "10.0.0.0/8" # cluster pod CIDR - - "172.16.0.0/12" # alt cluster service CIDR - ports: - - {name: TCP-6379, protocol: TCP, port: 6379} # redis - - {name: TCP-5432, protocol: TCP, port: 5432} # postgres - ingress: - - identifier: from-frontend - type: internal - ipAddresses: - - "10.244.0.0/16" # narrower — only the frontend pod's CIDR - ports: - - {name: TCP-9090, protocol: TCP, port: 9090} # sidecar metrics diff --git a/tests/resources/network-wildcards-cp/README.md b/tests/resources/network-wildcards-cp/README.md deleted file mode 100644 index 6660c1040..000000000 --- a/tests/resources/network-wildcards-cp/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# Network endpoint fixtures — ContainerProfile (user-defined-profile) form - -These are the **ContainerProfile** authoring examples for the network egress/ -ingress surface — the "new way" (migration #862) of authoring a user-defined -behavioural allow-list. Each file is a copy-pasteable, self-documenting example -of one edge case in the v0.0.2 network-endpoint grammar. - -## Relationship to `../network-wildcards/` - -`../network-wildcards/*.yaml` hold the same edge cases as **NetworkNeighborhood** -documents (per-workload: `spec.containers[]`). Those are consumed as-is by the -CEL matcher unit tests (`pkg/rulemanager/cel/libraries/networkneighborhood/ -fixtures_test.go`) and must stay in NN form. - -The files **here** are the migrated, user-authorable equivalents: - -| NetworkNeighborhood (learned / legacy) | ContainerProfile (user-authored, new) | -|---|---| -| per-**workload** | per-**container** | -| `spec.matchLabels` + `spec.containers[].{egress,ingress}` | `spec.matchLabels` + `spec.{egress,ingress}` directly | -| bound by workload selector | bound by pod label `kubescape.io/user-defined-profile: ` | -| carries `managed-by/status/completion` annotations | **no annotations** — name (+ namespace, injected) only; a signature is added by the signing tool | - -A multi-container NN (fixture 20) becomes **one CP document per container**, -`---`-separated in the same file. - -## Contents - -- `00-fusioncore-homoglyph-attack.yaml` — flagship security example: a pinned - single-vendor allow-list and the look-alike (homoglyph) domains it rejects. -- `01`–`20` — the network-endpoint edge cases (literal IPv4/v6, CIDR, the `*` - any-IP sentinel, mixed lists, deprecated singular `ipAddress`, DNS literals, - leading-`*` / trailing-`*` / mid-`⋯` wildcards, trailing-dot normalisation, - the rejected recursive `**`, egress+ingress direction isolation, ports/ - protocols, cluster-DNS via mid-`⋯`, and a multi-container split). - -## Wildcard token vocabulary - -| Token | Meaning | -|---|---| -| `⋯` (U+22EF, single codepoint — NOT three ASCII periods) | exactly one DNS label between fixed anchors | -| `*` leading | RFC 4592 wildcard — exactly one label before the suffix | -| `*` trailing | one or more labels after the prefix (never zero) | -| `*` as an `ipAddresses[i]` entry | sugar for `0.0.0.0/0` ∪ `::/0` (any IP) | - -## Authoring rules these examples follow - -- User-managed ContainerProfiles carry **only** `metadata.name` (namespace is - injected by tooling). No `managed-by`, no `status/completion` — those are - meaningless on an authored profile; the read path forces the enforcement - state for a label-referenced CP. -- `14-recursive-star-rejected.yaml` is **intentionally invalid** (`dnsNames: - ["**"]`) — do not `kubectl apply` it; it documents that recursive `**` is - not v0.0.2 syntax. diff --git a/tests/resources/network-wildcards-cp/00-fusioncore-homoglyph-attack.yaml b/tests/resources/network-wildcards/00-fusioncore-homoglyph-attack.yaml similarity index 100% rename from tests/resources/network-wildcards-cp/00-fusioncore-homoglyph-attack.yaml rename to tests/resources/network-wildcards/00-fusioncore-homoglyph-attack.yaml diff --git a/tests/resources/network-wildcards/01-literal-ipv4.yaml b/tests/resources/network-wildcards/01-literal-ipv4.yaml index a9861986c..78d441df8 100644 --- a/tests/resources/network-wildcards/01-literal-ipv4.yaml +++ b/tests/resources/network-wildcards/01-literal-ipv4.yaml @@ -6,21 +6,16 @@ # Spec ref: §5.7 "IPv4 / IPv6 literal" row # apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: NetworkNeighborhood +kind: ContainerProfile metadata: name: nw-01-literal-ipv4 - namespace: "{{NAMESPACE}}" - annotations: - sbob.io/spec-version: "0.0.2" spec: matchLabels: app: nw-01 - containers: - - name: client - egress: - - identifier: literal-ipv4 - type: external - ipAddresses: - - "162.0.217.171" - ports: - - {name: TCP-443, protocol: TCP, port: 443} + egress: + - identifier: literal-ipv4 + type: external + ipAddresses: + - "162.0.217.171" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/02-literal-ipv6.yaml b/tests/resources/network-wildcards/02-literal-ipv6.yaml index b0856b33a..3c5198454 100644 --- a/tests/resources/network-wildcards/02-literal-ipv6.yaml +++ b/tests/resources/network-wildcards/02-literal-ipv6.yaml @@ -7,21 +7,16 @@ # Spec ref: §5.7 — "textual canonicalisation is the verifier's responsibility" # apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: NetworkNeighborhood +kind: ContainerProfile metadata: name: nw-02-literal-ipv6 - namespace: "{{NAMESPACE}}" - annotations: - sbob.io/spec-version: "0.0.2" spec: matchLabels: app: nw-02 - containers: - - name: client - egress: - - identifier: literal-ipv6 - type: external - ipAddresses: - - "2001:db8::1" - ports: - - {name: TCP-443, protocol: TCP, port: 443} + egress: + - identifier: literal-ipv6 + type: external + ipAddresses: + - "2001:db8::1" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/03-cidr-ipv4.yaml b/tests/resources/network-wildcards/03-cidr-ipv4.yaml index cd803cbc0..fe3f1c8cb 100644 --- a/tests/resources/network-wildcards/03-cidr-ipv4.yaml +++ b/tests/resources/network-wildcards/03-cidr-ipv4.yaml @@ -8,21 +8,16 @@ # Spec ref: §5.7 "CIDR" row # apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: NetworkNeighborhood +kind: ContainerProfile metadata: name: nw-03-cidr-ipv4 - namespace: "{{NAMESPACE}}" - annotations: - sbob.io/spec-version: "0.0.2" spec: matchLabels: app: nw-03 - containers: - - name: client - egress: - - identifier: rfc1918-class-a - type: internal - ipAddresses: - - "10.0.0.0/8" - ports: - - {name: TCP-443, protocol: TCP, port: 443} + egress: + - identifier: rfc1918-class-a + type: internal + ipAddresses: + - "10.0.0.0/8" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/04-cidr-ipv6.yaml b/tests/resources/network-wildcards/04-cidr-ipv6.yaml index a885323c7..20966f210 100644 --- a/tests/resources/network-wildcards/04-cidr-ipv6.yaml +++ b/tests/resources/network-wildcards/04-cidr-ipv6.yaml @@ -6,21 +6,16 @@ # Spec ref: §5.7 "CIDR" row, second example # apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: NetworkNeighborhood +kind: ContainerProfile metadata: name: nw-04-cidr-ipv6 - namespace: "{{NAMESPACE}}" - annotations: - sbob.io/spec-version: "0.0.2" spec: matchLabels: app: nw-04 - containers: - - name: client - egress: - - identifier: rfc3849-doc-prefix - type: external - ipAddresses: - - "2001:db8::/32" - ports: - - {name: TCP-443, protocol: TCP, port: 443} + egress: + - identifier: rfc3849-doc-prefix + type: external + ipAddresses: + - "2001:db8::/32" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/05-any-ip-sentinel.yaml b/tests/resources/network-wildcards/05-any-ip-sentinel.yaml index 035fd046a..e0e5155dc 100644 --- a/tests/resources/network-wildcards/05-any-ip-sentinel.yaml +++ b/tests/resources/network-wildcards/05-any-ip-sentinel.yaml @@ -9,23 +9,16 @@ # Producers should normally enumerate concrete IPs/CIDRs. # apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: NetworkNeighborhood +kind: ContainerProfile metadata: name: nw-05-any-sentinel - namespace: "{{NAMESPACE}}" - annotations: - sbob.io/spec-version: "0.0.2" - # Make the operational risk explicit: - sbob.io/discouraged-wildcards: "ipAddresses-any-sentinel" spec: matchLabels: app: nw-05 - containers: - - name: client - egress: - - identifier: any-ip-development-profile - type: external - ipAddresses: - - "*" - ports: - - {name: TCP-443, protocol: TCP, port: 443} + egress: + - identifier: any-ip-development-profile + type: external + ipAddresses: + - "*" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/06-any-as-cidr.yaml b/tests/resources/network-wildcards/06-any-as-cidr.yaml index b897eb4ec..9ce3e7ba5 100644 --- a/tests/resources/network-wildcards/06-any-as-cidr.yaml +++ b/tests/resources/network-wildcards/06-any-as-cidr.yaml @@ -13,22 +13,17 @@ # the `*` sentinel. # apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: NetworkNeighborhood +kind: ContainerProfile metadata: name: nw-06-any-as-cidr - namespace: "{{NAMESPACE}}" - annotations: - sbob.io/spec-version: "0.0.2" spec: matchLabels: app: nw-06 - containers: - - name: client - egress: - - identifier: any-via-cidrs - type: external - ipAddresses: - - "0.0.0.0/0" - - "::/0" - ports: - - {name: TCP-443, protocol: TCP, port: 443} + egress: + - identifier: any-via-cidrs + type: external + ipAddresses: + - "0.0.0.0/0" + - "::/0" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/07-mixed-ip-list.yaml b/tests/resources/network-wildcards/07-mixed-ip-list.yaml index dc5d526fb..4aad619ba 100644 --- a/tests/resources/network-wildcards/07-mixed-ip-list.yaml +++ b/tests/resources/network-wildcards/07-mixed-ip-list.yaml @@ -13,24 +13,19 @@ # behaviour # apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: NetworkNeighborhood +kind: ContainerProfile metadata: name: nw-07-mixed-ip-list - namespace: "{{NAMESPACE}}" - annotations: - sbob.io/spec-version: "0.0.2" spec: matchLabels: app: nw-07 - containers: - - name: client - egress: - - identifier: mixed-shapes - type: external - ipAddresses: - - "162.0.217.171" # IPv4 literal - - "10.0.0.0/8" # IPv4 CIDR - - "2001:db8::/32" # IPv6 CIDR - - "*" # any (sentinel — overrides everything; here for test) - ports: - - {name: TCP-443, protocol: TCP, port: 443} + egress: + - identifier: mixed-shapes + type: external + ipAddresses: + - "162.0.217.171" # IPv4 literal + - "10.0.0.0/8" # IPv4 CIDR + - "2001:db8::/32" # IPv6 CIDR + - "*" # any (sentinel — overrides everything; here for test) + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/08-deprecated-ipaddress.yaml b/tests/resources/network-wildcards/08-deprecated-ipaddress.yaml index 5d56b271a..1f0f71501 100644 --- a/tests/resources/network-wildcards/08-deprecated-ipaddress.yaml +++ b/tests/resources/network-wildcards/08-deprecated-ipaddress.yaml @@ -10,23 +10,15 @@ # (plural) on the same entry — admission strategy rejects # apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: NetworkNeighborhood +kind: ContainerProfile metadata: name: nw-08-deprecated-ipaddress - namespace: "{{NAMESPACE}}" - annotations: - sbob.io/spec-version: "0.0.2" - # New profiles should use ipAddresses; this fixture exists only to pin - # back-compat behaviour for v0.0.1-era documents that haven't migrated yet. - sbob.io/migration-target: "ipAddresses" spec: matchLabels: app: nw-08 - containers: - - name: legacy-client - egress: - - identifier: legacy-singular-ip - type: external - ipAddress: "10.0.0.42" # DEPRECATED — kept here on purpose to exercise back-compat - ports: - - {name: TCP-443, protocol: TCP, port: 443} + egress: + - identifier: legacy-singular-ip + type: external + ipAddress: "10.0.0.42" # DEPRECATED — kept here on purpose to exercise back-compat + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/09-dns-literal.yaml b/tests/resources/network-wildcards/09-dns-literal.yaml index 93b199d49..190ce1c98 100644 --- a/tests/resources/network-wildcards/09-dns-literal.yaml +++ b/tests/resources/network-wildcards/09-dns-literal.yaml @@ -9,21 +9,16 @@ # RFC ref: RFC 1035 § 3.1 (FQDN syntax) # apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: NetworkNeighborhood +kind: ContainerProfile metadata: name: nw-09-dns-literal - namespace: "{{NAMESPACE}}" - annotations: - sbob.io/spec-version: "0.0.2" spec: matchLabels: app: nw-09 - containers: - - name: client - egress: - - identifier: stripe-api-literal - type: external - dnsNames: - - "api.stripe.com." - ports: - - {name: TCP-443, protocol: TCP, port: 443} + egress: + - identifier: stripe-api-literal + type: external + dnsNames: + - "api.stripe.com." + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/10-dns-leading-wildcard.yaml b/tests/resources/network-wildcards/10-dns-leading-wildcard.yaml index 46802c441..9a020ce89 100644 --- a/tests/resources/network-wildcards/10-dns-leading-wildcard.yaml +++ b/tests/resources/network-wildcards/10-dns-leading-wildcard.yaml @@ -15,21 +15,16 @@ # all honour this convention # apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: NetworkNeighborhood +kind: ContainerProfile metadata: name: nw-10-dns-leading-wildcard - namespace: "{{NAMESPACE}}" - annotations: - sbob.io/spec-version: "0.0.2" spec: matchLabels: app: nw-10 - containers: - - name: client - egress: - - identifier: example-com-subdomains - type: external - dnsNames: - - "*.example.com." - ports: - - {name: TCP-443, protocol: TCP, port: 443} + egress: + - identifier: example-com-subdomains + type: external + dnsNames: + - "*.example.com." + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/11-dns-mid-ellipsis.yaml b/tests/resources/network-wildcards/11-dns-mid-ellipsis.yaml index 3325329c6..ecec5cfc5 100644 --- a/tests/resources/network-wildcards/11-dns-mid-ellipsis.yaml +++ b/tests/resources/network-wildcards/11-dns-mid-ellipsis.yaml @@ -21,21 +21,16 @@ # It is NOT three ASCII periods (`...`). # apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: NetworkNeighborhood +kind: ContainerProfile metadata: name: nw-11-dns-mid-ellipsis - namespace: "{{NAMESPACE}}" - annotations: - sbob.io/spec-version: "0.0.2" spec: matchLabels: app: nw-11 - containers: - - name: dns-client - egress: - - identifier: cluster-svc-resolution - type: internal - dnsNames: - - "svc.⋯.cluster.local." - ports: - - {name: UDP-53, protocol: UDP, port: 53} + egress: + - identifier: cluster-svc-resolution + type: internal + dnsNames: + - "svc.⋯.cluster.local." + ports: + - {name: UDP-53, protocol: UDP, port: 53} diff --git a/tests/resources/network-wildcards/12-dns-trailing-star.yaml b/tests/resources/network-wildcards/12-dns-trailing-star.yaml index b78723f10..a4c8d613e 100644 --- a/tests/resources/network-wildcards/12-dns-trailing-star.yaml +++ b/tests/resources/network-wildcards/12-dns-trailing-star.yaml @@ -26,21 +26,16 @@ # variable hierarchy is on the LEFT of a fixed registry suffix. # apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: NetworkNeighborhood +kind: ContainerProfile metadata: name: nw-12-dns-trailing-star - namespace: "{{NAMESPACE}}" - annotations: - sbob.io/spec-version: "0.0.2" spec: matchLabels: app: nw-12 - containers: - - name: client - egress: - - identifier: mycorp-anything-deeper - type: external - dnsNames: - - "mycorp.com.*" - ports: - - {name: TCP-443, protocol: TCP, port: 443} + egress: + - identifier: mycorp-anything-deeper + type: external + dnsNames: + - "mycorp.com.*" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/13-dns-trailing-dot-normalisation.yaml b/tests/resources/network-wildcards/13-dns-trailing-dot-normalisation.yaml index fc7af7bdc..2bd56d58f 100644 --- a/tests/resources/network-wildcards/13-dns-trailing-dot-normalisation.yaml +++ b/tests/resources/network-wildcards/13-dns-trailing-dot-normalisation.yaml @@ -18,22 +18,17 @@ # normalisation runs on profile-side entries, not just observed names. # apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: NetworkNeighborhood +kind: ContainerProfile metadata: name: nw-13-dns-trailing-dot - namespace: "{{NAMESPACE}}" - annotations: - sbob.io/spec-version: "0.0.2" spec: matchLabels: app: nw-13 - containers: - - name: client - egress: - - identifier: mixed-trailing-dot-forms - type: external - dnsNames: - - "api.stripe.com." # canonical FQDN form - - "api.github.com" # without trailing dot — equivalent - ports: - - {name: TCP-443, protocol: TCP, port: 443} + egress: + - identifier: mixed-trailing-dot-forms + type: external + dnsNames: + - "api.stripe.com." # canonical FQDN form + - "api.stripe.com" # same host, without trailing dot — must compare equal + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/14-recursive-star-rejected.yaml b/tests/resources/network-wildcards/14-recursive-star-rejected.yaml index 703334fd8..64d2a0e24 100644 --- a/tests/resources/network-wildcards/14-recursive-star-rejected.yaml +++ b/tests/resources/network-wildcards/14-recursive-star-rejected.yaml @@ -17,22 +17,16 @@ # 3. Assert no NetworkNeighborhood named `nw-14-recursive-rejected` exists # apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: NetworkNeighborhood +kind: ContainerProfile metadata: name: nw-14-recursive-rejected - namespace: "{{NAMESPACE}}" - annotations: - sbob.io/spec-version: "0.0.2" - sbob.io/expected-admission: "rejected" spec: matchLabels: app: nw-14 - containers: - - name: client - egress: - - identifier: invalid-recursive - type: external - dnsNames: - - "**.example.com." # INVALID — admission MUST reject - ports: - - {name: TCP-443, protocol: TCP, port: 443} + egress: + - identifier: invalid-recursive + type: external + dnsNames: + - "**.example.com." # INVALID — admission MUST reject + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/15-egress-and-ingress.yaml b/tests/resources/network-wildcards/15-egress-and-ingress.yaml index a21f1a588..0558681cb 100644 --- a/tests/resources/network-wildcards/15-egress-and-ingress.yaml +++ b/tests/resources/network-wildcards/15-egress-and-ingress.yaml @@ -19,28 +19,23 @@ # but no built-in rule consumes them as of v0.0.2. Custom rules MAY. # apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: NetworkNeighborhood +kind: ContainerProfile metadata: name: nw-15-egress-and-ingress - namespace: "{{NAMESPACE}}" - annotations: - sbob.io/spec-version: "0.0.2" spec: matchLabels: app: nw-15 - containers: - - name: bidirectional - egress: - - identifier: outbound-class-a - type: internal - ipAddresses: - - "10.0.0.0/8" - ports: - - {name: TCP-443, protocol: TCP, port: 443} - ingress: - - identifier: inbound-rfc1918-class-c - type: internal - ipAddresses: - - "192.168.0.0/16" - ports: - - {name: TCP-8080, protocol: TCP, port: 8080} + egress: + - identifier: outbound-class-a + type: internal + ipAddresses: + - "10.0.0.0/8" + ports: + - {name: TCP-443, protocol: TCP, port: 443} + ingress: + - identifier: inbound-rfc1918-class-c + type: internal + ipAddresses: + - "192.168.0.0/16" + ports: + - {name: TCP-8080, protocol: TCP, port: 8080} diff --git a/tests/resources/network-wildcards/16-egress-none.yaml b/tests/resources/network-wildcards/16-egress-none.yaml index 113f87375..5687bcb37 100644 --- a/tests/resources/network-wildcards/16-egress-none.yaml +++ b/tests/resources/network-wildcards/16-egress-none.yaml @@ -17,22 +17,17 @@ # replication stream. # apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: NetworkNeighborhood +kind: ContainerProfile metadata: name: nw-16-egress-none - namespace: "{{NAMESPACE}}" - annotations: - sbob.io/spec-version: "0.0.2" spec: matchLabels: app: nw-16 - containers: - - name: locked-down-worker - egress: [] # NONE — any outbound traffic is a violation - ingress: - - identifier: control-plane-only - type: internal - ipAddresses: - - "10.0.0.1" - ports: - - {name: TCP-9000, protocol: TCP, port: 9000} + egress: [] # NONE — any outbound traffic is a violation + ingress: + - identifier: control-plane-only + type: internal + ipAddresses: + - "10.0.0.1" + ports: + - {name: TCP-9000, protocol: TCP, port: 9000} diff --git a/tests/resources/network-wildcards/17-realistic-stripe-api.yaml b/tests/resources/network-wildcards/17-realistic-stripe-api.yaml index 05ae61e2b..fc839ca07 100644 --- a/tests/resources/network-wildcards/17-realistic-stripe-api.yaml +++ b/tests/resources/network-wildcards/17-realistic-stripe-api.yaml @@ -19,40 +19,35 @@ # — see §4.7 note # apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: NetworkNeighborhood +kind: ContainerProfile metadata: name: nw-17-realistic-stripe - namespace: "{{NAMESPACE}}" - annotations: - sbob.io/spec-version: "0.0.2" spec: matchLabels: app: payment-service - containers: - - name: payment-app - egress: - - identifier: stripe-api - type: external - ipAddresses: - - "162.0.217.171" # Stripe public IP example - - "163.0.0.0/16" # Stripe routing range — for completeness - dnsNames: - - "api.stripe.com." - - "*.stripe.com." # leading-* RFC 4592 — covers files.stripe.com., - # webhooks.stripe.com., billing.stripe.com. - # but NOT v1.api.stripe.com. (two labels deep) - ports: - - {name: TCP-443, protocol: TCP, port: 443} - - identifier: cluster-dns - type: internal - # Selector-based entry — auto-translates to a NetworkPolicy egress rule - # that K8s enforces. Note: R0005/R0011 runtime matchers do NOT consult - # selectors as of v0.0.2 — they only walk ipAddresses/dnsNames. - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: kube-system - podSelector: - matchLabels: - k8s-app: kube-dns - ports: - - {name: UDP-53, protocol: UDP, port: 53} + egress: + - identifier: stripe-api + type: external + ipAddresses: + - "162.0.217.171" # Stripe public IP example + - "163.0.0.0/16" # Stripe routing range — for completeness + dnsNames: + - "api.stripe.com." + - "*.stripe.com." # leading-* RFC 4592 — covers files.stripe.com., + # webhooks.stripe.com., billing.stripe.com. + # but NOT v1.api.stripe.com. (two labels deep) + ports: + - {name: TCP-443, protocol: TCP, port: 443} + - identifier: cluster-dns + type: internal + # Selector-based entry — auto-translates to a NetworkPolicy egress rule + # that K8s enforces. Note: R0005/R0011 runtime matchers do NOT consult + # selectors as of v0.0.2 — they only walk ipAddresses/dnsNames. + namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - {name: UDP-53, protocol: UDP, port: 53} diff --git a/tests/resources/network-wildcards/18-cluster-dns-via-mid-ellipsis.yaml b/tests/resources/network-wildcards/18-cluster-dns-via-mid-ellipsis.yaml index c63df62a6..15cdfcec7 100644 --- a/tests/resources/network-wildcards/18-cluster-dns-via-mid-ellipsis.yaml +++ b/tests/resources/network-wildcards/18-cluster-dns-via-mid-ellipsis.yaml @@ -31,25 +31,20 @@ # declare the equivalent via this NN. # apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: NetworkNeighborhood +kind: ContainerProfile metadata: name: nw-18-cluster-dns-mid-ellipsis - namespace: "{{NAMESPACE}}" - annotations: - sbob.io/spec-version: "0.0.2" spec: matchLabels: app: nw-18 - containers: - - name: dns-client - egress: - - identifier: any-namespace-kubernetes-svc - type: internal - dnsNames: - - "kubernetes.⋯.svc.cluster.local." - # ↑ matches kubernetes.default.svc.cluster.local. exactly, - # parametric on the namespace label. Use one entry per - # service the workload calls; the wildcard is on the - # namespace position, not the service name. - ports: - - {name: TCP-443, protocol: TCP, port: 443} + egress: + - identifier: any-namespace-kubernetes-svc + type: internal + dnsNames: + - "kubernetes.⋯.svc.cluster.local." + # ↑ matches kubernetes.default.svc.cluster.local. exactly, + # parametric on the namespace label. Use one entry per + # service the workload calls; the wildcard is on the + # namespace position, not the service name. + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/19-port-protocol-with-cidr.yaml b/tests/resources/network-wildcards/19-port-protocol-with-cidr.yaml index c63de5ee7..869838314 100644 --- a/tests/resources/network-wildcards/19-port-protocol-with-cidr.yaml +++ b/tests/resources/network-wildcards/19-port-protocol-with-cidr.yaml @@ -18,24 +18,19 @@ # unless the entry's ports list also contains the (port, protocol) pair. # apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: NetworkNeighborhood +kind: ContainerProfile metadata: name: nw-19-port-proto-cidr - namespace: "{{NAMESPACE}}" - annotations: - sbob.io/spec-version: "0.0.2" spec: matchLabels: app: nw-19 - containers: - - name: client - egress: - - identifier: tls-only-class-a - type: internal - ipAddresses: - - "10.0.0.0/8" - ports: - - {name: TCP-443, protocol: TCP, port: 443} - # Note: no UDP entry, no port-80 entry — only TCP/443 within this CIDR. - # A request to (10.1.2.3, 80, TCP) should NOT match because the - # port-protocol filter is per-NetworkNeighbor-entry, not global. + egress: + - identifier: tls-only-class-a + type: internal + ipAddresses: + - "10.0.0.0/8" + ports: + - {name: TCP-443, protocol: TCP, port: 443} + # Note: no UDP entry, no port-80 entry — only TCP/443 within this CIDR. + # A request to (10.1.2.3, 80, TCP) should NOT match because the + # port-protocol filter is per-NetworkNeighbor-entry, not global. diff --git a/tests/resources/network-wildcards/20-multi-container-mixed-wildcards.yaml b/tests/resources/network-wildcards/20-multi-container-mixed-wildcards.yaml index bdc1e417d..371700b23 100644 --- a/tests/resources/network-wildcards/20-multi-container-mixed-wildcards.yaml +++ b/tests/resources/network-wildcards/20-multi-container-mixed-wildcards.yaml @@ -15,40 +15,44 @@ # This is also the most realistic deployment shape: a frontend that calls # external APIs plus an in-cluster sidecar that talks to DBs/caches. # +# container: frontend apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: NetworkNeighborhood +kind: ContainerProfile metadata: - name: nw-20-multi-container - namespace: "{{NAMESPACE}}" - annotations: - sbob.io/spec-version: "0.0.2" + name: nw-20-multi-container-frontend spec: matchLabels: app: nw-20 - containers: - - name: frontend - egress: - - identifier: external-api - type: external - dnsNames: - - "*.example.com." # leading-* RFC 4592 - - "api.partner.io." # literal - ports: - - {name: TCP-443, protocol: TCP, port: 443} - - name: sidecar - egress: - - identifier: in-cluster-services - type: internal - ipAddresses: - - "10.0.0.0/8" # cluster pod CIDR - - "172.16.0.0/12" # alt cluster service CIDR - ports: - - {name: TCP-6379, protocol: TCP, port: 6379} # redis - - {name: TCP-5432, protocol: TCP, port: 5432} # postgres - ingress: - - identifier: from-frontend - type: internal - ipAddresses: - - "10.244.0.0/16" # narrower — only the frontend pod's CIDR - ports: - - {name: TCP-9090, protocol: TCP, port: 9090} # sidecar metrics + egress: + - identifier: external-api + type: external + dnsNames: + - "*.example.com." # leading-* RFC 4592 + - "api.partner.io." # literal + ports: + - {name: TCP-443, protocol: TCP, port: 443} +--- +# container: sidecar +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: nw-20-multi-container-sidecar +spec: + matchLabels: + app: nw-20 + egress: + - identifier: in-cluster-services + type: internal + ipAddresses: + - "10.0.0.0/8" # cluster pod CIDR + - "172.16.0.0/12" # alt cluster service CIDR + ports: + - {name: TCP-6379, protocol: TCP, port: 6379} # redis + - {name: TCP-5432, protocol: TCP, port: 5432} # postgres + ingress: + - identifier: from-frontend + type: internal + ipAddresses: + - "10.244.0.0/16" # narrower — only the frontend pod's CIDR + ports: + - {name: TCP-9090, protocol: TCP, port: 9090} # sidecar metrics diff --git a/tests/resources/nnlint_test.go b/tests/resources/nnlint_test.go index 4ff13283b..d732524ee 100644 --- a/tests/resources/nnlint_test.go +++ b/tests/resources/nnlint_test.go @@ -255,7 +255,7 @@ func validIPEntry(s string) bool { // netFixtureGlobs are the user-authored network surfaces to lint, relative to // this package directory (tests/resources). var netFixtureGlobs = []string{ - "network-wildcards-cp/*.yaml", + "network-wildcards/*.yaml", "containerprofile-*-network.yaml", } From 243bb34ef413b1095bceb4b2f214099504522578 Mon Sep 17 00:00:00 2001 From: entlein Date: Wed, 29 Jul 2026 20:42:45 +0200 Subject: [PATCH 23/29] IMPORANT: add Test_08_ApplicationProfilePatching to component-tests.yaml Signed-off-by: entlein --- tests/component_test.go | 199 ++++++++---------- ...containerprofile-user-defined-network.yaml | 16 +- .../curl-exec-arg-wildcards-deployment.yaml | 11 +- tests/resources/mc35-cp-app.yaml | 8 +- tests/resources/mc35-cp-sidecar.yaml | 8 +- ...ulti-container-userdefined-deployment.yaml | 10 +- .../nginx-user-defined-deployment.yaml | 5 +- 7 files changed, 102 insertions(+), 155 deletions(-) diff --git a/tests/component_test.go b/tests/component_test.go index ea64f6c6a..79a961744 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -400,30 +400,33 @@ func Test_07_RuleBindingApplyTest(t *testing.T) { assert.NotEqualf(t, 0, exitCode, "Expected error when applying rule binding '%s'", file) } -func Test_08_ApplicationProfilePatching(t *testing.T) { +// Test_08_ContainerProfilePatching pins how a ContainerProfile behaves under a +// JSON-patch. A ContainerProfile is per-container with a FLAT spec (unlike the +// former ApplicationProfile, which nested containers under /spec/containers//), +// so patch paths target /spec/ directly. The contract exercised here: +// - `add /spec//-` appends one element (a syscall, a capability, an exec); +// - `replace /spec/` overwrites the whole field; +// - lifecycle annotations (kubescape.io/status, kubescape.io/completion) are +// patchable, bounded by the completed-immutability invariant (see Test_15): +// a completed profile cannot be patched back to learning, but a forward/lateral +// transition such as initializing→ready is allowed; +// - the storage layer accepts a JSONPatchType patch, persists it, and the +// patched fields read back. +func Test_08_ContainerProfilePatching(t *testing.T) { k8sClient := k8sinterface.NewKubernetesApi() storageclient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) t.Log("Creating namespace") ns := testutils.NewRandomNamespace() - // The unified ContainerProfile carries the (former ApplicationProfile) surfaces - // directly on its flat Spec — one profile per container — so JSON-patch paths - // target /spec/ instead of /spec/containers//. + // One profile per container; the (former ApplicationProfile) surfaces live + // directly on the flat Spec, so patches target /spec/. name := "replicaset-checkoutservice-59596bf8d8-server" containerProfile := &v1beta1.ContainerProfile{ ObjectMeta: metav1.ObjectMeta{ Name: name, - Labels: map[string]string{ - "kubescape.io/instance-template-hash": "59596bf8d8", - "kubescape.io/workload-api-group": "apps", - "kubescape.io/workload-api-version": "v1", - "kubescape.io/workload-kind": "Deployment", - "kubescape.io/workload-name": "checkoutservice", - "kubescape.io/workload-namespace": "node-agent-test-veum", - "kubescape.io/workload-container-name": "server", - "kubescape.io/workload-resource-version": "667544", - }, + // A learned CP carries lifecycle annotations; the patch below + // replaces them, so they must pre-exist. Annotations: map[string]string{ "kubescape.io/completion": "complete", "kubescape.io/status": "initializing", @@ -468,10 +471,47 @@ func Test_08_ApplicationProfilePatching(t *testing.T) { patch, err := json.Marshal(patchOperations) require.NoError(t, err) - // TODO use Storage abstraction? - _, err = storageclient.ContainerProfiles(ns.Name).Patch(context.Background(), name, types.JSONPatchType, patch, v1.PatchOptions{}) - - assert.NoError(t, err) + // Resilient to transient API errors: retry the patch until storage accepts it. + require.Eventually(t, func() bool { + _, patchErr := storageclient.ContainerProfiles(ns.Name).Patch( + context.Background(), name, types.JSONPatchType, patch, metav1.PatchOptions{}) + return patchErr == nil + }, 30*time.Second, 1*time.Second, "JSON-patch of the ContainerProfile must be accepted by storage") + + // Read back and prove the patch persisted on the flat spec. Poll, since the + // write may not be immediately visible. + var patched *v1beta1.ContainerProfile + require.Eventually(t, func() bool { + got, getErr := storageclient.ContainerProfiles(ns.Name).Get( + context.Background(), name, metav1.GetOptions{}) + if getErr != nil { + return false + } + patched = got + return patched.Annotations["kubescape.io/status"] == "ready" + }, 30*time.Second, 1*time.Second, "patched ContainerProfile must read back with the updated status") + + // replace reset /spec/capabilities to [NET_ADMIN], then four adds appended. + assert.ElementsMatch(t, []string{"NET_ADMIN", "SETGID", "SETPCAP", "SETUID", "SYS_ADMIN"}, + patched.Spec.Capabilities, "replace + add /- on /spec/capabilities") + + // add /spec/syscalls/- appended without dropping the learned syscalls. + assert.Subset(t, patched.Spec.Syscalls, []string{"accept4", "arch_prctl", "bind"}, + "add /spec/syscalls/- must append") + assert.Contains(t, patched.Spec.Syscalls, "read", "existing syscalls must survive the patch") + + // replace reset /spec/execs to checkoutservice, then one add appended the probe. + execPaths := make([]string, 0, len(patched.Spec.Execs)) + for _, e := range patched.Spec.Execs { + execPaths = append(execPaths, e.Path) + } + assert.ElementsMatch(t, []string{"/checkoutservice", "/bin/grpc_health_probe"}, execPaths, + "replace + add /- on /spec/execs") + + // lifecycle annotations updated; initializing→ready is a legal transition + // (a completed→learning regression would instead be reverted — see Test_15). + assert.Equal(t, "ready", patched.Annotations["kubescape.io/status"]) + assert.Equal(t, "complete", patched.Annotations["kubescape.io/completion"]) } func Test_09_FalsePositiveTest(t *testing.T) { @@ -1247,10 +1287,9 @@ func Test_27_ApplicationProfileOpens(t *testing.T) { t.Log("======================================") }() - // deployWithProfile creates a user-defined ApplicationProfile with the - // given Opens list, polls until it is retrievable from storage, then - // deploys nginx with the kubescape.io/user-defined-profile label - // pointing at it, and waits for the pod to be ready. + // deployWithProfile creates a user-defined ContainerProfile with the given + // Opens list, then deploys nginx bound to it via the + // kubescape.io/user-defined-profile label and waits for readiness. deployWithProfile := func(t *testing.T, opens []v1beta1.OpenCalls) *testutils.TestWorkload { t.Helper() ns := testutils.NewRandomNamespace() @@ -1849,33 +1888,11 @@ func Test_32_UnexpectedProcessArguments(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: overlayName, Namespace: ns.Name, - Labels: map[string]string{ - helpersv1.ApiGroupMetadataKey: "apps", - helpersv1.ApiVersionMetadataKey: "v1", - helpersv1.RelatedKindMetadataKey: "Deployment", - helpersv1.RelatedNameMetadataKey: "curl-32", - helpersv1.RelatedNamespaceMetadataKey: ns.Name, - }, }, Spec: v1beta1.ContainerProfileSpec{ Execs: []v1beta1.ExecCalls{ - // Profile shape: Path AND Args[0] both use the - // absolute-path symlink form (/bin/sh, - // /usr/bin/nslookup, ...). With the symlink- - // faithful precedence in parse.get_exec_path - // (fix 9a6eb359), the rule queries the - // symlink-as-invoked path that the kernel - // preserves in argv[0]. Recording-side - // resolveExecPath uses the same precedence so - // auto-learned profiles get the same key. - // - // Storage's CompareExecArgs is a strict - // positional compare — no special argv[0] - // normalisation — so Args[0] MUST be the same - // string as runtime argv[0]. For - // kubectl-exec'd processes that's the absolute - // path the caller invoked. - // + // storage's CompareExecArgs is a strict positional compare, so + // Args[0] must equal runtime argv[0] (the absolute path invoked). // pod startup: sleep {Path: "/bin/sleep", Args: []string{"/bin/sleep", dynamicpathdetector.ExecArgsWildcard}}, // sh -c @@ -1890,29 +1907,14 @@ func Test_32_UnexpectedProcessArguments(t *testing.T) { // anchor. (file:// URLs are used as the post-⋯ literals // so curl reads local files and exits 0.) {Path: "/usr/bin/curl", Args: []string{"/usr/bin/curl", "-s", dynamicpathdetector.DynamicIdentifier, "file:///etc/hosts", "file:///etc/hostname"}}, - // Busybox-symlink mirror entries. The curl image's - // /bin/{sleep,sh,echo} are symlinks to /bin/busybox, - // so the kernel's resolved /proc//exe — what - // IG captures as event.exepath — is /bin/busybox. - // parse.get_exec_path(args, comm, exepath) returns - // exepath first, so ap.was_executed queries arrive - // at the rule keyed on /bin/busybox, not the - // symlink form. Without a matching profile entry - // keyed on /bin/busybox, R0001 fires before R0040 - // ever evaluates and the test trips its R0001 - // precondition. The symlink-form entries above are - // retained for environments where exepath resolves - // to the as-invoked path (non-symlinked utilities; - // fexecve / argv[0] fallback in resolveExecPath). + // Busybox-symlink mirrors: the curl image's /bin/{sleep,sh,echo} + // resolve to /bin/busybox (exepath), which the rule keys on. These + // entries are required or R0001 fires before R0040 is reached. {Path: "/bin/busybox", Args: []string{"/bin/sleep", dynamicpathdetector.ExecArgsWildcard}}, {Path: "/bin/busybox", Args: []string{"/bin/sh", "-c", dynamicpathdetector.ExecArgsWildcard}}, {Path: "/bin/busybox", Args: []string{"/bin/echo", "hello", dynamicpathdetector.ExecArgsWildcard}}, - // Literal "*" arg: echo invoked with a GENUINE literal "*" - // (e.g. an unexpanded glob), recorded verbatim. Under the - // symbol contract a "*" in argv is DATA, not a wildcard, so - // this entry matches ONLY `echo star *` and must NOT broaden - // to `echo star `. CT-level mirror of storage's - // TestAP_LiteralStarVsDynamic. (busybox + symlink forms.) + // Literal "*" is DATA, not a wildcard: matches only `echo star *`, + // never `echo star ` (busybox + symlink forms). {Path: "/bin/echo", Args: []string{"/bin/echo", "star", "*"}}, {Path: "/bin/busybox", Args: []string{"/bin/echo", "star", "*"}}, }, @@ -1936,23 +1938,10 @@ func Test_32_UnexpectedProcessArguments(t *testing.T) { require.NoError(t, err) require.NoError(t, wl.WaitForReady(80)) - // Deterministic profile-load gate (replaces a fixed sleep that raced the - // asynchronous overlay load). node-agent must observe the pod, resolve - // the kubescape.io/user-defined-profile annotation to UserAPRef, fetch - // the user AP and build the projection before the argv-comparison rule - // (R0040) can evaluate at all; until then refreshOneEntry reports the CP - // "not-available" and R0040 is suppressed — which makes every POSITIVE - // subtest pass VACUOUSLY (no profile -> no R0040 -> ==0) and every - // NEGATIVE subtest time out. The fixed 30s sleep did not reliably cover - // that window (observed: all negatives failing on a slow load). - // - // The canary is a deterministic argv MISMATCH: [echo, ] matches - // neither [echo, hello, ⋯⋯] nor [echo, star, *], so once the overlay is - // projected it MUST fire R0040. R0040's cooldown key is uniqueId = - // comm+exepath+argv, so this distinct argv never suppresses a subtest's - // own R0040. We retry until it fires, then return the post-gate R0040 - // count as a baseline so subtests assert on the DELTA, not absolutes — - // closing the vacuous-positive hole. + // Profile-load gate: wait until the user-defined CP is projected before + // asserting. The canary is a deterministic argv mismatch ([echo, ]) + // that must fire R0040 once the profile loads; we retry until it does and + // return the post-gate R0040 count so subtests assert on the delta. countR0040 := func(alerts []testutils.Alert) int { n := 0 for _, a := range alerts { @@ -2323,37 +2312,24 @@ func Test_28_UserDefinedNetworkNeighborhood(t *testing.T) { start := time.Now() defer tearDownTest(t, start) - // setup creates a namespace with user-defined AP + NN + pod. - // The NN allows only fusioncore.ai (162.0.217.171) on TCP/80. + // setup deploys a pod bound to an authored ContainerProfile whose egress + // allows only fusioncore.ai (162.0.217.171) on TCP/80. setup := func(t *testing.T) *testutils.TestWorkload { t.Helper() ns := testutils.NewRandomNamespace() - // Upstream ContainerProfileCache (kubescape/node-agent#788) reads ONE - // pod label `kubescape.io/user-defined-profile=` and uses - // as the lookup key for BOTH the user AP and the user NN. - // AP and NN MUST therefore share that single name. const overlayName = "curl-28-overlay" - // Migration (#862): the user authors ONE ContainerProfile (managed-by: - // User) instead of a separate ApplicationProfile + NetworkNeighborhood. - // The kubescape.io/user-defined-profile pod label names this CP; node-agent - // uses it directly as the authoritative base. Its Spec merges the former AP - // surfaces (execs, syscalls) with the former NN surfaces (egress, selector). + // The user authors ONE ContainerProfile (merging the former AP + NN + // surfaces); the pod's kubescape.io/user-defined-profile label names it. _ = applyUserDefinedContainerProfile(t, ns.Name, "resources/containerprofile-user-defined-network.yaml") wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-user-defined-deployment.yaml")) require.NoError(t, err) require.NoError(t, wl.WaitForReady(80)) - // Cache-load latency on the upstream ContainerProfileCache is bursty - // — 15s is enough on a quiet runner but not on a loaded one. The - // failure mode is alert metadata `errorMessage:"waiting for profile - // update"`, which means the rule manager evaluated against an - // unloaded NN and fired R0005/R0011 spuriously. 30s covers the - // observed worst-case in CI without pushing total test time too - // far. Real fix would be to poll a cache-loaded signal, but no - // such signal is exposed today. + // Give node-agent time to load the profile before generating traffic; + // evaluating against an unloaded profile fires R0005/R0011 spuriously. time.Sleep(30 * time.Second) return wl } @@ -2945,19 +2921,12 @@ func Test_34_NetworkNeighborsCIDRCollapse(t *testing.T) { withPrefixes(s3CIDRs, "52.216.1."), withPrefixes(spreadCIDRs, "52.216."), got, withPrefixes(splitCIDRs, "52.216.2.")) } -// Test_35_MultiContainerPerContainerBinding pins the per-container binding of -// user-defined ContainerProfiles (review finding on node-agent#864). A -// multi-container pod shares ONE `kubescape.io/user-defined-profile` label -// value, but each container is profiled independently: node-agent resolves each -// container's authored CP as "