From 58ce8d97f01afe9540bb2f00cf70343d239ec275 Mon Sep 17 00:00:00 2001 From: entlein Date: Sat, 16 May 2026 13:16:38 +0200 Subject: [PATCH] feat(projection): add ExecsByPath composite-key surface to ProjectedContainerProfile Signed-off-by: entlein --- .../containerprofilecache/projection_apply.go | 28 +++++++++++ .../projection_apply_test.go | 50 +++++++++++++++++++ pkg/objectcache/projection_types.go | 9 ++++ 3 files changed, 87 insertions(+) diff --git a/pkg/objectcache/containerprofilecache/projection_apply.go b/pkg/objectcache/containerprofilecache/projection_apply.go index 135464188..ebe62df26 100644 --- a/pkg/objectcache/containerprofilecache/projection_apply.go +++ b/pkg/objectcache/containerprofilecache/projection_apply.go @@ -49,6 +49,7 @@ func Apply(spec *objectcache.RuleProjectionSpec, cp *v1beta1.ContainerProfile, c execsPaths := extractExecsPaths(cp) pcp.Execs = projectField(s.Execs, execsPaths, true) + pcp.ExecsByPath = extractExecsByPath(cp) endpointPaths := extractEndpointPaths(cp) pcp.Endpoints = projectField(s.Endpoints, endpointPaths, true) @@ -166,6 +167,33 @@ func extractExecsPaths(cp *v1beta1.ContainerProfile) []string { return paths } +// extractExecsByPath builds the path → args map used by exec-args +// matchers (e.g. dynamicpathdetector.CompareExecArgs in node-agent#807). +// Multiple ExecCalls entries with the same Path collapse to the last +// seen. nil-Args entries are stored as empty slices; downstream +// matchers distinguish "absent key" (path not in the profile at all) +// from "present with empty slice" (path captured but ran with no args). +// +// Args slices are CLONED rather than aliased — Apply is contract-bound +// to be a pure transform, and an alias would let consumers mutate the +// source profile by editing the projected map. +func extractExecsByPath(cp *v1beta1.ContainerProfile) map[string][]string { + if len(cp.Spec.Execs) == 0 { + return nil + } + m := make(map[string][]string, len(cp.Spec.Execs)) + for _, e := range cp.Spec.Execs { + if e.Args == nil { + m[e.Path] = []string{} + continue + } + cloned := make([]string, len(e.Args)) + copy(cloned, e.Args) + m[e.Path] = cloned + } + return m +} + func extractEndpointPaths(cp *v1beta1.ContainerProfile) []string { endpoints := make([]string, len(cp.Spec.Endpoints)) for i, e := range cp.Spec.Endpoints { diff --git a/pkg/objectcache/containerprofilecache/projection_apply_test.go b/pkg/objectcache/containerprofilecache/projection_apply_test.go index 15b63cf3c..a0308d3d6 100644 --- a/pkg/objectcache/containerprofilecache/projection_apply_test.go +++ b/pkg/objectcache/containerprofilecache/projection_apply_test.go @@ -410,3 +410,53 @@ func TestApply_ExactFilter_NoMatchYieldsNilValues(t *testing.T) { require.NotNil(t, pcp) assert.Nil(t, pcp.Opens.Values, "Values should be nil when no entries match the filter") } + +// TestApply_ExecsByPath_PopulatesFromSpec pins the projection of +// per-Path Args from cp.Spec.Execs into ProjectedContainerProfile.ExecsByPath. +// Three cases combined in one CP fixture to keep the gate compact: +// - 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) { + cp := &v1beta1.ContainerProfile{ + Spec: v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/sh", Args: []string{"-c", "echo hi"}}, + {Path: "/bin/echo", Args: nil}, + {Path: "/bin/sh", Args: []string{"-x", "later"}}, + }, + }, + } + pcp := Apply(&objectcache.RuleProjectionSpec{}, cp, nil) + require.NotNil(t, pcp) + require.NotNil(t, pcp.ExecsByPath, "ExecsByPath must be populated") + + // last write wins for duplicate Path + assert.Equal(t, []string{"-x", "later"}, pcp.ExecsByPath["/bin/sh"], + "duplicate Path: last ExecCalls entry should win") + + // nil Args → empty (non-nil) slice + got, present := pcp.ExecsByPath["/bin/echo"] + require.True(t, present, "/bin/echo must be present even with nil Args") + require.NotNil(t, got, "/bin/echo Args nil source must project as non-nil empty slice") + assert.Empty(t, got, "/bin/echo nil-Args must project as empty slice") + + // CLONED-slice invariant: mutating the projection must not affect + // the source ContainerProfile spec. + sourceCopy := append([]string{}, cp.Spec.Execs[2].Args...) // current "/bin/sh" + pcp.ExecsByPath["/bin/sh"][0] = "MUTATED" + assert.Equal(t, sourceCopy, cp.Spec.Execs[2].Args, + "mutating the projected slice must not propagate to the source profile (cloned, not aliased)") +} + +// TestApply_ExecsByPath_NilWhenSpecEmpty pins the contract that an +// empty Execs list yields a nil ExecsByPath (not an allocated empty +// map). Matches the project-wide convention of nil-for-empty. +func TestApply_ExecsByPath_NilWhenSpecEmpty(t *testing.T) { + cp := &v1beta1.ContainerProfile{Spec: v1beta1.ContainerProfileSpec{}} + pcp := Apply(&objectcache.RuleProjectionSpec{}, cp, nil) + require.NotNil(t, pcp) + assert.Nil(t, pcp.ExecsByPath, "empty Spec.Execs must project to nil ExecsByPath") +} diff --git a/pkg/objectcache/projection_types.go b/pkg/objectcache/projection_types.go index ed55d671b..c9d8c3a6b 100644 --- a/pkg/objectcache/projection_types.go +++ b/pkg/objectcache/projection_types.go @@ -54,6 +54,15 @@ type ProjectedContainerProfile struct { IngressDomains ProjectedField IngressAddresses ProjectedField + // ExecsByPath carries the per-Path Args slice from cp.Spec.Execs so + // downstream consumers (e.g. dynamicpathdetector.CompareExecArgs used + // by R0040 in node-agent#807) can run wildcard-aware argv matching + // against the projected profile. Keyed by Exec.Path (same key used + // in Execs.Values / Execs.Patterns). Projection-v1 dropped argv + // matching as "future work"; this field re-adds the storage surface + // without re-introducing the matcher itself. + ExecsByPath map[string][]string + SpecHash string SyncChecksum string PolicyByRuleId map[string]v1beta1.RulePolicy