From 4bbe102e662826a102fe67e0260c0d0449e84c52 Mon Sep 17 00:00:00 2001 From: entlein Date: Fri, 15 May 2026 22:22:38 +0200 Subject: [PATCH 1/7] allowing CIDRs, wildcards and Plural in IP and DNS Signed-off-by: entlein --- pkg/apis/softwarecomposition/network_types.go | 6 +- .../v1beta1/network_types.go | 6 +- .../v1beta1/network_types_protobuf_test.go | 74 +++++++ pkg/registry/file/networkmatch/README.md | 74 +++++++ pkg/registry/file/networkmatch/bench_test.go | 152 +++++++++++++ pkg/registry/file/networkmatch/doc.go | 9 + pkg/registry/file/networkmatch/match_dns.go | 201 ++++++++++++++++++ .../file/networkmatch/match_dns_test.go | 186 ++++++++++++++++ pkg/registry/file/networkmatch/match_ip.go | 91 ++++++++ .../file/networkmatch/match_ip_test.go | 147 +++++++++++++ pkg/registry/file/networkmatch/validate.go | 79 +++++++ .../file/networkmatch/validate_test.go | 60 ++++++ .../networkneighborhood/strategy.go | 72 +++++++ .../networkneighborhood/strategy_test.go | 156 ++++++++++++++ 14 files changed, 1311 insertions(+), 2 deletions(-) create mode 100644 pkg/apis/softwarecomposition/v1beta1/network_types_protobuf_test.go create mode 100644 pkg/registry/file/networkmatch/README.md create mode 100644 pkg/registry/file/networkmatch/bench_test.go create mode 100644 pkg/registry/file/networkmatch/doc.go create mode 100644 pkg/registry/file/networkmatch/match_dns.go create mode 100644 pkg/registry/file/networkmatch/match_dns_test.go create mode 100644 pkg/registry/file/networkmatch/match_ip.go create mode 100644 pkg/registry/file/networkmatch/match_ip_test.go create mode 100644 pkg/registry/file/networkmatch/validate.go create mode 100644 pkg/registry/file/networkmatch/validate_test.go diff --git a/pkg/apis/softwarecomposition/network_types.go b/pkg/apis/softwarecomposition/network_types.go index c1fba9efa..1b4778652 100644 --- a/pkg/apis/softwarecomposition/network_types.go +++ b/pkg/apis/softwarecomposition/network_types.go @@ -65,7 +65,11 @@ type NetworkNeighbor struct { Ports []NetworkPort PodSelector *metav1.LabelSelector NamespaceSelector *metav1.LabelSelector - IPAddress string + IPAddress string // DEPRECATED - use IPAddresses instead. + // IPAddresses is the v0.0.2 list-form replacement for IPAddress. + // Each entry MAY be a literal IP, a CIDR (a.b.c.d/n), or the "*" sentinel. + // See pkg/registry/file/networkmatch for matcher semantics. + IPAddresses []string } type NetworkPort struct { diff --git a/pkg/apis/softwarecomposition/v1beta1/network_types.go b/pkg/apis/softwarecomposition/v1beta1/network_types.go index b5557dc29..ae012a8e0 100644 --- a/pkg/apis/softwarecomposition/v1beta1/network_types.go +++ b/pkg/apis/softwarecomposition/v1beta1/network_types.go @@ -61,7 +61,11 @@ type NetworkNeighbor struct { Ports []NetworkPort `json:"ports" patchStrategy:"merge" patchMergeKey:"name" protobuf:"bytes,5,rep,name=ports"` PodSelector *metav1.LabelSelector `json:"podSelector" protobuf:"bytes,6,req,name=podSelector"` NamespaceSelector *metav1.LabelSelector `json:"namespaceSelector" protobuf:"bytes,7,req,name=namespaceSelector"` - IPAddress string `json:"ipAddress" protobuf:"bytes,8,req,name=ipAddress"` + IPAddress string `json:"ipAddress" protobuf:"bytes,8,req,name=ipAddress"` // DEPRECATED - use IPAddresses instead. + // IPAddresses is the v0.0.2 list-form replacement for IPAddress. + // Each entry MAY be a literal IP, a CIDR (a.b.c.d/n), or the "*" sentinel. + // See pkg/registry/file/networkmatch for matcher semantics. + IPAddresses []string `json:"ipAddresses,omitempty" protobuf:"bytes,9,rep,name=ipAddresses"` } type NetworkPort struct { diff --git a/pkg/apis/softwarecomposition/v1beta1/network_types_protobuf_test.go b/pkg/apis/softwarecomposition/v1beta1/network_types_protobuf_test.go new file mode 100644 index 000000000..63b166bbd --- /dev/null +++ b/pkg/apis/softwarecomposition/v1beta1/network_types_protobuf_test.go @@ -0,0 +1,74 @@ +package v1beta1 + +import ( + "reflect" + "testing" +) + +// TestNetworkNeighbor_IPAddresses_ProtobufRoundtrip pins the v0.0.2 +// protobuf wire contract for the new IPAddresses field. Storage persists +// NetworkNeighborhood objects to etcd via this protobuf encoding; if +// the field is dropped on round-trip, the spec field is silently lost +// and runtime matchers see an empty list. +// +// Protobuf field number 9 (declared on the struct tag) MUST be preserved +// across Marshal → Unmarshal. +func TestNetworkNeighbor_IPAddresses_ProtobufRoundtrip(t *testing.T) { + original := &NetworkNeighbor{ + Identifier: "test-entry", + Type: "external", + IPAddress: "10.1.2.3", // deprecated singular still works + IPAddresses: []string{"10.0.0.0/8", "192.168.0.0/16", "*", "2001:db8::/32"}, + DNSNames: []string{"api.stripe.com.", "*.stripe.com."}, + } + + wire, err := original.Marshal() + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + decoded := &NetworkNeighbor{} + if err := decoded.Unmarshal(wire); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if !reflect.DeepEqual(decoded.IPAddresses, original.IPAddresses) { + t.Errorf("IPAddresses roundtrip mismatch:\n got: %v\n want: %v", + decoded.IPAddresses, original.IPAddresses) + } + + // Sanity: existing fields still survive (no regression). + if decoded.IPAddress != original.IPAddress { + t.Errorf("deprecated IPAddress lost: got %q want %q", decoded.IPAddress, original.IPAddress) + } + if !reflect.DeepEqual(decoded.DNSNames, original.DNSNames) { + t.Errorf("DNSNames lost: got %v want %v", decoded.DNSNames, original.DNSNames) + } +} + +// TestNetworkNeighbor_IPAddresses_EmptyOmitted confirms that an empty +// IPAddresses slice is not encoded on the wire (zero overhead for +// existing profiles that don't use the new field). +func TestNetworkNeighbor_IPAddresses_EmptyOmitted(t *testing.T) { + withField := &NetworkNeighbor{ + Identifier: "id", + Type: "external", + IPAddresses: nil, + } + withoutField := &NetworkNeighbor{ + Identifier: "id", + Type: "external", + } + a, err := withField.Marshal() + if err != nil { + t.Fatalf("Marshal(withField): %v", err) + } + b, err := withoutField.Marshal() + if err != nil { + t.Fatalf("Marshal(withoutField): %v", err) + } + if !reflect.DeepEqual(a, b) { + t.Errorf("nil IPAddresses must encode identically to absent field;\n got %d bytes vs %d bytes", + len(a), len(b)) + } +} diff --git a/pkg/registry/file/networkmatch/README.md b/pkg/registry/file/networkmatch/README.md new file mode 100644 index 000000000..1630c72c3 --- /dev/null +++ b/pkg/registry/file/networkmatch/README.md @@ -0,0 +1,74 @@ +# networkmatch + +Wildcard-aware matchers for the `NetworkNeighbor.IPAddresses` and +`NetworkNeighbor.DNSNames` fields, used by node-agent's CEL functions +`nn.was_address_in_{egress,ingress}` and `nn.is_domain_in_{egress,ingress}`. + +This package is the runtime counterpart to the spec sections §5.7 (IP) +and §5.8 (DNS) at . + +## Wildcard token vocabulary + +Same tokens as the path / argv matchers in `dynamicpathdetector` — see +that package's `coverage_test.go` for the contract. + +| Token | IP semantics | DNS semantics | +|---|---|---| +| Literal | byte-equality after canonicalization (net.IP) | byte-equality after trailing-dot normalization | +| CIDR (`a.b.c.d/n`) | `net.IPNet.Contains(observed)` | — | +| `*` as full entry | sugar for `0.0.0.0/0` ∪ `::/0` (any IP) | — | +| `*.` (leading) | — | RFC 4592 — exactly one DNS label before `` | +| `.⋯.` (mid) | — | DynamicIdentifier — exactly one DNS label between `` and `` | +| `.*` (trailing) | — | one or more DNS labels after `` (never zero) | +| `**` | reserved (rejected at admission) | reserved (rejected at admission) | + +## API + +```go +// MatchIP reports whether observedIP matches any of the profile entries. +// Each entry MAY be: a literal IP, a CIDR, or the "*" sentinel. +// +// observedIP is matched as text (the function calls net.ParseIP internally +// so the caller does not need to pre-parse it). Empty profile slice +// returns false (no entries → nothing to match against). Empty observedIP +// returns false (no observation to match). +// +// Compile-once contract: callers running this in a hot path SHOULD wrap +// it in a closure that captures pre-compiled *IPNet values across calls +// (the caller knows the profile's lifecycle, this function does not). +func MatchIP(profileEntries []string, observedIP string) bool + +// MatchDNS reports whether observedName matches any of the profile entries. +// Each entry MAY use the wildcard tokens above. +// +// Both profile entries and observedName are normalized before +// comparison: a trailing dot is stripped if present, and labels are +// lowercased for case-insensitive equality. +func MatchDNS(profileEntries []string, observedName string) bool +``` + +## Performance contract + +Both functions are called per network event from R0005 / R0011 / R1003 / +R1009. The benchmarks in `bench_test.go` track: + +- `BenchmarkMatchIP_Literal` — baseline byte-equality +- `BenchmarkMatchIP_CIDR` — single CIDR match +- `BenchmarkMatchIP_LongMixedList` — 10-entry mixed list, observed IP not in list (worst case) +- `BenchmarkMatchDNS_Literal` — baseline +- `BenchmarkMatchDNS_LeadingWildcard` — RFC 4592 +- `BenchmarkMatchDNS_DeepName` — 10-label observed name against a leading-`*` profile + +Targets (CI runner reference): +- IP literal / CIDR: < 200 ns per call +- DNS literal: < 300 ns per call +- DNS wildcard: < 600 ns per call + +Beat or hold these on every change; the matcher fires on every network +event captured by the eBPF tracers. + +## Testing + +`match_ip_test.go` and `match_dns_test.go` are the contract pinning. The +fixtures in `node-agent/tests/resources/network-wildcards/` are the +end-to-end examples; both layers MUST agree. diff --git a/pkg/registry/file/networkmatch/bench_test.go b/pkg/registry/file/networkmatch/bench_test.go new file mode 100644 index 000000000..8060eb719 --- /dev/null +++ b/pkg/registry/file/networkmatch/bench_test.go @@ -0,0 +1,152 @@ +package networkmatch + +import "testing" + +// Benchmarks for the IP matcher. +// +// Targets (CI runner reference, see README.md): +// IP literal / CIDR : < 200 ns/op +// long mixed list : < 1 µs/op +// +// Run: go test -bench=. -benchmem ./pkg/registry/file/networkmatch/ + +func BenchmarkMatchIP_Literal(b *testing.B) { + profile := []string{"10.1.2.3"} + for i := 0; i < b.N; i++ { + _ = MatchIP(profile, "10.1.2.3") + } +} + +func BenchmarkMatchIP_CIDR(b *testing.B) { + profile := []string{"10.0.0.0/8"} + for i := 0; i < b.N; i++ { + _ = MatchIP(profile, "10.1.2.3") + } +} + +func BenchmarkMatchIP_AnySentinel(b *testing.B) { + profile := []string{"*"} + for i := 0; i < b.N; i++ { + _ = MatchIP(profile, "10.1.2.3") + } +} + +func BenchmarkMatchIP_LongMixedList(b *testing.B) { + // Worst case: 10 entries, observed IP not in any of them. + // Validates that adding entries scales linearly without per-entry alloc. + profile := []string{ + "10.1.2.3", "10.1.2.4", "10.1.2.5", + "192.168.0.0/16", + "172.16.0.0/12", + "8.8.8.8", "8.8.4.4", + "2001:db8::/32", + "203.0.113.0/24", + "198.51.100.0/24", + } + for i := 0; i < b.N; i++ { + _ = MatchIP(profile, "1.1.1.1") + } +} + +func BenchmarkMatchIP_LongMixedList_HitFirst(b *testing.B) { + // Best case for early-exit: hit on the first entry. + profile := []string{ + "10.1.2.3", "10.1.2.4", "10.1.2.5", + "192.168.0.0/16", "172.16.0.0/12", + } + for i := 0; i < b.N; i++ { + _ = MatchIP(profile, "10.1.2.3") + } +} + +// Hot-path benchmark: compile once, match many. This is how +// the CEL-function callers in node-agent SHOULD use the matcher. + +func BenchmarkCompiledIPMatcher_Literal(b *testing.B) { + m := CompileIP([]string{"10.1.2.3"}) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = m.Match("10.1.2.3") + } +} + +func BenchmarkCompiledIPMatcher_CIDR(b *testing.B) { + m := CompileIP([]string{"10.0.0.0/8"}) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = m.Match("10.1.2.3") + } +} + +func BenchmarkCompiledIPMatcher_LongMixedList(b *testing.B) { + m := CompileIP([]string{ + "10.1.2.3", "10.1.2.4", "10.1.2.5", + "192.168.0.0/16", + "172.16.0.0/12", + "8.8.8.8", "8.8.4.4", + "2001:db8::/32", + "203.0.113.0/24", + "198.51.100.0/24", + }) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = m.Match("1.1.1.1") + } +} + +// DNS matcher benchmarks. Targets (CI runner reference): +// DNS literal : < 300 ns/op +// DNS wildcard : < 600 ns/op + +func BenchmarkMatchDNS_Literal(b *testing.B) { + profile := []string{"api.stripe.com."} + for i := 0; i < b.N; i++ { + _ = MatchDNS(profile, "api.stripe.com.") + } +} + +func BenchmarkMatchDNS_LeadingWildcard(b *testing.B) { + profile := []string{"*.stripe.com."} + for i := 0; i < b.N; i++ { + _ = MatchDNS(profile, "webhooks.stripe.com.") + } +} + +func BenchmarkCompiledDNSMatcher_Literal(b *testing.B) { + m := CompileDNS([]string{"api.stripe.com."}) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = m.Match("api.stripe.com.") + } +} + +func BenchmarkCompiledDNSMatcher_LeadingWildcard(b *testing.B) { + m := CompileDNS([]string{"*.stripe.com."}) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = m.Match("webhooks.stripe.com.") + } +} + +func BenchmarkCompiledDNSMatcher_DeepName(b *testing.B) { + // 10-label observed name against a leading-* pattern (will miss). + m := CompileDNS([]string{"*.stripe.com."}) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = m.Match("a.b.c.d.e.f.g.h.stripe.com.") + } +} + +func BenchmarkCompiledDNSMatcher_LongMixedList(b *testing.B) { + m := CompileDNS([]string{ + "api.stripe.com.", + "*.stripe.com.", + "api.partner.io.", + "kubernetes.⋯.svc.cluster.local.", + "internal.*", + }) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = m.Match("kubernetes.production.svc.cluster.local.") + } +} diff --git a/pkg/registry/file/networkmatch/doc.go b/pkg/registry/file/networkmatch/doc.go new file mode 100644 index 000000000..1ad2602d5 --- /dev/null +++ b/pkg/registry/file/networkmatch/doc.go @@ -0,0 +1,9 @@ +// Package networkmatch provides wildcard-aware matchers for the +// NetworkNeighbor.IPAddresses and NetworkNeighbor.DNSNames profile fields. +// +// It is the runtime counterpart to spec sections §5.7 (IP) and §5.8 (DNS) +// of the BoB specification (v0.0.2). +// +// See README.md for the wildcard token vocabulary, public API, and +// performance contract. +package networkmatch diff --git a/pkg/registry/file/networkmatch/match_dns.go b/pkg/registry/file/networkmatch/match_dns.go new file mode 100644 index 000000000..6ebcd6913 --- /dev/null +++ b/pkg/registry/file/networkmatch/match_dns.go @@ -0,0 +1,201 @@ +package networkmatch + +import "strings" + +// DNS wildcard tokens. These mirror the path/argv tokens in +// dynamicpathdetector but apply with DNS-label semantics. +const ( + // DNSDynamicLabel is U+22EF — matches exactly one DNS label in + // the middle of a pattern (mirror of dynamicpathdetector.DynamicIdentifier). + DNSDynamicLabel = "⋯" + + // DNSWildcardLabel is "*" — matches exactly one label when it's the + // LEADING label (RFC 4592), or one or more labels when it's the + // TRAILING label (project extension, spec §5.8 row 3). + DNSWildcardLabel = "*" +) + +// DNSMatcher is the compiled form of a DNS profile. +// Each entry compiles into one dnsPattern struct. +type DNSMatcher struct { + patterns []dnsPattern +} + +type dnsPattern struct { + labels []string // labels in declaration order, lowercased, trailing dot stripped + hasLeadingStar bool // labels[0] == "*" + hasTrailingStar bool // labels[len-1] == "*" + valid bool // false if pattern was malformed (e.g. "**" or empty label) +} + +// CompileDNS builds a DNSMatcher from profile entries. +// Malformed entries (empty, "**", empty inner labels) are silently skipped. +func CompileDNS(profileEntries []string) *DNSMatcher { + m := &DNSMatcher{} + for _, entry := range profileEntries { + p := compileDNSPattern(entry) + if !p.valid { + continue + } + m.patterns = append(m.patterns, p) + } + return m +} + +// Match reports whether the observed DNS name is admitted by this matcher. +func (m *DNSMatcher) Match(observed string) bool { + if observed == "" { + return false + } + obsLabels, ok := splitDNS(observed) + if !ok { + return false + } + for i := range m.patterns { + if matchDNSPattern(&m.patterns[i], obsLabels) { + return true + } + } + return false +} + +// MatchDNS is the convenience wrapper. Hot paths SHOULD reuse a +// compiled *DNSMatcher built once via CompileDNS. +func MatchDNS(profileEntries []string, observed string) bool { + if observed == "" || len(profileEntries) == 0 { + return false + } + return CompileDNS(profileEntries).Match(observed) +} + +// splitDNS canonicalizes a DNS name (lowercases, strips trailing dot) +// and splits on "." into labels. Returns (labels, valid). +// An empty inner label (e.g. "foo..bar") returns valid=false. +func splitDNS(name string) ([]string, bool) { + canon := strings.ToLower(strings.TrimSuffix(name, ".")) + if canon == "" { + return nil, false + } + labels := strings.Split(canon, ".") + for _, l := range labels { + if l == "" { + return nil, false + } + } + return labels, true +} + +// compileDNSPattern parses one profile entry into a dnsPattern. +// Sets valid=false on malformed input (which the caller silently skips). +func compileDNSPattern(entry string) dnsPattern { + if entry == "" { + return dnsPattern{} + } + canon := strings.ToLower(strings.TrimSuffix(entry, ".")) + if canon == "" { + return dnsPattern{} + } + labels := strings.Split(canon, ".") + for _, l := range labels { + switch { + case l == "": + // foo..bar — empty inner label is malformed. + return dnsPattern{} + case l == "**": + // Reserved/recursive — explicitly rejected per spec §5.8. + return dnsPattern{} + } + } + p := dnsPattern{labels: labels, valid: true} + if len(labels) > 0 { + if labels[0] == DNSWildcardLabel { + p.hasLeadingStar = true + } + if labels[len(labels)-1] == DNSWildcardLabel { + p.hasTrailingStar = true + } + } + // "*" alone (single-label pattern) is degenerate. Treat as + // leading-star with one label semantics — but since there's no suffix + // to match against, it's only useful matching single-label observations. + // Spec §5.8 doesn't bless this; reject for safety. + if len(labels) == 1 && p.hasLeadingStar { + return dnsPattern{} + } + return p +} + +// matchDNSPattern evaluates one compiled pattern against observed labels. +// +// Algorithm: walk pattern labels left-to-right against observed labels, +// applying token semantics: +// +// leading "*" (only at index 0): consumes EXACTLY ONE observed label (RFC 4592) +// "⋯" (any position): consumes EXACTLY ONE observed label +// trailing "*" (only at last): consumes ONE OR MORE observed labels (§5.8) +// literal: byte-equality (already lowercased) +// +// Mid-position "*" tokens (i.e. "*" not at index 0 and not at last index) +// are treated as DynamicLabel-equivalent (one label) — but spec restricts +// declaration to leading/trailing only; admission validates the position. +func matchDNSPattern(p *dnsPattern, obs []string) bool { + plabels := p.labels + pi := 0 + oi := 0 + plen := len(plabels) + olen := len(obs) + + for pi < plen { + tok := plabels[pi] + isLast := pi == plen-1 + isFirst := pi == 0 + + // Trailing "*" — consume one or more remaining labels. + // Pattern ends here, so observed must have at least one label left + // AND we exit the loop after. + if tok == DNSWildcardLabel && isLast && !isFirst { + return olen-oi >= 1 + } + + // Leading "*" — consume exactly one label. + if tok == DNSWildcardLabel && isFirst { + if oi >= olen { + return false + } + oi++ + pi++ + continue + } + + // "⋯" — consume exactly one label (any position). + if tok == DNSDynamicLabel { + if oi >= olen { + return false + } + oi++ + pi++ + continue + } + + // Mid-position "*" (declaration-illegal but defensive): treat as one label. + if tok == DNSWildcardLabel { + if oi >= olen { + return false + } + oi++ + pi++ + continue + } + + // Literal label — byte equality. + if oi >= olen || obs[oi] != tok { + return false + } + oi++ + pi++ + } + + // Pattern fully consumed — observed must also be fully consumed + // (anchored match — DNS patterns are FQDN-anchored). + return oi == olen +} diff --git a/pkg/registry/file/networkmatch/match_dns_test.go b/pkg/registry/file/networkmatch/match_dns_test.go new file mode 100644 index 000000000..df8a91dd5 --- /dev/null +++ b/pkg/registry/file/networkmatch/match_dns_test.go @@ -0,0 +1,186 @@ +package networkmatch + +import "testing" + +// Contract pinning for MatchDNS / CompileDNS. Encodes spec §5.8. +// User-facing fixtures: node-agent/tests/resources/network-wildcards/{09..14,17,18}.yaml + +func TestMatchDNS_LiteralEquality(t *testing.T) { + cases := []struct { + name string + profile []string + observed string + want bool + }{ + {"hit", []string{"api.stripe.com."}, "api.stripe.com.", true}, + {"miss-different-tld", []string{"api.stripe.com."}, "api.stripe.org.", false}, + {"miss-extra-label", []string{"api.stripe.com."}, "v1.api.stripe.com.", false}, + {"miss-too-short", []string{"api.stripe.com."}, "stripe.com.", false}, + {"case-insensitive", []string{"API.Stripe.com."}, "api.stripe.com.", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := MatchDNS(tc.profile, tc.observed); got != tc.want { + t.Errorf("MatchDNS(%v, %q) = %v, want %v", tc.profile, tc.observed, got, tc.want) + } + }) + } +} + +func TestMatchDNS_TrailingDotNormalisation(t *testing.T) { + // Trailing dot is the FQDN canonical form. Profile entries SHOULD have it, + // observed names from runtime SHOULD have it, but the matcher MUST be + // resilient to either form on either side. + cases := []struct { + name string + profile []string + observed string + want bool + }{ + {"both-with-dot", []string{"api.stripe.com."}, "api.stripe.com.", true}, + {"profile-no-dot", []string{"api.stripe.com"}, "api.stripe.com.", true}, + {"observed-no-dot", []string{"api.stripe.com."}, "api.stripe.com", true}, + {"neither-dot", []string{"api.stripe.com"}, "api.stripe.com", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := MatchDNS(tc.profile, tc.observed); got != tc.want { + t.Errorf("MatchDNS(%v, %q) = %v, want %v", tc.profile, tc.observed, got, tc.want) + } + }) + } +} + +func TestMatchDNS_LeadingWildcard_RFC4592(t *testing.T) { + // "*.example.com." matches EXACTLY ONE label before the suffix. + cases := []struct { + name string + profile []string + observed string + want bool + }{ + {"hit-one-label", []string{"*.example.com."}, "api.example.com.", true}, + {"miss-zero-labels", []string{"*.example.com."}, "example.com.", false}, + {"miss-two-labels", []string{"*.example.com."}, "v1.api.example.com.", false}, + {"miss-different-suffix", []string{"*.example.com."}, "api.example.org.", false}, + {"hit-with-numeric-label", []string{"*.example.com."}, "v1.example.com.", true}, + {"hit-with-hyphen", []string{"*.example.com."}, "my-app.example.com.", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := MatchDNS(tc.profile, tc.observed); got != tc.want { + t.Errorf("MatchDNS(%v, %q) = %v, want %v", tc.profile, tc.observed, got, tc.want) + } + }) + } +} + +func TestMatchDNS_MidEllipsis(t *testing.T) { + // ".⋯." — DynamicIdentifier matches EXACTLY ONE label in the middle. + // This is the user's specific case for kubernetes service FQDNs. + cases := []struct { + name string + profile []string + observed string + want bool + }{ + {"hit-one-label-mid", []string{"kubernetes.⋯.svc.cluster.local."}, "kubernetes.default.svc.cluster.local.", true}, + {"hit-different-ns", []string{"kubernetes.⋯.svc.cluster.local."}, "kubernetes.kube-system.svc.cluster.local.", true}, + {"miss-zero-labels-mid", []string{"kubernetes.⋯.svc.cluster.local."}, "kubernetes.svc.cluster.local.", false}, + {"miss-two-labels-mid", []string{"kubernetes.⋯.svc.cluster.local."}, "kubernetes.foo.bar.svc.cluster.local.", false}, + {"miss-different-prefix", []string{"kubernetes.⋯.svc.cluster.local."}, "redis.default.svc.cluster.local.", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := MatchDNS(tc.profile, tc.observed); got != tc.want { + t.Errorf("MatchDNS(%v, %q) = %v, want %v", tc.profile, tc.observed, got, tc.want) + } + }) + } +} + +func TestMatchDNS_TrailingStar(t *testing.T) { + // ".*" — trailing * matches ONE OR MORE labels (never zero). + // This is the project-specific extension (not RFC 4592 — that only + // covers leading *). + cases := []struct { + name string + profile []string + observed string + want bool + }{ + {"hit-one-label", []string{"internal.*"}, "internal.foo.", true}, + {"hit-many-labels", []string{"internal.*"}, "internal.foo.bar.baz.", true}, + {"miss-zero-labels", []string{"internal.*"}, "internal.", false}, + {"miss-different-prefix", []string{"internal.*"}, "external.foo.", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := MatchDNS(tc.profile, tc.observed); got != tc.want { + t.Errorf("MatchDNS(%v, %q) = %v, want %v", tc.profile, tc.observed, got, tc.want) + } + }) + } +} + +func TestMatchDNS_ListAcceptIfAnyMatches(t *testing.T) { + // Disjunction across the entry list. Mirror of fixture 17. + profile := []string{ + "api.stripe.com.", + "*.stripe.com.", + "api.partner.io.", + } + cases := []struct { + observed string + want bool + }{ + {"api.stripe.com.", true}, // literal hit + {"webhooks.stripe.com.", true}, // *.stripe.com. + {"v1.api.stripe.com.", false}, // two labels deep, *.stripe.com. only allows one + {"api.partner.io.", true}, // literal hit + {"api.example.com.", false}, // not in any entry + } + for _, tc := range cases { + t.Run(tc.observed, func(t *testing.T) { + if got := MatchDNS(profile, tc.observed); got != tc.want { + t.Errorf("MatchDNS(profile, %q) = %v, want %v", tc.observed, got, tc.want) + } + }) + } +} + +func TestMatchDNS_RejectsMalformed(t *testing.T) { + cases := []struct { + name string + profile []string + observed string + want bool + }{ + {"empty-profile", nil, "api.stripe.com.", false}, + {"empty-observation", []string{"api.stripe.com."}, "", false}, + {"empty-string-entry-skipped", []string{""}, "api.stripe.com.", false}, + {"recursive-double-star-rejected", []string{"**"}, "anything.com.", false}, + {"empty-label-in-pattern-not-recognised", []string{"foo..bar."}, "foo.bar.", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := MatchDNS(tc.profile, tc.observed); got != tc.want { + t.Errorf("MatchDNS(%v, %q) = %v, want %v", tc.profile, tc.observed, got, tc.want) + } + }) + } +} + +func TestCompiledDNSMatcher_Reuse(t *testing.T) { + // Compiled-form contract: build once, match many. + m := CompileDNS([]string{"*.stripe.com.", "api.partner.io."}) + if !m.Match("webhooks.stripe.com.") { + t.Error("compiled matcher missed *.stripe.com. hit") + } + if m.Match("v1.api.stripe.com.") { + t.Error("compiled matcher should NOT match two-label-deep against *.stripe.com.") + } + if !m.Match("api.partner.io.") { + t.Error("compiled matcher missed literal hit") + } +} diff --git a/pkg/registry/file/networkmatch/match_ip.go b/pkg/registry/file/networkmatch/match_ip.go new file mode 100644 index 000000000..095117e9d --- /dev/null +++ b/pkg/registry/file/networkmatch/match_ip.go @@ -0,0 +1,91 @@ +package networkmatch + +import ( + "net" + "strings" +) + +// AnyIPSentinel is the profile entry that matches any valid IP address. +// Equivalent to the union of 0.0.0.0/0 and ::/0. Spec §5.7. +const AnyIPSentinel = "*" + +// IPMatcher is the compiled form of an IP profile. +// Callers in the hot path (CEL functions, runtime rules) build one per +// profile and reuse it across every observed event for that profile. +type IPMatcher struct { + any bool // any AnyIPSentinel ("*") entry → match anything + literals []net.IP // already-parsed literal IPs (IPv4-canonicalized by net.ParseIP) + cidrs []*net.IPNet // pre-compiled CIDRs +} + +// CompileIP builds an IPMatcher from a profile entry list. +// Malformed entries are silently dropped (validation is the admission layer's job). +// Returns a usable matcher even on an empty / all-malformed input — Match will return false. +func CompileIP(profileEntries []string) *IPMatcher { + m := &IPMatcher{} + for _, entry := range profileEntries { + if entry == "" { + continue + } + if entry == AnyIPSentinel { + m.any = true + continue + } + if strings.Contains(entry, "/") { + _, cidr, err := net.ParseCIDR(entry) + if err != nil { + continue + } + m.cidrs = append(m.cidrs, cidr) + continue + } + ip := net.ParseIP(entry) + if ip == nil { + continue + } + m.literals = append(m.literals, ip) + } + return m +} + +// Match reports whether the observed IP text is admitted by this matcher. +func (m *IPMatcher) Match(observedIP string) bool { + if observedIP == "" { + return false + } + if m.any { + // Even with the sentinel, the observation must be a valid IP. + // Empty / garbage observations always fail (admission requires a real address). + if net.ParseIP(observedIP) == nil { + return false + } + return true + } + parsed := net.ParseIP(observedIP) + if parsed == nil { + return false + } + for _, lit := range m.literals { + if lit.Equal(parsed) { + return true + } + } + for _, cidr := range m.cidrs { + if cidr.Contains(parsed) { + return true + } + } + return false +} + +// MatchIP is the convenience wrapper that compiles + matches in one call. +// Use this only on cold paths; hot paths SHOULD reuse a cached *IPMatcher +// constructed via CompileIP. +// +// Empty profile or empty observation returns false. +func MatchIP(profileEntries []string, observedIP string) bool { + if observedIP == "" || len(profileEntries) == 0 { + return false + } + return CompileIP(profileEntries).Match(observedIP) +} diff --git a/pkg/registry/file/networkmatch/match_ip_test.go b/pkg/registry/file/networkmatch/match_ip_test.go new file mode 100644 index 000000000..a127fc185 --- /dev/null +++ b/pkg/registry/file/networkmatch/match_ip_test.go @@ -0,0 +1,147 @@ +package networkmatch + +import "testing" + +// Contract pinning for MatchIP. These tests encode the v0.0.2 IP-matching +// surface from spec §5.7. The fixtures in +// node-agent/tests/resources/network-wildcards/{01..08,15..20}.yaml are +// the user-facing examples; this file is the unit-level contract. + +func TestMatchIP_LiteralEquality(t *testing.T) { + cases := []struct { + name string + profile []string + observed string + want bool + }{ + {"ipv4-hit", []string{"10.1.2.3"}, "10.1.2.3", true}, + {"ipv4-miss", []string{"10.1.2.3"}, "10.1.2.4", false}, + {"ipv6-hit-canonical", []string{"2001:db8::1"}, "2001:db8::1", true}, + {"ipv6-hit-different-format", []string{"2001:db8::1"}, "2001:0db8:0000:0000:0000:0000:0000:0001", true}, + // IPv4-mapped IPv6 ::ffff:a.b.c.d MUST match its IPv4 form — same on-the-wire + // destination. net.IP.Equal handles this naturally; documenting it as a contract. + {"ipv4-mapped-v6-matches-v4", []string{"10.0.0.1"}, "::ffff:10.0.0.1", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := MatchIP(tc.profile, tc.observed); got != tc.want { + t.Errorf("MatchIP(%v, %q) = %v, want %v", tc.profile, tc.observed, got, tc.want) + } + }) + } +} + +func TestMatchIP_CIDRMembership(t *testing.T) { + cases := []struct { + name string + profile []string + observed string + want bool + }{ + {"ipv4-cidr-hit", []string{"10.0.0.0/8"}, "10.1.2.3", true}, + {"ipv4-cidr-edge-network", []string{"10.0.0.0/8"}, "10.0.0.0", true}, + {"ipv4-cidr-edge-broadcast", []string{"10.0.0.0/8"}, "10.255.255.255", true}, + {"ipv4-cidr-miss", []string{"10.0.0.0/8"}, "11.0.0.1", false}, + {"ipv4-cidr-32-equals-literal", []string{"10.1.2.3/32"}, "10.1.2.3", true}, + {"ipv4-cidr-32-other-miss", []string{"10.1.2.3/32"}, "10.1.2.4", false}, + {"ipv6-cidr-hit", []string{"2001:db8::/32"}, "2001:db8::1", true}, + {"ipv6-cidr-miss", []string{"2001:db8::/32"}, "2001:db9::1", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := MatchIP(tc.profile, tc.observed); got != tc.want { + t.Errorf("MatchIP(%v, %q) = %v, want %v", tc.profile, tc.observed, got, tc.want) + } + }) + } +} + +func TestMatchIP_AnySentinel(t *testing.T) { + // The "*" sentinel matches any valid IP. Spec §5.7 row 3. + cases := []struct { + name string + observed string + want bool + }{ + {"ipv4-any", "1.2.3.4", true}, + {"ipv6-any", "2001:db8::1", true}, + {"loopback-v4", "127.0.0.1", true}, + {"loopback-v6", "::1", true}, + {"empty-still-false", "", false}, // empty observation cannot match anything, even * + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := MatchIP([]string{"*"}, tc.observed); got != tc.want { + t.Errorf("MatchIP(['*'], %q) = %v, want %v", tc.observed, got, tc.want) + } + }) + } +} + +func TestMatchIP_AnyAsCIDR(t *testing.T) { + // 0.0.0.0/0 and ::/0 are the RFC-aligned alternatives to "*". + if !MatchIP([]string{"0.0.0.0/0"}, "1.2.3.4") { + t.Error("0.0.0.0/0 should match any IPv4") + } + if !MatchIP([]string{"::/0"}, "2001:db8::1") { + t.Error("::/0 should match any IPv6") + } + // 0.0.0.0/0 alone does NOT cover IPv6 — RFC distinct address families. + if MatchIP([]string{"0.0.0.0/0"}, "2001:db8::1") { + t.Error("0.0.0.0/0 must NOT match IPv6 — distinct address family") + } + // ::/0 alone does NOT cover IPv4 (Go's net.IPNet behavior confirms this). + if MatchIP([]string{"::/0"}, "1.2.3.4") { + t.Error("::/0 must NOT match IPv4 — distinct address family") + } +} + +func TestMatchIP_RejectsMalformed(t *testing.T) { + // Malformed entries are skipped (not crashed on); other valid entries + // in the same list still match. This is the runtime-side defence; + // admission-time validation should reject them at write time. + cases := []struct { + name string + profile []string + observed string + want bool + }{ + {"garbage-only-no-match", []string{"not-an-ip"}, "1.2.3.4", false}, + {"garbage-cidr-skipped", []string{"10.0.0.0/40"}, "10.1.2.3", false}, + {"garbage-skipped-but-valid-still-matches", []string{"not-an-ip", "10.1.2.3"}, "10.1.2.3", true}, + {"empty-profile", nil, "1.2.3.4", false}, + {"empty-string-entry", []string{""}, "1.2.3.4", false}, + {"observed-is-garbage", []string{"10.1.2.3"}, "not-an-ip", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := MatchIP(tc.profile, tc.observed); got != tc.want { + t.Errorf("MatchIP(%v, %q) = %v, want %v", tc.profile, tc.observed, got, tc.want) + } + }) + } +} + +func TestMatchIP_ListAcceptIfAnyMatches(t *testing.T) { + // Mirror of fixture 07: mixed list. Disjunctive — any single entry hit means match. + profile := []string{"10.1.2.3", "192.168.0.0/16", "*"} + if !MatchIP(profile, "10.1.2.3") { + t.Error("literal hit in mixed list must match") + } + if !MatchIP(profile, "192.168.5.5") { + t.Error("CIDR hit in mixed list must match") + } + // "*" is in the list, so anything matches via the sentinel. + if !MatchIP(profile, "8.8.8.8") { + t.Error("'*' in list must match any valid IP") + } + + // Without the sentinel, only literal+CIDR coverage holds. + narrower := []string{"10.1.2.3", "192.168.0.0/16"} + if MatchIP(narrower, "8.8.8.8") { + t.Error("non-listed IP must NOT match without '*' sentinel") + } + if !MatchIP(narrower, "192.168.5.5") { + t.Error("CIDR-listed IP must match without '*' sentinel") + } +} diff --git a/pkg/registry/file/networkmatch/validate.go b/pkg/registry/file/networkmatch/validate.go new file mode 100644 index 000000000..3dcf2f08d --- /dev/null +++ b/pkg/registry/file/networkmatch/validate.go @@ -0,0 +1,79 @@ +package networkmatch + +import ( + "fmt" + "net" + "strings" +) + +// ValidateIPEntry returns an error describing why entry is not a valid +// member of an IPAddresses[] list, or nil if it is valid. +// +// Valid forms: +// - literal IP (parsed by net.ParseIP) +// - CIDR (parsed by net.ParseCIDR) +// - the AnyIPSentinel ("*") +// +// This is the admission-time defence; runtime MatchIP also tolerates +// malformed entries (silently skips them) so a bad write doesn't kill +// the whole match. +func ValidateIPEntry(entry string) error { + if entry == "" { + return fmt.Errorf("empty IP entry") + } + if entry == AnyIPSentinel { + return nil + } + if strings.Contains(entry, "/") { + if _, _, err := net.ParseCIDR(entry); err != nil { + return fmt.Errorf("malformed CIDR %q: %w", entry, err) + } + return nil + } + if net.ParseIP(entry) == nil { + return fmt.Errorf("malformed IP %q (not a literal, not a CIDR)", entry) + } + return nil +} + +// ValidateDNSEntry returns an error describing why entry is not a valid +// member of a DNSNames[] list, or nil if it is valid. +// +// Valid forms (spec §5.8): +// - literal name (with or without trailing dot) +// - leading "*" (only as the first label, RFC 4592) +// - trailing "*" (only as the last label) +// - mid "⋯" (DynamicLabel, anywhere) +// +// Rejected: +// - "**" anywhere (recursive — reserved) +// - empty inner labels (e.g. "foo..bar") +// - "*" in any position other than first or last +// - lone "*" with no fixed anchor (degenerate single-label pattern) +func ValidateDNSEntry(entry string) error { + if entry == "" { + return fmt.Errorf("empty DNS entry") + } + canon := strings.TrimSuffix(entry, ".") + if canon == "" { + return fmt.Errorf("DNS entry %q is just a trailing dot", entry) + } + labels := strings.Split(canon, ".") + if len(labels) == 1 && labels[0] == DNSWildcardLabel { + return fmt.Errorf("lone %q is not a valid DNS pattern — needs an anchored suffix", DNSWildcardLabel) + } + for i, l := range labels { + switch { + case l == "": + return fmt.Errorf("DNS entry %q has empty label at position %d", entry, i) + case l == "**": + return fmt.Errorf("DNS entry %q contains reserved recursive wildcard %q", entry, "**") + case l == DNSWildcardLabel && i != 0 && i != len(labels)-1: + return fmt.Errorf( + "DNS entry %q: bare %q is only allowed as the first label (RFC 4592) "+ + "or last label (project extension); use %q for mid positions", + entry, DNSWildcardLabel, DNSDynamicLabel) + } + } + return nil +} diff --git a/pkg/registry/file/networkmatch/validate_test.go b/pkg/registry/file/networkmatch/validate_test.go new file mode 100644 index 000000000..a03ed470c --- /dev/null +++ b/pkg/registry/file/networkmatch/validate_test.go @@ -0,0 +1,60 @@ +package networkmatch + +import "testing" + +func TestValidateIPEntry(t *testing.T) { + cases := []struct { + name string + entry string + wantErr bool + }{ + {"empty", "", true}, + {"literal-v4", "10.1.2.3", false}, + {"literal-v6", "2001:db8::1", false}, + {"cidr-v4", "10.0.0.0/8", false}, + {"cidr-v6", "2001:db8::/32", false}, + {"any-sentinel", "*", false}, + {"any-cidr-v4", "0.0.0.0/0", false}, + {"any-cidr-v6", "::/0", false}, + {"garbage", "not-an-ip", true}, + {"bad-cidr-mask", "10.0.0.0/40", true}, + {"bad-cidr-host", "not-an-ip/8", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateIPEntry(tc.entry) + if (err != nil) != tc.wantErr { + t.Errorf("ValidateIPEntry(%q) err=%v, wantErr=%v", tc.entry, err, tc.wantErr) + } + }) + } +} + +func TestValidateDNSEntry(t *testing.T) { + cases := []struct { + name string + entry string + wantErr bool + }{ + {"empty", "", true}, + {"trailing-dot-only", ".", true}, + {"literal-with-dot", "api.stripe.com.", false}, + {"literal-no-dot", "api.stripe.com", false}, + {"leading-star", "*.example.com.", false}, + {"trailing-star", "internal.*", false}, + {"mid-ellipsis", "kubernetes.⋯.svc.cluster.local.", false}, + {"recursive-double-star", "**", true}, + {"recursive-in-middle", "foo.**.bar.", true}, + {"empty-inner-label", "foo..bar.", true}, + {"lone-star", "*", true}, + {"mid-star-rejected", "foo.*.bar.", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateDNSEntry(tc.entry) + if (err != nil) != tc.wantErr { + t.Errorf("ValidateDNSEntry(%q) err=%v, wantErr=%v", tc.entry, err, tc.wantErr) + } + }) + } +} diff --git a/pkg/registry/softwarecomposition/networkneighborhood/strategy.go b/pkg/registry/softwarecomposition/networkneighborhood/strategy.go index e8d171e04..07447ecf0 100644 --- a/pkg/registry/softwarecomposition/networkneighborhood/strategy.go +++ b/pkg/registry/softwarecomposition/networkneighborhood/strategy.go @@ -16,6 +16,7 @@ import ( "github.com/kubescape/go-logger" "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" "github.com/kubescape/storage/pkg/apis/softwarecomposition" + "github.com/kubescape/storage/pkg/registry/file/networkmatch" "github.com/kubescape/storage/pkg/registry/softwarecomposition/common" "github.com/kubescape/storage/pkg/utils" ) @@ -104,9 +105,78 @@ func (NetworkNeighborhoodStrategy) Validate(_ context.Context, obj runtime.Objec allErrors = append(allErrors, err) } + allErrors = append(allErrors, validateNetworkProfileEntries(&ap.Spec)...) + return allErrors } +// validateNetworkProfileEntries walks every NetworkNeighbor in the spec and +// validates each IPAddresses[] and DNSNames[] entry against the v0.0.2 +// wildcard token grammar (spec §5.7, §5.8). +// +// This is the admission-time defence; runtime matchers also tolerate +// malformed entries so a misconfigured profile doesn't kill the +// detection path entirely. +func validateNetworkProfileEntries(spec *softwarecomposition.NetworkNeighborhoodSpec) field.ErrorList { + var errs field.ErrorList + specPath := field.NewPath("spec") + // Ordered slice rather than a map: Go map iteration is non-deterministic, + // and admission errors flow back to clients via the apiserver. Stable + // ordering keeps error messages reproducible across requests and across + // test runs. + groups := []struct { + name string + items []softwarecomposition.NetworkNeighborhoodContainer + }{ + {name: "containers", items: spec.Containers}, + {name: "initContainers", items: spec.InitContainers}, + {name: "ephemeralContainers", items: spec.EphemeralContainers}, + } + for _, g := range groups { + groupPath := specPath.Child(g.name) + for ci, c := range g.items { + containerPath := groupPath.Index(ci) + errs = append(errs, validateNeighborList(containerPath.Child("egress"), c.Egress)...) + errs = append(errs, validateNeighborList(containerPath.Child("ingress"), c.Ingress)...) + } + } + return errs +} + +func validateNeighborList(parent *field.Path, list []softwarecomposition.NetworkNeighbor) field.ErrorList { + var errs field.ErrorList + for ni, n := range list { + nPath := parent.Index(ni) + ipsPath := nPath.Child("ipAddresses") + for ei, e := range n.IPAddresses { + if err := networkmatch.ValidateIPEntry(e); err != nil { + errs = append(errs, field.Invalid(ipsPath.Index(ei), e, err.Error())) + } + } + // Deprecated singular IPAddress is still accepted; validate it too + // so malformed values can't slip past admission via the old form. + if n.IPAddress != "" { + if err := networkmatch.ValidateIPEntry(n.IPAddress); err != nil { + errs = append(errs, field.Invalid(nPath.Child("ipAddress"), n.IPAddress, err.Error())) + } + } + dnsPath := nPath.Child("dnsNames") + for ei, e := range n.DNSNames { + if err := networkmatch.ValidateDNSEntry(e); err != nil { + errs = append(errs, field.Invalid(dnsPath.Index(ei), e, err.Error())) + } + } + // Deprecated singular DNS is still accepted; validate it too, + // mirroring the IPAddress pattern above. + if n.DNS != "" { + if err := networkmatch.ValidateDNSEntry(n.DNS); err != nil { + errs = append(errs, field.Invalid(nPath.Child("dns"), n.DNS, err.Error())) + } + } + } + return errs +} + // WarningsOnCreate returns warnings for the creation of the given object. func (NetworkNeighborhoodStrategy) WarningsOnCreate(_ context.Context, _ runtime.Object) []string { return nil @@ -136,6 +206,8 @@ func (NetworkNeighborhoodStrategy) ValidateUpdate(_ context.Context, obj, _ runt allErrors = append(allErrors, err) } + allErrors = append(allErrors, validateNetworkProfileEntries(&ap.Spec)...) + return allErrors } diff --git a/pkg/registry/softwarecomposition/networkneighborhood/strategy_test.go b/pkg/registry/softwarecomposition/networkneighborhood/strategy_test.go index 6990101ab..bcec7a8aa 100644 --- a/pkg/registry/softwarecomposition/networkneighborhood/strategy_test.go +++ b/pkg/registry/softwarecomposition/networkneighborhood/strategy_test.go @@ -344,3 +344,159 @@ func TestPrepareForUpdateFullObj(t *testing.T) { }) } } + +// TestValidate_NetworkProfileEntries pins the v0.0.2 admission contract: +// malformed IPAddresses[] / DNSNames[] entries cause Validate to return +// field errors that the apiserver translates into a 400 to the client. +// +// Runtime matchers tolerate malformed entries (silently skip), but +// admission rejects them so the next person reviewing the profile sees +// a clean document — and so the user gets fast feedback at write time. +func TestValidate_NetworkProfileEntries(t *testing.T) { + makeNN := func(neighbor softwarecomposition.NetworkNeighbor) *softwarecomposition.NetworkNeighborhood { + return &softwarecomposition.NetworkNeighborhood{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-nn", + Namespace: "default", + Annotations: map[string]string{helpers.CompletionMetadataKey: "complete", helpers.StatusMetadataKey: "ready"}, + }, + Spec: softwarecomposition.NetworkNeighborhoodSpec{ + Containers: []softwarecomposition.NetworkNeighborhoodContainer{ + {Name: "c", Egress: []softwarecomposition.NetworkNeighbor{neighbor}}, + }, + }, + } + } + + cases := []struct { + name string + neighbor softwarecomposition.NetworkNeighbor + // wantPaths is the multiset of expected error field paths. + // Asserting paths (not just count) pins the field-path contract + // — if validation starts emitting errors on the wrong field path, + // downstream tooling that surfaces these to users will break. + wantPaths []string + }{ + { + name: "all valid IPs and DNSNames", + neighbor: softwarecomposition.NetworkNeighbor{IPAddresses: []string{"10.0.0.0/8", "*", "1.2.3.4"}, DNSNames: []string{"*.example.com.", "api.partner.io."}}, + wantPaths: nil, + }, + { + name: "single malformed IP", + neighbor: softwarecomposition.NetworkNeighbor{IPAddresses: []string{"not-an-ip"}}, + wantPaths: []string{"spec.containers[0].egress[0].ipAddresses[0]"}, + }, + { + name: "single malformed CIDR", + neighbor: softwarecomposition.NetworkNeighbor{IPAddresses: []string{"10.0.0.0/40"}}, + wantPaths: []string{"spec.containers[0].egress[0].ipAddresses[0]"}, + }, + { + name: "recursive DNS wildcard rejected", + neighbor: softwarecomposition.NetworkNeighbor{DNSNames: []string{"**"}}, + wantPaths: []string{"spec.containers[0].egress[0].dnsNames[0]"}, + }, + { + name: "mid-position bare star rejected (must use ⋯)", + neighbor: softwarecomposition.NetworkNeighbor{DNSNames: []string{"foo.*.bar."}}, + wantPaths: []string{"spec.containers[0].egress[0].dnsNames[0]"}, + }, + { + name: "mixed: some good, some bad", + neighbor: softwarecomposition.NetworkNeighbor{IPAddresses: []string{"10.1.2.3", "garbage", "192.168.0.0/16"}, DNSNames: []string{"api.example.com.", "**", "*.example.com."}}, + wantPaths: []string{ + "spec.containers[0].egress[0].ipAddresses[1]", + "spec.containers[0].egress[0].dnsNames[1]", + }, + }, + { + name: "deprecated singular IPAddress malformed is also rejected", + neighbor: softwarecomposition.NetworkNeighbor{IPAddress: "not-an-ip"}, + wantPaths: []string{"spec.containers[0].egress[0].ipAddress"}, + }, + { + name: "deprecated singular DNS malformed is also rejected", + neighbor: softwarecomposition.NetworkNeighbor{DNS: "**"}, + wantPaths: []string{"spec.containers[0].egress[0].dns"}, + }, + } + + s := NetworkNeighborhoodStrategy{} + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + errs := s.Validate(context.TODO(), makeNN(tc.neighbor)) + if len(errs) != len(tc.wantPaths) { + t.Fatalf("Validate returned %d errors, want %d. errs: %v", len(errs), len(tc.wantPaths), errs) + } + gotPaths := make([]string, 0, len(errs)) + for _, e := range errs { + gotPaths = append(gotPaths, e.Field) + } + // Order-insensitive set comparison: build a multiset from each side. + gotSet := map[string]int{} + for _, p := range gotPaths { + gotSet[p]++ + } + wantSet := map[string]int{} + for _, p := range tc.wantPaths { + wantSet[p]++ + } + for p, n := range wantSet { + if gotSet[p] != n { + t.Errorf("expected %d errors at path %q, got %d (all paths: %v)", n, p, gotSet[p], gotPaths) + } + } + for p := range gotSet { + if _, ok := wantSet[p]; !ok { + t.Errorf("unexpected error at path %q (all paths: %v)", p, gotPaths) + } + } + }) + } +} + +// TestValidateUpdate_NetworkProfileEntries pins the same admission contract +// for the update path. CR (storage#30) caught that ValidateUpdate originally +// skipped network-profile validation, allowing malformed entries to land via +// PUT after a clean POST. +func TestValidateUpdate_NetworkProfileEntries(t *testing.T) { + bad := &softwarecomposition.NetworkNeighborhood{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-nn", + Namespace: "default", + Annotations: map[string]string{helpers.CompletionMetadataKey: "complete", helpers.StatusMetadataKey: "ready"}, + }, + Spec: softwarecomposition.NetworkNeighborhoodSpec{ + Containers: []softwarecomposition.NetworkNeighborhoodContainer{{ + Name: "c", + Egress: []softwarecomposition.NetworkNeighbor{ + {IPAddresses: []string{"not-an-ip"}, DNSNames: []string{"**"}}, + }, + }}, + }, + } + s := NetworkNeighborhoodStrategy{} + errs := s.ValidateUpdate(context.TODO(), bad, bad) + wantPaths := map[string]int{ + "spec.containers[0].egress[0].ipAddresses[0]": 1, + "spec.containers[0].egress[0].dnsNames[0]": 1, + } + if len(errs) != 2 { + t.Fatalf("ValidateUpdate returned %d errors, want 2. errs: %v", len(errs), errs) + } + gotSet := map[string]int{} + for _, e := range errs { + gotSet[e.Field]++ + } + for p, n := range wantPaths { + if gotSet[p] != n { + t.Errorf("expected %d errors at path %q, got %d (all: %v)", n, p, gotSet[p], errs) + } + } + for p := range gotSet { + if _, ok := wantPaths[p]; !ok { + t.Errorf("unexpected error at path %q (all: %v)", p, errs) + } + } +} From 12b82f41de6158151cf904f0c5edbc8fc4521bef Mon Sep 17 00:00:00 2001 From: entlein Date: Fri, 15 May 2026 22:25:45 +0200 Subject: [PATCH 2/7] amend README.md Signed-off-by: entlein --- pkg/registry/file/networkmatch/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/registry/file/networkmatch/README.md b/pkg/registry/file/networkmatch/README.md index 1630c72c3..bf7319769 100644 --- a/pkg/registry/file/networkmatch/README.md +++ b/pkg/registry/file/networkmatch/README.md @@ -5,7 +5,7 @@ Wildcard-aware matchers for the `NetworkNeighbor.IPAddresses` and `nn.was_address_in_{egress,ingress}` and `nn.is_domain_in_{egress,ingress}`. This package is the runtime counterpart to the spec sections §5.7 (IP) -and §5.8 (DNS) at . +and §5.8 (DNS) at . ## Wildcard token vocabulary From ef3cbc6498fe27980dda33bcd8ebda70e1074acc Mon Sep 17 00:00:00 2001 From: entlein Date: Sat, 16 May 2026 13:13:49 +0200 Subject: [PATCH 3/7] apply rabbit feedback: align networkmatch + network types with rc1 final state Signed-off-by: entlein --- pkg/registry/file/networkmatch/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/registry/file/networkmatch/README.md b/pkg/registry/file/networkmatch/README.md index bf7319769..1630c72c3 100644 --- a/pkg/registry/file/networkmatch/README.md +++ b/pkg/registry/file/networkmatch/README.md @@ -5,7 +5,7 @@ Wildcard-aware matchers for the `NetworkNeighbor.IPAddresses` and `nn.was_address_in_{egress,ingress}` and `nn.is_domain_in_{egress,ingress}`. This package is the runtime counterpart to the spec sections §5.7 (IP) -and §5.8 (DNS) at . +and §5.8 (DNS) at . ## Wildcard token vocabulary From 870aa0e98aadb6315dbe4ad4a609d6de3e47c83f Mon Sep 17 00:00:00 2001 From: Entlein Date: Wed, 27 May 2026 18:18:45 +0200 Subject: [PATCH 4/7] fix(api): hand-edit IPAddresses codegen so the field actually persists NetworkNeighbor.IPAddresses (added on this branch) was missing hand-edited entries in the Marshal/Size/String/Unmarshal stanzas of generated.pb.go, the proto declaration, both conversion functions, and the internal+v1beta1 deepcopy. Result: TestNetworkNeighbor_IPAddresses_ ProtobufRoundtrip failed and the field was silently dropped on every real storage write. This patch adds the missing codec for field 9 (repeated string, wire tag 0x4a), the .proto declaration, the conversion stanzas in both directions, and the deepcopy slice copy in both type-system layers. Codegen pipeline is not run as part of build on this fork (the protoc image is x86_64-only); hand-edit follows the proven recipe from commit 0d83e2b3 / ad60a5b4. Resolves matthyx review on network_types.go:68 (2026-05-27). Signed-off-by: entlein --- .../v1beta1/generated.pb.go | 48 +++++++++++++++++++ .../v1beta1/generated.proto | 6 +++ .../v1beta1/zz_generated.conversion.go | 1 + .../v1beta1/zz_generated.deepcopy.go | 5 ++ .../zz_generated.deepcopy.go | 5 ++ 5 files changed, 65 insertions(+) diff --git a/pkg/apis/softwarecomposition/v1beta1/generated.pb.go b/pkg/apis/softwarecomposition/v1beta1/generated.pb.go index 68922f90a..378aaa39f 100644 --- a/pkg/apis/softwarecomposition/v1beta1/generated.pb.go +++ b/pkg/apis/softwarecomposition/v1beta1/generated.pb.go @@ -4199,6 +4199,15 @@ func (m *NetworkNeighbor) MarshalToSizedBuffer(dAtA []byte) (int, error) { _ = i var l int _ = l + if len(m.IPAddresses) > 0 { + for iNdEx := len(m.IPAddresses) - 1; iNdEx >= 0; iNdEx-- { + i -= len(m.IPAddresses[iNdEx]) + copy(dAtA[i:], m.IPAddresses[iNdEx]) + i = encodeVarintGenerated(dAtA, i, uint64(len(m.IPAddresses[iNdEx]))) + i-- + dAtA[i] = 0x4a + } + } i -= len(m.IPAddress) copy(dAtA[i:], m.IPAddress) i = encodeVarintGenerated(dAtA, i, uint64(len(m.IPAddress))) @@ -10293,6 +10302,12 @@ func (m *NetworkNeighbor) Size() (n int) { } l = len(m.IPAddress) n += 1 + l + sovGenerated(uint64(l)) + if len(m.IPAddresses) > 0 { + for _, s := range m.IPAddresses { + l = len(s) + n += 1 + l + sovGenerated(uint64(l)) + } + } return n } @@ -13133,6 +13148,7 @@ func (this *NetworkNeighbor) String() string { `PodSelector:` + strings.Replace(fmt.Sprintf("%v", this.PodSelector), "LabelSelector", "v1.LabelSelector", 1) + `,`, `NamespaceSelector:` + strings.Replace(fmt.Sprintf("%v", this.NamespaceSelector), "LabelSelector", "v1.LabelSelector", 1) + `,`, `IPAddress:` + fmt.Sprintf("%v", this.IPAddress) + `,`, + `IPAddresses:` + fmt.Sprintf("%v", this.IPAddresses) + `,`, `}`, }, "") return s @@ -26856,6 +26872,38 @@ func (m *NetworkNeighbor) Unmarshal(dAtA []byte) error { } m.IPAddress = string(dAtA[iNdEx:postIndex]) iNdEx = postIndex + case 9: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field IPAddresses", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenerated + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthGenerated + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLengthGenerated + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.IPAddresses = append(m.IPAddresses, string(dAtA[iNdEx:postIndex])) + iNdEx = postIndex default: iNdEx = preIndex skippy, err := skipGenerated(dAtA[iNdEx:]) diff --git a/pkg/apis/softwarecomposition/v1beta1/generated.proto b/pkg/apis/softwarecomposition/v1beta1/generated.proto index 59879b6f1..dc7904605 100644 --- a/pkg/apis/softwarecomposition/v1beta1/generated.proto +++ b/pkg/apis/softwarecomposition/v1beta1/generated.proto @@ -869,6 +869,12 @@ message NetworkNeighbor { optional .k8s.io.apimachinery.pkg.apis.meta.v1.LabelSelector namespaceSelector = 7; optional string ipAddress = 8; + + // IPAddresses is the v0.0.2 list-form replacement for the deprecated + // single-IP `ipAddress` field. Each entry MAY be a literal IP, a + // CIDR (a.b.c.d/n), or the "*" sentinel. See + // pkg/registry/file/networkmatch for matcher semantics. + repeated string ipAddresses = 9; } // NetworkNeighborhood represents a list of network communications for a specific workload. diff --git a/pkg/apis/softwarecomposition/v1beta1/zz_generated.conversion.go b/pkg/apis/softwarecomposition/v1beta1/zz_generated.conversion.go index 03d068836..d047c41a0 100644 --- a/pkg/apis/softwarecomposition/v1beta1/zz_generated.conversion.go +++ b/pkg/apis/softwarecomposition/v1beta1/zz_generated.conversion.go @@ -3684,6 +3684,7 @@ func autoConvert_softwarecomposition_NetworkNeighbor_To_v1beta1_NetworkNeighbor( out.PodSelector = (*metav1.LabelSelector)(unsafe.Pointer(in.PodSelector)) out.NamespaceSelector = (*metav1.LabelSelector)(unsafe.Pointer(in.NamespaceSelector)) out.IPAddress = in.IPAddress + out.IPAddresses = *(*[]string)(unsafe.Pointer(&in.IPAddresses)) return nil } diff --git a/pkg/apis/softwarecomposition/v1beta1/zz_generated.deepcopy.go b/pkg/apis/softwarecomposition/v1beta1/zz_generated.deepcopy.go index 6b8ba1d21..031141250 100644 --- a/pkg/apis/softwarecomposition/v1beta1/zz_generated.deepcopy.go +++ b/pkg/apis/softwarecomposition/v1beta1/zz_generated.deepcopy.go @@ -1963,6 +1963,11 @@ func (in *NetworkNeighbor) DeepCopyInto(out *NetworkNeighbor) { *out = new(metav1.LabelSelector) (*in).DeepCopyInto(*out) } + if in.IPAddresses != nil { + in, out := &in.IPAddresses, &out.IPAddresses + *out = make([]string, len(*in)) + copy(*out, *in) + } return } diff --git a/pkg/apis/softwarecomposition/zz_generated.deepcopy.go b/pkg/apis/softwarecomposition/zz_generated.deepcopy.go index de6a86e0e..461b514c4 100644 --- a/pkg/apis/softwarecomposition/zz_generated.deepcopy.go +++ b/pkg/apis/softwarecomposition/zz_generated.deepcopy.go @@ -1970,6 +1970,11 @@ func (in *NetworkNeighbor) DeepCopyInto(out *NetworkNeighbor) { *out = new(metav1.LabelSelector) (*in).DeepCopyInto(*out) } + if in.IPAddresses != nil { + in, out := &in.IPAddresses, &out.IPAddresses + *out = make([]string, len(*in)) + copy(*out, *in) + } return } From c3241fb4b464304ed5e5eb79635b36eaad9fb687 Mon Sep 17 00:00:00 2001 From: entlein Date: Fri, 15 May 2026 22:59:50 +0200 Subject: [PATCH 5/7] profile-compaction: CollapseConfig CRD + projection overlay + user-managed lifecycle Signed-off-by: entlein --- .../collapseconfiguration-default-sample.yaml | 29 + .../softwarecomposition/collapse_types.go | 73 ++ pkg/apis/softwarecomposition/register.go | 2 + .../v1beta1/collapse_types.go | 69 ++ .../v1beta1/generated.pb.go | 742 ++++++++++++++++++ .../v1beta1/generated.proto | 45 ++ .../v1beta1/generated.protomessage.pb.go | 8 + .../softwarecomposition/v1beta1/register.go | 2 + .../v1beta1/zz_generated.conversion.go | 135 ++++ .../v1beta1/zz_generated.deepcopy.go | 97 +++ .../v1beta1/zz_generated.model_name.go | 20 + .../zz_generated.deepcopy.go | 97 +++ pkg/apiserver/apiserver.go | 2 + .../v1beta1/collapseconfigentry.go | 54 ++ .../v1beta1/collapseconfiguration.go | 238 ++++++ .../v1beta1/collapseconfigurationspec.go | 70 ++ pkg/generated/applyconfiguration/utils.go | 6 + .../v1beta1/collapseconfiguration.go | 74 ++ .../fake/fake_collapseconfiguration.go | 53 ++ .../fake/fake_softwarecomposition_client.go | 4 + .../v1beta1/generated_expansion.go | 2 + .../v1beta1/softwarecomposition_client.go | 5 + .../informers/externalversions/generic.go | 2 + .../v1beta1/collapseconfiguration.go | 101 +++ .../softwarecomposition/v1beta1/interface.go | 7 + .../v1beta1/collapseconfiguration.go | 48 ++ .../v1beta1/expansion_generated.go | 4 + pkg/generated/openapi/zz_generated.openapi.go | 179 +++++ .../file/applicationprofile_processor.go | 32 +- ...rofile_processor_collapse_provider_test.go | 170 ++++ .../file/applicationprofile_processor_test.go | 209 +++++ pkg/registry/file/cleanup.go | 18 + pkg/registry/file/cleanup_test.go | 68 ++ .../file/containerprofile_processor.go | 18 +- ...rofile_processor_collapse_provider_test.go | 123 +++ .../collapse_config_from_crd.go | 116 +++ .../tests/collapse_config_crd_test.go | 162 ++++ .../collapseconfiguration/etcd.go | 59 ++ .../collapseconfiguration/strategy.go | 156 ++++ .../collapseconfiguration/strategy_test.go | 148 ++++ 40 files changed, 3435 insertions(+), 12 deletions(-) create mode 100644 artifacts/collapseconfiguration-default-sample.yaml create mode 100644 pkg/apis/softwarecomposition/collapse_types.go create mode 100644 pkg/apis/softwarecomposition/v1beta1/collapse_types.go create mode 100644 pkg/generated/applyconfiguration/softwarecomposition/v1beta1/collapseconfigentry.go create mode 100644 pkg/generated/applyconfiguration/softwarecomposition/v1beta1/collapseconfiguration.go create mode 100644 pkg/generated/applyconfiguration/softwarecomposition/v1beta1/collapseconfigurationspec.go create mode 100644 pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1/collapseconfiguration.go create mode 100644 pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1/fake/fake_collapseconfiguration.go create mode 100644 pkg/generated/informers/externalversions/softwarecomposition/v1beta1/collapseconfiguration.go create mode 100644 pkg/generated/listers/softwarecomposition/v1beta1/collapseconfiguration.go create mode 100644 pkg/registry/file/applicationprofile_processor_collapse_provider_test.go create mode 100644 pkg/registry/file/containerprofile_processor_collapse_provider_test.go create mode 100644 pkg/registry/file/dynamicpathdetector/collapse_config_from_crd.go create mode 100644 pkg/registry/file/dynamicpathdetector/tests/collapse_config_crd_test.go create mode 100644 pkg/registry/softwarecomposition/collapseconfiguration/etcd.go create mode 100644 pkg/registry/softwarecomposition/collapseconfiguration/strategy.go create mode 100644 pkg/registry/softwarecomposition/collapseconfiguration/strategy_test.go diff --git a/artifacts/collapseconfiguration-default-sample.yaml b/artifacts/collapseconfiguration-default-sample.yaml new file mode 100644 index 000000000..a4a52524b --- /dev/null +++ b/artifacts/collapseconfiguration-default-sample.yaml @@ -0,0 +1,29 @@ +# Sample CollapseConfiguration. Apply to a cluster running storage to +# replace the compiled-in defaults at deflate time. +# +# The resource is cluster-scoped; the singleton "default" is the only +# name the deflate path consults. +# +# kubectl apply -f artifacts/collapseconfiguration-default-sample.yaml +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: CollapseConfiguration +metadata: + name: default +spec: + # Fallback threshold for AnalyzeOpens when no per-prefix entry matches. + openDynamicThreshold: 50 + # Fallback threshold for AnalyzeEndpoints. + endpointDynamicThreshold: 100 + # Per-prefix overrides, evaluated longest-prefix-wins. + collapseConfigs: + - prefix: /etc + threshold: 100 + - prefix: /etc/apache2 + threshold: 50 + - prefix: /opt + threshold: 50 + - prefix: /var/run + threshold: 50 + - prefix: /app + threshold: 50 diff --git a/pkg/apis/softwarecomposition/collapse_types.go b/pkg/apis/softwarecomposition/collapse_types.go new file mode 100644 index 000000000..a39400efb --- /dev/null +++ b/pkg/apis/softwarecomposition/collapse_types.go @@ -0,0 +1,73 @@ +/* +Copyright 2024 The Kubescape Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package softwarecomposition + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// +genclient +// +genclient:nonNamespaced +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// CollapseConfiguration is a cluster-scoped resource carrying per-prefix +// thresholds for the dynamic-path-detector's open/endpoint collapse step. +// +// At runtime the storage server's deflate path reads the singleton +// CollapseConfiguration (name "default") and feeds its entries into +// NewPathAnalyzerWithConfigs(...). When the resource is absent the deflate +// path falls back to the package-level defaultCollapseConfigs slice. +// +// Tooling (e.g. bobctl autotune) can write the singleton to push tuned +// thresholds back into a running cluster without restarting the storage +// server. +type CollapseConfiguration struct { + metav1.TypeMeta + metav1.ObjectMeta + + Spec CollapseConfigurationSpec +} + +// CollapseConfigurationSpec carries the cluster-wide collapse thresholds. +type CollapseConfigurationSpec struct { + // OpenDynamicThreshold is the fallback threshold for AnalyzeOpens when + // no per-prefix entry matches the walked path. + OpenDynamicThreshold int32 + // EndpointDynamicThreshold is the counterpart for AnalyzeEndpoints. + EndpointDynamicThreshold int32 + // CollapseConfigs is the per-prefix threshold override list, evaluated + // longest-prefix-wins. + CollapseConfigs []CollapseConfigEntry +} + +// CollapseConfigEntry is one per-prefix threshold override. +type CollapseConfigEntry struct { + // Prefix is the path prefix to match (e.g. "/etc", "/opt"). + Prefix string + // Threshold is the maximum number of unique children allowed at any + // trie node under Prefix before that node collapses to a single + // dynamic identifier. + Threshold int32 +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// CollapseConfigurationList is a list of CollapseConfiguration objects. +type CollapseConfigurationList struct { + metav1.TypeMeta + metav1.ListMeta + + Items []CollapseConfiguration +} diff --git a/pkg/apis/softwarecomposition/register.go b/pkg/apis/softwarecomposition/register.go index 623950c9d..32f6e4495 100644 --- a/pkg/apis/softwarecomposition/register.go +++ b/pkg/apis/softwarecomposition/register.go @@ -83,6 +83,8 @@ func addKnownTypes(scheme *runtime.Scheme) error { &SBOMSyftFilteredList{}, &SeccompProfile{}, &SeccompProfileList{}, + &CollapseConfiguration{}, + &CollapseConfigurationList{}, ) return nil } diff --git a/pkg/apis/softwarecomposition/v1beta1/collapse_types.go b/pkg/apis/softwarecomposition/v1beta1/collapse_types.go new file mode 100644 index 000000000..a85932f72 --- /dev/null +++ b/pkg/apis/softwarecomposition/v1beta1/collapse_types.go @@ -0,0 +1,69 @@ +/* +Copyright 2024 The Kubescape Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta1 + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// +genclient +// +genclient:nonNamespaced +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// CollapseConfiguration is a cluster-scoped resource carrying per-prefix +// thresholds for the dynamic-path-detector's open/endpoint collapse step. +// The storage server's deflate path reads the singleton (name "default") +// and feeds its entries into NewPathAnalyzerWithConfigs at runtime. +type CollapseConfiguration struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"` + + Spec CollapseConfigurationSpec `json:"spec" protobuf:"bytes,2,req,name=spec"` +} + +// CollapseConfigurationSpec carries the cluster-wide collapse thresholds. +type CollapseConfigurationSpec struct { + // OpenDynamicThreshold is the fallback threshold for AnalyzeOpens when + // no per-prefix entry matches the walked path. + OpenDynamicThreshold int32 `json:"openDynamicThreshold" protobuf:"varint,1,req,name=openDynamicThreshold"` + // EndpointDynamicThreshold is the counterpart for AnalyzeEndpoints. + EndpointDynamicThreshold int32 `json:"endpointDynamicThreshold" protobuf:"varint,2,req,name=endpointDynamicThreshold"` + // CollapseConfigs is the per-prefix threshold override list, evaluated + // longest-prefix-wins. Each entry is keyed by Prefix so server-side + // apply patches one entry at a time instead of replacing the slice. + // +listType=map + // +listMapKey=prefix + CollapseConfigs []CollapseConfigEntry `json:"collapseConfigs,omitempty" protobuf:"bytes,3,rep,name=collapseConfigs"` +} + +// CollapseConfigEntry is one per-prefix threshold override. +type CollapseConfigEntry struct { + // Prefix is the path prefix to match (e.g. "/etc", "/opt"). + Prefix string `json:"prefix" protobuf:"bytes,1,req,name=prefix"` + // Threshold is the maximum number of unique children allowed at any + // trie node under Prefix before that node collapses to a single + // dynamic identifier. + Threshold int32 `json:"threshold" protobuf:"varint,2,req,name=threshold"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// CollapseConfigurationList is a list of CollapseConfiguration objects. +type CollapseConfigurationList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"` + + Items []CollapseConfiguration `json:"items" protobuf:"bytes,2,rep,name=items"` +} diff --git a/pkg/apis/softwarecomposition/v1beta1/generated.pb.go b/pkg/apis/softwarecomposition/v1beta1/generated.pb.go index 378aaa39f..a64b55c3e 100644 --- a/pkg/apis/softwarecomposition/v1beta1/generated.pb.go +++ b/pkg/apis/softwarecomposition/v1beta1/generated.pb.go @@ -60,6 +60,14 @@ func (m *CallStack) Reset() { *m = CallStack{} } func (m *CallStackNode) Reset() { *m = CallStackNode{} } +func (m *CollapseConfigEntry) Reset() { *m = CollapseConfigEntry{} } + +func (m *CollapseConfiguration) Reset() { *m = CollapseConfiguration{} } + +func (m *CollapseConfigurationList) Reset() { *m = CollapseConfigurationList{} } + +func (m *CollapseConfigurationSpec) Reset() { *m = CollapseConfigurationSpec{} } + func (m *Component) Reset() { *m = Component{} } func (m *Condition) Reset() { *m = Condition{} } @@ -909,6 +917,170 @@ func (m *CallStackNode) MarshalToSizedBuffer(dAtA []byte) (int, error) { return len(dAtA) - i, nil } +func (m *CollapseConfigEntry) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *CollapseConfigEntry) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *CollapseConfigEntry) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + i = encodeVarintGenerated(dAtA, i, uint64(m.Threshold)) + i-- + dAtA[i] = 0x10 + i -= len(m.Prefix) + copy(dAtA[i:], m.Prefix) + i = encodeVarintGenerated(dAtA, i, uint64(len(m.Prefix))) + i-- + dAtA[i] = 0xa + return len(dAtA) - i, nil +} + +func (m *CollapseConfiguration) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *CollapseConfiguration) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *CollapseConfiguration) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + { + size, err := m.Spec.MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintGenerated(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x12 + { + size, err := m.ObjectMeta.MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintGenerated(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0xa + return len(dAtA) - i, nil +} + +func (m *CollapseConfigurationList) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *CollapseConfigurationList) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *CollapseConfigurationList) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + if len(m.Items) > 0 { + for iNdEx := len(m.Items) - 1; iNdEx >= 0; iNdEx-- { + { + size, err := m.Items[iNdEx].MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintGenerated(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x12 + } + } + { + size, err := m.ListMeta.MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintGenerated(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0xa + return len(dAtA) - i, nil +} + +func (m *CollapseConfigurationSpec) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *CollapseConfigurationSpec) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *CollapseConfigurationSpec) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + if len(m.CollapseConfigs) > 0 { + for iNdEx := len(m.CollapseConfigs) - 1; iNdEx >= 0; iNdEx-- { + { + size, err := m.CollapseConfigs[iNdEx].MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintGenerated(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x1a + } + } + i = encodeVarintGenerated(dAtA, i, uint64(m.EndpointDynamicThreshold)) + i-- + dAtA[i] = 0x10 + i = encodeVarintGenerated(dAtA, i, uint64(m.OpenDynamicThreshold)) + i-- + dAtA[i] = 0x8 + return len(dAtA) - i, nil +} + func (m *Component) Marshal() (dAtA []byte, err error) { size := m.Size() dAtA = make([]byte, size) @@ -9024,6 +9196,65 @@ func (m *CallStackNode) Size() (n int) { return n } +func (m *CollapseConfigEntry) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Prefix) + n += 1 + l + sovGenerated(uint64(l)) + n += 1 + sovGenerated(uint64(m.Threshold)) + return n +} + +func (m *CollapseConfiguration) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = m.ObjectMeta.Size() + n += 1 + l + sovGenerated(uint64(l)) + l = m.Spec.Size() + n += 1 + l + sovGenerated(uint64(l)) + return n +} + +func (m *CollapseConfigurationList) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = m.ListMeta.Size() + n += 1 + l + sovGenerated(uint64(l)) + if len(m.Items) > 0 { + for _, e := range m.Items { + l = e.Size() + n += 1 + l + sovGenerated(uint64(l)) + } + } + return n +} + +func (m *CollapseConfigurationSpec) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + n += 1 + sovGenerated(uint64(m.OpenDynamicThreshold)) + n += 1 + sovGenerated(uint64(m.EndpointDynamicThreshold)) + if len(m.CollapseConfigs) > 0 { + for _, e := range m.CollapseConfigs { + l = e.Size() + n += 1 + l + sovGenerated(uint64(l)) + } + } + return n +} + func (m *Component) Size() (n int) { if m == nil { return 0 @@ -12156,6 +12387,61 @@ func (this *CallStackNode) String() string { }, "") return s } +func (this *CollapseConfigEntry) String() string { + if this == nil { + return "nil" + } + s := strings.Join([]string{`&CollapseConfigEntry{`, + `Prefix:` + fmt.Sprintf("%v", this.Prefix) + `,`, + `Threshold:` + fmt.Sprintf("%v", this.Threshold) + `,`, + `}`, + }, "") + return s +} +func (this *CollapseConfiguration) String() string { + if this == nil { + return "nil" + } + s := strings.Join([]string{`&CollapseConfiguration{`, + `ObjectMeta:` + strings.Replace(strings.Replace(fmt.Sprintf("%v", this.ObjectMeta), "ObjectMeta", "v1.ObjectMeta", 1), `&`, ``, 1) + `,`, + `Spec:` + strings.Replace(strings.Replace(this.Spec.String(), "CollapseConfigurationSpec", "CollapseConfigurationSpec", 1), `&`, ``, 1) + `,`, + `}`, + }, "") + return s +} +func (this *CollapseConfigurationList) String() string { + if this == nil { + return "nil" + } + repeatedStringForItems := "[]CollapseConfiguration{" + for _, f := range this.Items { + repeatedStringForItems += strings.Replace(strings.Replace(f.String(), "CollapseConfiguration", "CollapseConfiguration", 1), `&`, ``, 1) + "," + } + repeatedStringForItems += "}" + s := strings.Join([]string{`&CollapseConfigurationList{`, + `ListMeta:` + strings.Replace(strings.Replace(fmt.Sprintf("%v", this.ListMeta), "ListMeta", "v1.ListMeta", 1), `&`, ``, 1) + `,`, + `Items:` + repeatedStringForItems + `,`, + `}`, + }, "") + return s +} +func (this *CollapseConfigurationSpec) String() string { + if this == nil { + return "nil" + } + repeatedStringForCollapseConfigs := "[]CollapseConfigEntry{" + for _, f := range this.CollapseConfigs { + repeatedStringForCollapseConfigs += strings.Replace(strings.Replace(f.String(), "CollapseConfigEntry", "CollapseConfigEntry", 1), `&`, ``, 1) + "," + } + repeatedStringForCollapseConfigs += "}" + s := strings.Join([]string{`&CollapseConfigurationSpec{`, + `OpenDynamicThreshold:` + fmt.Sprintf("%v", this.OpenDynamicThreshold) + `,`, + `EndpointDynamicThreshold:` + fmt.Sprintf("%v", this.EndpointDynamicThreshold) + `,`, + `CollapseConfigs:` + repeatedStringForCollapseConfigs + `,`, + `}`, + }, "") + return s +} func (this *Component) String() string { if this == nil { return "nil" @@ -16117,6 +16403,462 @@ func (m *CallStackNode) Unmarshal(dAtA []byte) error { } return nil } +func (m *CollapseConfigEntry) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenerated + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: CollapseConfigEntry: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: CollapseConfigEntry: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Prefix", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenerated + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthGenerated + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLengthGenerated + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Prefix = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Threshold", wireType) + } + m.Threshold = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenerated + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Threshold |= int32(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skipGenerated(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthGenerated + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *CollapseConfiguration) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenerated + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: CollapseConfiguration: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: CollapseConfiguration: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ObjectMeta", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenerated + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthGenerated + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthGenerated + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if err := m.ObjectMeta.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Spec", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenerated + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthGenerated + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthGenerated + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if err := m.Spec.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipGenerated(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthGenerated + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *CollapseConfigurationList) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenerated + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: CollapseConfigurationList: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: CollapseConfigurationList: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ListMeta", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenerated + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthGenerated + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthGenerated + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if err := m.ListMeta.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Items", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenerated + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthGenerated + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthGenerated + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Items = append(m.Items, CollapseConfiguration{}) + if err := m.Items[len(m.Items)-1].Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipGenerated(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthGenerated + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *CollapseConfigurationSpec) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenerated + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: CollapseConfigurationSpec: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: CollapseConfigurationSpec: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field OpenDynamicThreshold", wireType) + } + m.OpenDynamicThreshold = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenerated + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.OpenDynamicThreshold |= int32(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 2: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field EndpointDynamicThreshold", wireType) + } + m.EndpointDynamicThreshold = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenerated + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.EndpointDynamicThreshold |= int32(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field CollapseConfigs", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenerated + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthGenerated + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthGenerated + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.CollapseConfigs = append(m.CollapseConfigs, CollapseConfigEntry{}) + if err := m.CollapseConfigs[len(m.CollapseConfigs)-1].Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipGenerated(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthGenerated + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} func (m *Component) Unmarshal(dAtA []byte) error { l := len(dAtA) iNdEx := 0 diff --git a/pkg/apis/softwarecomposition/v1beta1/generated.proto b/pkg/apis/softwarecomposition/v1beta1/generated.proto index dc7904605..4d4c5a808 100644 --- a/pkg/apis/softwarecomposition/v1beta1/generated.proto +++ b/pkg/apis/softwarecomposition/v1beta1/generated.proto @@ -131,6 +131,51 @@ message CallStackNode { optional StackFrame frame = 2; } +// CollapseConfigEntry is one per-prefix threshold override. +message CollapseConfigEntry { + // Prefix is the path prefix to match (e.g. "/etc", "/opt"). + optional string prefix = 1; + + // Threshold is the maximum number of unique children allowed at any + // trie node under Prefix before that node collapses to a single + // dynamic identifier. + optional int32 threshold = 2; +} + +// CollapseConfiguration is a cluster-scoped resource carrying per-prefix +// thresholds for the dynamic-path-detector's open/endpoint collapse step. +// The storage server's deflate path reads the singleton (name "default") +// and feeds its entries into NewPathAnalyzerWithConfigs at runtime. +message CollapseConfiguration { + optional .k8s.io.apimachinery.pkg.apis.meta.v1.ObjectMeta metadata = 1; + + optional CollapseConfigurationSpec spec = 2; +} + +// CollapseConfigurationList is a list of CollapseConfiguration objects. +message CollapseConfigurationList { + optional .k8s.io.apimachinery.pkg.apis.meta.v1.ListMeta metadata = 1; + + repeated CollapseConfiguration items = 2; +} + +// CollapseConfigurationSpec carries the cluster-wide collapse thresholds. +message CollapseConfigurationSpec { + // OpenDynamicThreshold is the fallback threshold for AnalyzeOpens when + // no per-prefix entry matches the walked path. + optional int32 openDynamicThreshold = 1; + + // EndpointDynamicThreshold is the counterpart for AnalyzeEndpoints. + optional int32 endpointDynamicThreshold = 2; + + // CollapseConfigs is the per-prefix threshold override list, evaluated + // longest-prefix-wins. Each entry is keyed by Prefix so server-side + // apply patches one entry at a time instead of replacing the slice. + // +listType=map + // +listMapKey=prefix + repeated CollapseConfigEntry collapseConfigs = 3; +} + message Component { // ID is an IRI identifying the component. It is optional as the component // can also be identified using hashes or software identifiers. diff --git a/pkg/apis/softwarecomposition/v1beta1/generated.protomessage.pb.go b/pkg/apis/softwarecomposition/v1beta1/generated.protomessage.pb.go index b1d2d8e21..f3dfcbe82 100644 --- a/pkg/apis/softwarecomposition/v1beta1/generated.protomessage.pb.go +++ b/pkg/apis/softwarecomposition/v1beta1/generated.protomessage.pb.go @@ -41,6 +41,14 @@ func (*CallStack) ProtoMessage() {} func (*CallStackNode) ProtoMessage() {} +func (*CollapseConfigEntry) ProtoMessage() {} + +func (*CollapseConfiguration) ProtoMessage() {} + +func (*CollapseConfigurationList) ProtoMessage() {} + +func (*CollapseConfigurationSpec) ProtoMessage() {} + func (*Component) ProtoMessage() {} func (*Condition) ProtoMessage() {} diff --git a/pkg/apis/softwarecomposition/v1beta1/register.go b/pkg/apis/softwarecomposition/v1beta1/register.go index 808ef81ef..193896cba 100644 --- a/pkg/apis/softwarecomposition/v1beta1/register.go +++ b/pkg/apis/softwarecomposition/v1beta1/register.go @@ -79,6 +79,8 @@ func addKnownTypes(scheme *runtime.Scheme) error { &SBOMSyftFilteredList{}, &SeccompProfile{}, &SeccompProfileList{}, + &CollapseConfiguration{}, + &CollapseConfigurationList{}, ) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) return nil diff --git a/pkg/apis/softwarecomposition/v1beta1/zz_generated.conversion.go b/pkg/apis/softwarecomposition/v1beta1/zz_generated.conversion.go index d047c41a0..a1b7c6e71 100644 --- a/pkg/apis/softwarecomposition/v1beta1/zz_generated.conversion.go +++ b/pkg/apis/softwarecomposition/v1beta1/zz_generated.conversion.go @@ -141,6 +141,46 @@ func RegisterConversions(s *runtime.Scheme) error { }); err != nil { return err } + if err := s.AddGeneratedConversionFunc((*CollapseConfigEntry)(nil), (*softwarecomposition.CollapseConfigEntry)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_v1beta1_CollapseConfigEntry_To_softwarecomposition_CollapseConfigEntry(a.(*CollapseConfigEntry), b.(*softwarecomposition.CollapseConfigEntry), scope) + }); err != nil { + return err + } + if err := s.AddGeneratedConversionFunc((*softwarecomposition.CollapseConfigEntry)(nil), (*CollapseConfigEntry)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_softwarecomposition_CollapseConfigEntry_To_v1beta1_CollapseConfigEntry(a.(*softwarecomposition.CollapseConfigEntry), b.(*CollapseConfigEntry), scope) + }); err != nil { + return err + } + if err := s.AddGeneratedConversionFunc((*CollapseConfiguration)(nil), (*softwarecomposition.CollapseConfiguration)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_v1beta1_CollapseConfiguration_To_softwarecomposition_CollapseConfiguration(a.(*CollapseConfiguration), b.(*softwarecomposition.CollapseConfiguration), scope) + }); err != nil { + return err + } + if err := s.AddGeneratedConversionFunc((*softwarecomposition.CollapseConfiguration)(nil), (*CollapseConfiguration)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_softwarecomposition_CollapseConfiguration_To_v1beta1_CollapseConfiguration(a.(*softwarecomposition.CollapseConfiguration), b.(*CollapseConfiguration), scope) + }); err != nil { + return err + } + if err := s.AddGeneratedConversionFunc((*CollapseConfigurationList)(nil), (*softwarecomposition.CollapseConfigurationList)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_v1beta1_CollapseConfigurationList_To_softwarecomposition_CollapseConfigurationList(a.(*CollapseConfigurationList), b.(*softwarecomposition.CollapseConfigurationList), scope) + }); err != nil { + return err + } + if err := s.AddGeneratedConversionFunc((*softwarecomposition.CollapseConfigurationList)(nil), (*CollapseConfigurationList)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_softwarecomposition_CollapseConfigurationList_To_v1beta1_CollapseConfigurationList(a.(*softwarecomposition.CollapseConfigurationList), b.(*CollapseConfigurationList), scope) + }); err != nil { + return err + } + if err := s.AddGeneratedConversionFunc((*CollapseConfigurationSpec)(nil), (*softwarecomposition.CollapseConfigurationSpec)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_v1beta1_CollapseConfigurationSpec_To_softwarecomposition_CollapseConfigurationSpec(a.(*CollapseConfigurationSpec), b.(*softwarecomposition.CollapseConfigurationSpec), scope) + }); err != nil { + return err + } + if err := s.AddGeneratedConversionFunc((*softwarecomposition.CollapseConfigurationSpec)(nil), (*CollapseConfigurationSpec)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_softwarecomposition_CollapseConfigurationSpec_To_v1beta1_CollapseConfigurationSpec(a.(*softwarecomposition.CollapseConfigurationSpec), b.(*CollapseConfigurationSpec), scope) + }); err != nil { + return err + } if err := s.AddGeneratedConversionFunc((*Component)(nil), (*softwarecomposition.Component)(nil), func(a, b interface{}, scope conversion.Scope) error { return Convert_v1beta1_Component_To_softwarecomposition_Component(a.(*Component), b.(*softwarecomposition.Component), scope) }); err != nil { @@ -1988,6 +2028,100 @@ func Convert_softwarecomposition_CallStackNode_To_v1beta1_CallStackNode(in *soft return autoConvert_softwarecomposition_CallStackNode_To_v1beta1_CallStackNode(in, out, s) } +func autoConvert_v1beta1_CollapseConfigEntry_To_softwarecomposition_CollapseConfigEntry(in *CollapseConfigEntry, out *softwarecomposition.CollapseConfigEntry, s conversion.Scope) error { + out.Prefix = in.Prefix + out.Threshold = in.Threshold + return nil +} + +// Convert_v1beta1_CollapseConfigEntry_To_softwarecomposition_CollapseConfigEntry is an autogenerated conversion function. +func Convert_v1beta1_CollapseConfigEntry_To_softwarecomposition_CollapseConfigEntry(in *CollapseConfigEntry, out *softwarecomposition.CollapseConfigEntry, s conversion.Scope) error { + return autoConvert_v1beta1_CollapseConfigEntry_To_softwarecomposition_CollapseConfigEntry(in, out, s) +} + +func autoConvert_softwarecomposition_CollapseConfigEntry_To_v1beta1_CollapseConfigEntry(in *softwarecomposition.CollapseConfigEntry, out *CollapseConfigEntry, s conversion.Scope) error { + out.Prefix = in.Prefix + out.Threshold = in.Threshold + return nil +} + +// Convert_softwarecomposition_CollapseConfigEntry_To_v1beta1_CollapseConfigEntry is an autogenerated conversion function. +func Convert_softwarecomposition_CollapseConfigEntry_To_v1beta1_CollapseConfigEntry(in *softwarecomposition.CollapseConfigEntry, out *CollapseConfigEntry, s conversion.Scope) error { + return autoConvert_softwarecomposition_CollapseConfigEntry_To_v1beta1_CollapseConfigEntry(in, out, s) +} + +func autoConvert_v1beta1_CollapseConfiguration_To_softwarecomposition_CollapseConfiguration(in *CollapseConfiguration, out *softwarecomposition.CollapseConfiguration, s conversion.Scope) error { + out.ObjectMeta = in.ObjectMeta + if err := Convert_v1beta1_CollapseConfigurationSpec_To_softwarecomposition_CollapseConfigurationSpec(&in.Spec, &out.Spec, s); err != nil { + return err + } + return nil +} + +// Convert_v1beta1_CollapseConfiguration_To_softwarecomposition_CollapseConfiguration is an autogenerated conversion function. +func Convert_v1beta1_CollapseConfiguration_To_softwarecomposition_CollapseConfiguration(in *CollapseConfiguration, out *softwarecomposition.CollapseConfiguration, s conversion.Scope) error { + return autoConvert_v1beta1_CollapseConfiguration_To_softwarecomposition_CollapseConfiguration(in, out, s) +} + +func autoConvert_softwarecomposition_CollapseConfiguration_To_v1beta1_CollapseConfiguration(in *softwarecomposition.CollapseConfiguration, out *CollapseConfiguration, s conversion.Scope) error { + out.ObjectMeta = in.ObjectMeta + if err := Convert_softwarecomposition_CollapseConfigurationSpec_To_v1beta1_CollapseConfigurationSpec(&in.Spec, &out.Spec, s); err != nil { + return err + } + return nil +} + +// Convert_softwarecomposition_CollapseConfiguration_To_v1beta1_CollapseConfiguration is an autogenerated conversion function. +func Convert_softwarecomposition_CollapseConfiguration_To_v1beta1_CollapseConfiguration(in *softwarecomposition.CollapseConfiguration, out *CollapseConfiguration, s conversion.Scope) error { + return autoConvert_softwarecomposition_CollapseConfiguration_To_v1beta1_CollapseConfiguration(in, out, s) +} + +func autoConvert_v1beta1_CollapseConfigurationList_To_softwarecomposition_CollapseConfigurationList(in *CollapseConfigurationList, out *softwarecomposition.CollapseConfigurationList, s conversion.Scope) error { + out.ListMeta = in.ListMeta + out.Items = *(*[]softwarecomposition.CollapseConfiguration)(unsafe.Pointer(&in.Items)) + return nil +} + +// Convert_v1beta1_CollapseConfigurationList_To_softwarecomposition_CollapseConfigurationList is an autogenerated conversion function. +func Convert_v1beta1_CollapseConfigurationList_To_softwarecomposition_CollapseConfigurationList(in *CollapseConfigurationList, out *softwarecomposition.CollapseConfigurationList, s conversion.Scope) error { + return autoConvert_v1beta1_CollapseConfigurationList_To_softwarecomposition_CollapseConfigurationList(in, out, s) +} + +func autoConvert_softwarecomposition_CollapseConfigurationList_To_v1beta1_CollapseConfigurationList(in *softwarecomposition.CollapseConfigurationList, out *CollapseConfigurationList, s conversion.Scope) error { + out.ListMeta = in.ListMeta + out.Items = *(*[]CollapseConfiguration)(unsafe.Pointer(&in.Items)) + return nil +} + +// Convert_softwarecomposition_CollapseConfigurationList_To_v1beta1_CollapseConfigurationList is an autogenerated conversion function. +func Convert_softwarecomposition_CollapseConfigurationList_To_v1beta1_CollapseConfigurationList(in *softwarecomposition.CollapseConfigurationList, out *CollapseConfigurationList, s conversion.Scope) error { + return autoConvert_softwarecomposition_CollapseConfigurationList_To_v1beta1_CollapseConfigurationList(in, out, s) +} + +func autoConvert_v1beta1_CollapseConfigurationSpec_To_softwarecomposition_CollapseConfigurationSpec(in *CollapseConfigurationSpec, out *softwarecomposition.CollapseConfigurationSpec, s conversion.Scope) error { + out.OpenDynamicThreshold = in.OpenDynamicThreshold + out.EndpointDynamicThreshold = in.EndpointDynamicThreshold + out.CollapseConfigs = *(*[]softwarecomposition.CollapseConfigEntry)(unsafe.Pointer(&in.CollapseConfigs)) + return nil +} + +// Convert_v1beta1_CollapseConfigurationSpec_To_softwarecomposition_CollapseConfigurationSpec is an autogenerated conversion function. +func Convert_v1beta1_CollapseConfigurationSpec_To_softwarecomposition_CollapseConfigurationSpec(in *CollapseConfigurationSpec, out *softwarecomposition.CollapseConfigurationSpec, s conversion.Scope) error { + return autoConvert_v1beta1_CollapseConfigurationSpec_To_softwarecomposition_CollapseConfigurationSpec(in, out, s) +} + +func autoConvert_softwarecomposition_CollapseConfigurationSpec_To_v1beta1_CollapseConfigurationSpec(in *softwarecomposition.CollapseConfigurationSpec, out *CollapseConfigurationSpec, s conversion.Scope) error { + out.OpenDynamicThreshold = in.OpenDynamicThreshold + out.EndpointDynamicThreshold = in.EndpointDynamicThreshold + out.CollapseConfigs = *(*[]CollapseConfigEntry)(unsafe.Pointer(&in.CollapseConfigs)) + return nil +} + +// Convert_softwarecomposition_CollapseConfigurationSpec_To_v1beta1_CollapseConfigurationSpec is an autogenerated conversion function. +func Convert_softwarecomposition_CollapseConfigurationSpec_To_v1beta1_CollapseConfigurationSpec(in *softwarecomposition.CollapseConfigurationSpec, out *CollapseConfigurationSpec, s conversion.Scope) error { + return autoConvert_softwarecomposition_CollapseConfigurationSpec_To_v1beta1_CollapseConfigurationSpec(in, out, s) +} + func autoConvert_v1beta1_Component_To_softwarecomposition_Component(in *Component, out *softwarecomposition.Component, s conversion.Scope) error { out.ID = in.ID out.Hashes = *(*map[softwarecomposition.Algorithm]softwarecomposition.Hash)(unsafe.Pointer(&in.Hashes)) @@ -3667,6 +3801,7 @@ func autoConvert_v1beta1_NetworkNeighbor_To_softwarecomposition_NetworkNeighbor( out.PodSelector = (*metav1.LabelSelector)(unsafe.Pointer(in.PodSelector)) out.NamespaceSelector = (*metav1.LabelSelector)(unsafe.Pointer(in.NamespaceSelector)) out.IPAddress = in.IPAddress + out.IPAddresses = *(*[]string)(unsafe.Pointer(&in.IPAddresses)) return nil } diff --git a/pkg/apis/softwarecomposition/v1beta1/zz_generated.deepcopy.go b/pkg/apis/softwarecomposition/v1beta1/zz_generated.deepcopy.go index 031141250..fc71852b5 100644 --- a/pkg/apis/softwarecomposition/v1beta1/zz_generated.deepcopy.go +++ b/pkg/apis/softwarecomposition/v1beta1/zz_generated.deepcopy.go @@ -319,6 +319,103 @@ func (in *CallStackNode) DeepCopy() *CallStackNode { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CollapseConfigEntry) DeepCopyInto(out *CollapseConfigEntry) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CollapseConfigEntry. +func (in *CollapseConfigEntry) DeepCopy() *CollapseConfigEntry { + if in == nil { + return nil + } + out := new(CollapseConfigEntry) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CollapseConfiguration) DeepCopyInto(out *CollapseConfiguration) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CollapseConfiguration. +func (in *CollapseConfiguration) DeepCopy() *CollapseConfiguration { + if in == nil { + return nil + } + out := new(CollapseConfiguration) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CollapseConfiguration) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CollapseConfigurationList) DeepCopyInto(out *CollapseConfigurationList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]CollapseConfiguration, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CollapseConfigurationList. +func (in *CollapseConfigurationList) DeepCopy() *CollapseConfigurationList { + if in == nil { + return nil + } + out := new(CollapseConfigurationList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CollapseConfigurationList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CollapseConfigurationSpec) DeepCopyInto(out *CollapseConfigurationSpec) { + *out = *in + if in.CollapseConfigs != nil { + in, out := &in.CollapseConfigs, &out.CollapseConfigs + *out = make([]CollapseConfigEntry, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CollapseConfigurationSpec. +func (in *CollapseConfigurationSpec) DeepCopy() *CollapseConfigurationSpec { + if in == nil { + return nil + } + out := new(CollapseConfigurationSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Component) DeepCopyInto(out *Component) { *out = *in diff --git a/pkg/apis/softwarecomposition/v1beta1/zz_generated.model_name.go b/pkg/apis/softwarecomposition/v1beta1/zz_generated.model_name.go index 73b7c8c3b..394d16b65 100644 --- a/pkg/apis/softwarecomposition/v1beta1/zz_generated.model_name.go +++ b/pkg/apis/softwarecomposition/v1beta1/zz_generated.model_name.go @@ -71,6 +71,26 @@ func (in CallStackNode) OpenAPIModelName() string { return "com.github.kubescape.storage.pkg.apis.softwarecomposition.v1beta1.CallStackNode" } +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in CollapseConfigEntry) OpenAPIModelName() string { + return "com.github.kubescape.storage.pkg.apis.softwarecomposition.v1beta1.CollapseConfigEntry" +} + +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in CollapseConfiguration) OpenAPIModelName() string { + return "com.github.kubescape.storage.pkg.apis.softwarecomposition.v1beta1.CollapseConfiguration" +} + +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in CollapseConfigurationList) OpenAPIModelName() string { + return "com.github.kubescape.storage.pkg.apis.softwarecomposition.v1beta1.CollapseConfigurationList" +} + +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in CollapseConfigurationSpec) OpenAPIModelName() string { + return "com.github.kubescape.storage.pkg.apis.softwarecomposition.v1beta1.CollapseConfigurationSpec" +} + // OpenAPIModelName returns the OpenAPI model name for this type. func (in Component) OpenAPIModelName() string { return "com.github.kubescape.storage.pkg.apis.softwarecomposition.v1beta1.Component" diff --git a/pkg/apis/softwarecomposition/zz_generated.deepcopy.go b/pkg/apis/softwarecomposition/zz_generated.deepcopy.go index 461b514c4..3176c2c13 100644 --- a/pkg/apis/softwarecomposition/zz_generated.deepcopy.go +++ b/pkg/apis/softwarecomposition/zz_generated.deepcopy.go @@ -326,6 +326,103 @@ func (in *CallStackNode) DeepCopy() *CallStackNode { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CollapseConfigEntry) DeepCopyInto(out *CollapseConfigEntry) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CollapseConfigEntry. +func (in *CollapseConfigEntry) DeepCopy() *CollapseConfigEntry { + if in == nil { + return nil + } + out := new(CollapseConfigEntry) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CollapseConfiguration) DeepCopyInto(out *CollapseConfiguration) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CollapseConfiguration. +func (in *CollapseConfiguration) DeepCopy() *CollapseConfiguration { + if in == nil { + return nil + } + out := new(CollapseConfiguration) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CollapseConfiguration) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CollapseConfigurationList) DeepCopyInto(out *CollapseConfigurationList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]CollapseConfiguration, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CollapseConfigurationList. +func (in *CollapseConfigurationList) DeepCopy() *CollapseConfigurationList { + if in == nil { + return nil + } + out := new(CollapseConfigurationList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CollapseConfigurationList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CollapseConfigurationSpec) DeepCopyInto(out *CollapseConfigurationSpec) { + *out = *in + if in.CollapseConfigs != nil { + in, out := &in.CollapseConfigs, &out.CollapseConfigs + *out = make([]CollapseConfigEntry, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CollapseConfigurationSpec. +func (in *CollapseConfigurationSpec) DeepCopy() *CollapseConfigurationSpec { + if in == nil { + return nil + } + out := new(CollapseConfigurationSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Component) DeepCopyInto(out *Component) { *out = *in diff --git a/pkg/apiserver/apiserver.go b/pkg/apiserver/apiserver.go index b043532ce..0a463c3b9 100644 --- a/pkg/apiserver/apiserver.go +++ b/pkg/apiserver/apiserver.go @@ -24,6 +24,7 @@ import ( sbomregistry "github.com/kubescape/storage/pkg/registry" "github.com/kubescape/storage/pkg/registry/file" "github.com/kubescape/storage/pkg/registry/softwarecomposition/applicationprofile" + "github.com/kubescape/storage/pkg/registry/softwarecomposition/collapseconfiguration" "github.com/kubescape/storage/pkg/registry/softwarecomposition/configurationscansummary" "github.com/kubescape/storage/pkg/registry/softwarecomposition/containerprofile" "github.com/kubescape/storage/pkg/registry/softwarecomposition/generatednetworkpolicy" @@ -160,6 +161,7 @@ func (c completedConfig) New() (*WardleServer, error) { ) apiGroupInfo.VersionedResourcesStorageMap["v1beta1"] = map[string]rest.Storage{ "applicationprofiles": ep(applicationprofile.NewREST, applicationProfileStorageImpl), + "collapseconfigurations": ep(collapseconfiguration.NewREST), "configurationscansummaries": ep(configurationscansummary.NewREST, configScanStorageImpl), "containerprofiles": ep(containerprofile.NewREST, containerProfileStorageImpl), "generatednetworkpolicies": ep(generatednetworkpolicy.NewREST, generatedNetworkPolicyStorage), diff --git a/pkg/generated/applyconfiguration/softwarecomposition/v1beta1/collapseconfigentry.go b/pkg/generated/applyconfiguration/softwarecomposition/v1beta1/collapseconfigentry.go new file mode 100644 index 000000000..55cf5aed7 --- /dev/null +++ b/pkg/generated/applyconfiguration/softwarecomposition/v1beta1/collapseconfigentry.go @@ -0,0 +1,54 @@ +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1beta1 + +// CollapseConfigEntryApplyConfiguration represents a declarative configuration of the CollapseConfigEntry type for use +// with apply. +// +// CollapseConfigEntry is one per-prefix threshold override. +type CollapseConfigEntryApplyConfiguration struct { + // Prefix is the path prefix to match (e.g. "/etc", "/opt"). + Prefix *string `json:"prefix,omitempty"` + // Threshold is the maximum number of unique children allowed at any + // trie node under Prefix before that node collapses to a single + // dynamic identifier. + Threshold *int32 `json:"threshold,omitempty"` +} + +// CollapseConfigEntryApplyConfiguration constructs a declarative configuration of the CollapseConfigEntry type for use with +// apply. +func CollapseConfigEntry() *CollapseConfigEntryApplyConfiguration { + return &CollapseConfigEntryApplyConfiguration{} +} + +// WithPrefix sets the Prefix field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Prefix field is set to the value of the last call. +func (b *CollapseConfigEntryApplyConfiguration) WithPrefix(value string) *CollapseConfigEntryApplyConfiguration { + b.Prefix = &value + return b +} + +// WithThreshold sets the Threshold field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Threshold field is set to the value of the last call. +func (b *CollapseConfigEntryApplyConfiguration) WithThreshold(value int32) *CollapseConfigEntryApplyConfiguration { + b.Threshold = &value + return b +} diff --git a/pkg/generated/applyconfiguration/softwarecomposition/v1beta1/collapseconfiguration.go b/pkg/generated/applyconfiguration/softwarecomposition/v1beta1/collapseconfiguration.go new file mode 100644 index 000000000..bfc45dbc6 --- /dev/null +++ b/pkg/generated/applyconfiguration/softwarecomposition/v1beta1/collapseconfiguration.go @@ -0,0 +1,238 @@ +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1beta1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + v1 "k8s.io/client-go/applyconfigurations/meta/v1" +) + +// CollapseConfigurationApplyConfiguration represents a declarative configuration of the CollapseConfiguration type for use +// with apply. +// +// CollapseConfiguration is a cluster-scoped resource carrying per-prefix +// thresholds for the dynamic-path-detector's open/endpoint collapse step. +// The storage server's deflate path reads the singleton (name "default") +// and feeds its entries into NewPathAnalyzerWithConfigs at runtime. +type CollapseConfigurationApplyConfiguration struct { + v1.TypeMetaApplyConfiguration `json:",inline"` + *v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"` + Spec *CollapseConfigurationSpecApplyConfiguration `json:"spec,omitempty"` +} + +// CollapseConfiguration constructs a declarative configuration of the CollapseConfiguration type for use with +// apply. +func CollapseConfiguration(name string) *CollapseConfigurationApplyConfiguration { + b := &CollapseConfigurationApplyConfiguration{} + b.WithName(name) + b.WithKind("CollapseConfiguration") + b.WithAPIVersion("spdx.softwarecomposition.kubescape.io/v1beta1") + return b +} + +func (b CollapseConfigurationApplyConfiguration) IsApplyConfiguration() {} + +// WithKind sets the Kind field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Kind field is set to the value of the last call. +func (b *CollapseConfigurationApplyConfiguration) WithKind(value string) *CollapseConfigurationApplyConfiguration { + b.TypeMetaApplyConfiguration.Kind = &value + return b +} + +// WithAPIVersion sets the APIVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the APIVersion field is set to the value of the last call. +func (b *CollapseConfigurationApplyConfiguration) WithAPIVersion(value string) *CollapseConfigurationApplyConfiguration { + b.TypeMetaApplyConfiguration.APIVersion = &value + return b +} + +// WithName sets the Name field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Name field is set to the value of the last call. +func (b *CollapseConfigurationApplyConfiguration) WithName(value string) *CollapseConfigurationApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Name = &value + return b +} + +// WithGenerateName sets the GenerateName field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the GenerateName field is set to the value of the last call. +func (b *CollapseConfigurationApplyConfiguration) WithGenerateName(value string) *CollapseConfigurationApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.GenerateName = &value + return b +} + +// WithNamespace sets the Namespace field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Namespace field is set to the value of the last call. +func (b *CollapseConfigurationApplyConfiguration) WithNamespace(value string) *CollapseConfigurationApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Namespace = &value + return b +} + +// WithUID sets the UID field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the UID field is set to the value of the last call. +func (b *CollapseConfigurationApplyConfiguration) WithUID(value types.UID) *CollapseConfigurationApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.UID = &value + return b +} + +// WithResourceVersion sets the ResourceVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ResourceVersion field is set to the value of the last call. +func (b *CollapseConfigurationApplyConfiguration) WithResourceVersion(value string) *CollapseConfigurationApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.ResourceVersion = &value + return b +} + +// WithGeneration sets the Generation field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Generation field is set to the value of the last call. +func (b *CollapseConfigurationApplyConfiguration) WithGeneration(value int64) *CollapseConfigurationApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Generation = &value + return b +} + +// WithCreationTimestamp sets the CreationTimestamp field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the CreationTimestamp field is set to the value of the last call. +func (b *CollapseConfigurationApplyConfiguration) WithCreationTimestamp(value metav1.Time) *CollapseConfigurationApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.CreationTimestamp = &value + return b +} + +// WithDeletionTimestamp sets the DeletionTimestamp field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DeletionTimestamp field is set to the value of the last call. +func (b *CollapseConfigurationApplyConfiguration) WithDeletionTimestamp(value metav1.Time) *CollapseConfigurationApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.DeletionTimestamp = &value + return b +} + +// WithDeletionGracePeriodSeconds sets the DeletionGracePeriodSeconds field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DeletionGracePeriodSeconds field is set to the value of the last call. +func (b *CollapseConfigurationApplyConfiguration) WithDeletionGracePeriodSeconds(value int64) *CollapseConfigurationApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.DeletionGracePeriodSeconds = &value + return b +} + +// WithLabels puts the entries into the Labels field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Labels field, +// overwriting an existing map entries in Labels field with the same key. +func (b *CollapseConfigurationApplyConfiguration) WithLabels(entries map[string]string) *CollapseConfigurationApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.ObjectMetaApplyConfiguration.Labels == nil && len(entries) > 0 { + b.ObjectMetaApplyConfiguration.Labels = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.ObjectMetaApplyConfiguration.Labels[k] = v + } + return b +} + +// WithAnnotations puts the entries into the Annotations field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Annotations field, +// overwriting an existing map entries in Annotations field with the same key. +func (b *CollapseConfigurationApplyConfiguration) WithAnnotations(entries map[string]string) *CollapseConfigurationApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.ObjectMetaApplyConfiguration.Annotations == nil && len(entries) > 0 { + b.ObjectMetaApplyConfiguration.Annotations = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.ObjectMetaApplyConfiguration.Annotations[k] = v + } + return b +} + +// WithOwnerReferences adds the given value to the OwnerReferences field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the OwnerReferences field. +func (b *CollapseConfigurationApplyConfiguration) WithOwnerReferences(values ...*v1.OwnerReferenceApplyConfiguration) *CollapseConfigurationApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + if values[i] == nil { + panic("nil value passed to WithOwnerReferences") + } + b.ObjectMetaApplyConfiguration.OwnerReferences = append(b.ObjectMetaApplyConfiguration.OwnerReferences, *values[i]) + } + return b +} + +// WithFinalizers adds the given value to the Finalizers field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Finalizers field. +func (b *CollapseConfigurationApplyConfiguration) WithFinalizers(values ...string) *CollapseConfigurationApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + b.ObjectMetaApplyConfiguration.Finalizers = append(b.ObjectMetaApplyConfiguration.Finalizers, values[i]) + } + return b +} + +func (b *CollapseConfigurationApplyConfiguration) ensureObjectMetaApplyConfigurationExists() { + if b.ObjectMetaApplyConfiguration == nil { + b.ObjectMetaApplyConfiguration = &v1.ObjectMetaApplyConfiguration{} + } +} + +// WithSpec sets the Spec field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Spec field is set to the value of the last call. +func (b *CollapseConfigurationApplyConfiguration) WithSpec(value *CollapseConfigurationSpecApplyConfiguration) *CollapseConfigurationApplyConfiguration { + b.Spec = value + return b +} + +// GetKind retrieves the value of the Kind field in the declarative configuration. +func (b *CollapseConfigurationApplyConfiguration) GetKind() *string { + return b.TypeMetaApplyConfiguration.Kind +} + +// GetAPIVersion retrieves the value of the APIVersion field in the declarative configuration. +func (b *CollapseConfigurationApplyConfiguration) GetAPIVersion() *string { + return b.TypeMetaApplyConfiguration.APIVersion +} + +// GetName retrieves the value of the Name field in the declarative configuration. +func (b *CollapseConfigurationApplyConfiguration) GetName() *string { + b.ensureObjectMetaApplyConfigurationExists() + return b.ObjectMetaApplyConfiguration.Name +} + +// GetNamespace retrieves the value of the Namespace field in the declarative configuration. +func (b *CollapseConfigurationApplyConfiguration) GetNamespace() *string { + b.ensureObjectMetaApplyConfigurationExists() + return b.ObjectMetaApplyConfiguration.Namespace +} diff --git a/pkg/generated/applyconfiguration/softwarecomposition/v1beta1/collapseconfigurationspec.go b/pkg/generated/applyconfiguration/softwarecomposition/v1beta1/collapseconfigurationspec.go new file mode 100644 index 000000000..45974767b --- /dev/null +++ b/pkg/generated/applyconfiguration/softwarecomposition/v1beta1/collapseconfigurationspec.go @@ -0,0 +1,70 @@ +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1beta1 + +// CollapseConfigurationSpecApplyConfiguration represents a declarative configuration of the CollapseConfigurationSpec type for use +// with apply. +// +// CollapseConfigurationSpec carries the cluster-wide collapse thresholds. +type CollapseConfigurationSpecApplyConfiguration struct { + // OpenDynamicThreshold is the fallback threshold for AnalyzeOpens when + // no per-prefix entry matches the walked path. + OpenDynamicThreshold *int32 `json:"openDynamicThreshold,omitempty"` + // EndpointDynamicThreshold is the counterpart for AnalyzeEndpoints. + EndpointDynamicThreshold *int32 `json:"endpointDynamicThreshold,omitempty"` + // CollapseConfigs is the per-prefix threshold override list, evaluated + // longest-prefix-wins. Each entry is keyed by Prefix so server-side + // apply patches one entry at a time instead of replacing the slice. + CollapseConfigs []CollapseConfigEntryApplyConfiguration `json:"collapseConfigs,omitempty"` +} + +// CollapseConfigurationSpecApplyConfiguration constructs a declarative configuration of the CollapseConfigurationSpec type for use with +// apply. +func CollapseConfigurationSpec() *CollapseConfigurationSpecApplyConfiguration { + return &CollapseConfigurationSpecApplyConfiguration{} +} + +// WithOpenDynamicThreshold sets the OpenDynamicThreshold field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the OpenDynamicThreshold field is set to the value of the last call. +func (b *CollapseConfigurationSpecApplyConfiguration) WithOpenDynamicThreshold(value int32) *CollapseConfigurationSpecApplyConfiguration { + b.OpenDynamicThreshold = &value + return b +} + +// WithEndpointDynamicThreshold sets the EndpointDynamicThreshold field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the EndpointDynamicThreshold field is set to the value of the last call. +func (b *CollapseConfigurationSpecApplyConfiguration) WithEndpointDynamicThreshold(value int32) *CollapseConfigurationSpecApplyConfiguration { + b.EndpointDynamicThreshold = &value + return b +} + +// WithCollapseConfigs adds the given value to the CollapseConfigs field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the CollapseConfigs field. +func (b *CollapseConfigurationSpecApplyConfiguration) WithCollapseConfigs(values ...*CollapseConfigEntryApplyConfiguration) *CollapseConfigurationSpecApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithCollapseConfigs") + } + b.CollapseConfigs = append(b.CollapseConfigs, *values[i]) + } + return b +} diff --git a/pkg/generated/applyconfiguration/utils.go b/pkg/generated/applyconfiguration/utils.go index d6c6b37e7..bf88e2b57 100644 --- a/pkg/generated/applyconfiguration/utils.go +++ b/pkg/generated/applyconfiguration/utils.go @@ -46,6 +46,12 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &softwarecompositionv1beta1.CallStackApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("CallStackNode"): return &softwarecompositionv1beta1.CallStackNodeApplyConfiguration{} + case v1beta1.SchemeGroupVersion.WithKind("CollapseConfigEntry"): + return &softwarecompositionv1beta1.CollapseConfigEntryApplyConfiguration{} + case v1beta1.SchemeGroupVersion.WithKind("CollapseConfiguration"): + return &softwarecompositionv1beta1.CollapseConfigurationApplyConfiguration{} + case v1beta1.SchemeGroupVersion.WithKind("CollapseConfigurationSpec"): + return &softwarecompositionv1beta1.CollapseConfigurationSpecApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("Component"): return &softwarecompositionv1beta1.ComponentApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("Condition"): diff --git a/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1/collapseconfiguration.go b/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1/collapseconfiguration.go new file mode 100644 index 000000000..711d1bcc9 --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1/collapseconfiguration.go @@ -0,0 +1,74 @@ +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package v1beta1 + +import ( + context "context" + + softwarecompositionv1beta1 "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + applyconfigurationsoftwarecompositionv1beta1 "github.com/kubescape/storage/pkg/generated/applyconfiguration/softwarecomposition/v1beta1" + scheme "github.com/kubescape/storage/pkg/generated/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + gentype "k8s.io/client-go/gentype" +) + +// CollapseConfigurationsGetter has a method to return a CollapseConfigurationInterface. +// A group's client should implement this interface. +type CollapseConfigurationsGetter interface { + CollapseConfigurations() CollapseConfigurationInterface +} + +// CollapseConfigurationInterface has methods to work with CollapseConfiguration resources. +type CollapseConfigurationInterface interface { + Create(ctx context.Context, collapseConfiguration *softwarecompositionv1beta1.CollapseConfiguration, opts v1.CreateOptions) (*softwarecompositionv1beta1.CollapseConfiguration, error) + Update(ctx context.Context, collapseConfiguration *softwarecompositionv1beta1.CollapseConfiguration, opts v1.UpdateOptions) (*softwarecompositionv1beta1.CollapseConfiguration, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*softwarecompositionv1beta1.CollapseConfiguration, error) + List(ctx context.Context, opts v1.ListOptions) (*softwarecompositionv1beta1.CollapseConfigurationList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *softwarecompositionv1beta1.CollapseConfiguration, err error) + Apply(ctx context.Context, collapseConfiguration *applyconfigurationsoftwarecompositionv1beta1.CollapseConfigurationApplyConfiguration, opts v1.ApplyOptions) (result *softwarecompositionv1beta1.CollapseConfiguration, err error) + CollapseConfigurationExpansion +} + +// collapseConfigurations implements CollapseConfigurationInterface +type collapseConfigurations struct { + *gentype.ClientWithListAndApply[*softwarecompositionv1beta1.CollapseConfiguration, *softwarecompositionv1beta1.CollapseConfigurationList, *applyconfigurationsoftwarecompositionv1beta1.CollapseConfigurationApplyConfiguration] +} + +// newCollapseConfigurations returns a CollapseConfigurations +func newCollapseConfigurations(c *SpdxV1beta1Client) *collapseConfigurations { + return &collapseConfigurations{ + gentype.NewClientWithListAndApply[*softwarecompositionv1beta1.CollapseConfiguration, *softwarecompositionv1beta1.CollapseConfigurationList, *applyconfigurationsoftwarecompositionv1beta1.CollapseConfigurationApplyConfiguration]( + "collapseconfigurations", + c.RESTClient(), + scheme.ParameterCodec, + "", + func() *softwarecompositionv1beta1.CollapseConfiguration { + return &softwarecompositionv1beta1.CollapseConfiguration{} + }, + func() *softwarecompositionv1beta1.CollapseConfigurationList { + return &softwarecompositionv1beta1.CollapseConfigurationList{} + }, + ), + } +} diff --git a/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1/fake/fake_collapseconfiguration.go b/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1/fake/fake_collapseconfiguration.go new file mode 100644 index 000000000..29b3746b6 --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1/fake/fake_collapseconfiguration.go @@ -0,0 +1,53 @@ +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1beta1 "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + softwarecompositionv1beta1 "github.com/kubescape/storage/pkg/generated/applyconfiguration/softwarecomposition/v1beta1" + typedsoftwarecompositionv1beta1 "github.com/kubescape/storage/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1" + gentype "k8s.io/client-go/gentype" +) + +// fakeCollapseConfigurations implements CollapseConfigurationInterface +type fakeCollapseConfigurations struct { + *gentype.FakeClientWithListAndApply[*v1beta1.CollapseConfiguration, *v1beta1.CollapseConfigurationList, *softwarecompositionv1beta1.CollapseConfigurationApplyConfiguration] + Fake *FakeSpdxV1beta1 +} + +func newFakeCollapseConfigurations(fake *FakeSpdxV1beta1) typedsoftwarecompositionv1beta1.CollapseConfigurationInterface { + return &fakeCollapseConfigurations{ + gentype.NewFakeClientWithListAndApply[*v1beta1.CollapseConfiguration, *v1beta1.CollapseConfigurationList, *softwarecompositionv1beta1.CollapseConfigurationApplyConfiguration]( + fake.Fake, + "", + v1beta1.SchemeGroupVersion.WithResource("collapseconfigurations"), + v1beta1.SchemeGroupVersion.WithKind("CollapseConfiguration"), + func() *v1beta1.CollapseConfiguration { return &v1beta1.CollapseConfiguration{} }, + func() *v1beta1.CollapseConfigurationList { return &v1beta1.CollapseConfigurationList{} }, + func(dst, src *v1beta1.CollapseConfigurationList) { dst.ListMeta = src.ListMeta }, + func(list *v1beta1.CollapseConfigurationList) []*v1beta1.CollapseConfiguration { + return gentype.ToPointerSlice(list.Items) + }, + func(list *v1beta1.CollapseConfigurationList, items []*v1beta1.CollapseConfiguration) { + list.Items = gentype.FromPointerSlice(items) + }, + ), + fake, + } +} diff --git a/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1/fake/fake_softwarecomposition_client.go b/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1/fake/fake_softwarecomposition_client.go index 6e6dfcd72..090d2f59a 100644 --- a/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1/fake/fake_softwarecomposition_client.go +++ b/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1/fake/fake_softwarecomposition_client.go @@ -32,6 +32,10 @@ func (c *FakeSpdxV1beta1) ApplicationProfiles(namespace string) v1beta1.Applicat return newFakeApplicationProfiles(c, namespace) } +func (c *FakeSpdxV1beta1) CollapseConfigurations() v1beta1.CollapseConfigurationInterface { + return newFakeCollapseConfigurations(c) +} + func (c *FakeSpdxV1beta1) ConfigurationScanSummaries(namespace string) v1beta1.ConfigurationScanSummaryInterface { return newFakeConfigurationScanSummaries(c, namespace) } diff --git a/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1/generated_expansion.go b/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1/generated_expansion.go index 4b567f16e..d53465a69 100644 --- a/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1/generated_expansion.go +++ b/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1/generated_expansion.go @@ -20,6 +20,8 @@ package v1beta1 type ApplicationProfileExpansion interface{} +type CollapseConfigurationExpansion interface{} + type ConfigurationScanSummaryExpansion interface{} type ContainerProfileExpansion interface{} diff --git a/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1/softwarecomposition_client.go b/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1/softwarecomposition_client.go index 3ead4172c..2eeee8013 100644 --- a/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1/softwarecomposition_client.go +++ b/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1/softwarecomposition_client.go @@ -29,6 +29,7 @@ import ( type SpdxV1beta1Interface interface { RESTClient() rest.Interface ApplicationProfilesGetter + CollapseConfigurationsGetter ConfigurationScanSummariesGetter ContainerProfilesGetter GeneratedNetworkPoliciesGetter @@ -54,6 +55,10 @@ func (c *SpdxV1beta1Client) ApplicationProfiles(namespace string) ApplicationPro return newApplicationProfiles(c, namespace) } +func (c *SpdxV1beta1Client) CollapseConfigurations() CollapseConfigurationInterface { + return newCollapseConfigurations(c) +} + func (c *SpdxV1beta1Client) ConfigurationScanSummaries(namespace string) ConfigurationScanSummaryInterface { return newConfigurationScanSummaries(c, namespace) } diff --git a/pkg/generated/informers/externalversions/generic.go b/pkg/generated/informers/externalversions/generic.go index 74746d948..935aebaa9 100644 --- a/pkg/generated/informers/externalversions/generic.go +++ b/pkg/generated/informers/externalversions/generic.go @@ -55,6 +55,8 @@ func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource // Group=spdx.softwarecomposition.kubescape.io, Version=v1beta1 case v1beta1.SchemeGroupVersion.WithResource("applicationprofiles"): return &genericInformer{resource: resource.GroupResource(), informer: f.Spdx().V1beta1().ApplicationProfiles().Informer()}, nil + case v1beta1.SchemeGroupVersion.WithResource("collapseconfigurations"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Spdx().V1beta1().CollapseConfigurations().Informer()}, nil case v1beta1.SchemeGroupVersion.WithResource("configurationscansummaries"): return &genericInformer{resource: resource.GroupResource(), informer: f.Spdx().V1beta1().ConfigurationScanSummaries().Informer()}, nil case v1beta1.SchemeGroupVersion.WithResource("containerprofiles"): diff --git a/pkg/generated/informers/externalversions/softwarecomposition/v1beta1/collapseconfiguration.go b/pkg/generated/informers/externalversions/softwarecomposition/v1beta1/collapseconfiguration.go new file mode 100644 index 000000000..621949a47 --- /dev/null +++ b/pkg/generated/informers/externalversions/softwarecomposition/v1beta1/collapseconfiguration.go @@ -0,0 +1,101 @@ +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package v1beta1 + +import ( + context "context" + time "time" + + apissoftwarecompositionv1beta1 "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + versioned "github.com/kubescape/storage/pkg/generated/clientset/versioned" + internalinterfaces "github.com/kubescape/storage/pkg/generated/informers/externalversions/internalinterfaces" + softwarecompositionv1beta1 "github.com/kubescape/storage/pkg/generated/listers/softwarecomposition/v1beta1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// CollapseConfigurationInformer provides access to a shared informer and lister for +// CollapseConfigurations. +type CollapseConfigurationInformer interface { + Informer() cache.SharedIndexInformer + Lister() softwarecompositionv1beta1.CollapseConfigurationLister +} + +type collapseConfigurationInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// NewCollapseConfigurationInformer constructs a new informer for CollapseConfiguration type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewCollapseConfigurationInformer(client versioned.Interface, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredCollapseConfigurationInformer(client, resyncPeriod, indexers, nil) +} + +// NewFilteredCollapseConfigurationInformer constructs a new informer for CollapseConfiguration type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredCollapseConfigurationInformer(client versioned.Interface, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + cache.ToListWatcherWithWatchListSemantics(&cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.SpdxV1beta1().CollapseConfigurations().List(context.Background(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.SpdxV1beta1().CollapseConfigurations().Watch(context.Background(), options) + }, + ListWithContextFunc: func(ctx context.Context, options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.SpdxV1beta1().CollapseConfigurations().List(ctx, options) + }, + WatchFuncWithContext: func(ctx context.Context, options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.SpdxV1beta1().CollapseConfigurations().Watch(ctx, options) + }, + }, client), + &apissoftwarecompositionv1beta1.CollapseConfiguration{}, + resyncPeriod, + indexers, + ) +} + +func (f *collapseConfigurationInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredCollapseConfigurationInformer(client, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *collapseConfigurationInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&apissoftwarecompositionv1beta1.CollapseConfiguration{}, f.defaultInformer) +} + +func (f *collapseConfigurationInformer) Lister() softwarecompositionv1beta1.CollapseConfigurationLister { + return softwarecompositionv1beta1.NewCollapseConfigurationLister(f.Informer().GetIndexer()) +} diff --git a/pkg/generated/informers/externalversions/softwarecomposition/v1beta1/interface.go b/pkg/generated/informers/externalversions/softwarecomposition/v1beta1/interface.go index 0bad416a2..cdd3bcc00 100644 --- a/pkg/generated/informers/externalversions/softwarecomposition/v1beta1/interface.go +++ b/pkg/generated/informers/externalversions/softwarecomposition/v1beta1/interface.go @@ -26,6 +26,8 @@ import ( type Interface interface { // ApplicationProfiles returns a ApplicationProfileInformer. ApplicationProfiles() ApplicationProfileInformer + // CollapseConfigurations returns a CollapseConfigurationInformer. + CollapseConfigurations() CollapseConfigurationInformer // ConfigurationScanSummaries returns a ConfigurationScanSummaryInformer. ConfigurationScanSummaries() ConfigurationScanSummaryInformer // ContainerProfiles returns a ContainerProfileInformer. @@ -72,6 +74,11 @@ func (v *version) ApplicationProfiles() ApplicationProfileInformer { return &applicationProfileInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} } +// CollapseConfigurations returns a CollapseConfigurationInformer. +func (v *version) CollapseConfigurations() CollapseConfigurationInformer { + return &collapseConfigurationInformer{factory: v.factory, tweakListOptions: v.tweakListOptions} +} + // ConfigurationScanSummaries returns a ConfigurationScanSummaryInformer. func (v *version) ConfigurationScanSummaries() ConfigurationScanSummaryInformer { return &configurationScanSummaryInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} diff --git a/pkg/generated/listers/softwarecomposition/v1beta1/collapseconfiguration.go b/pkg/generated/listers/softwarecomposition/v1beta1/collapseconfiguration.go new file mode 100644 index 000000000..6ffec1014 --- /dev/null +++ b/pkg/generated/listers/softwarecomposition/v1beta1/collapseconfiguration.go @@ -0,0 +1,48 @@ +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by lister-gen. DO NOT EDIT. + +package v1beta1 + +import ( + softwarecompositionv1beta1 "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + labels "k8s.io/apimachinery/pkg/labels" + listers "k8s.io/client-go/listers" + cache "k8s.io/client-go/tools/cache" +) + +// CollapseConfigurationLister helps list CollapseConfigurations. +// All objects returned here must be treated as read-only. +type CollapseConfigurationLister interface { + // List lists all CollapseConfigurations in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*softwarecompositionv1beta1.CollapseConfiguration, err error) + // Get retrieves the CollapseConfiguration from the index for a given name. + // Objects returned here must be treated as read-only. + Get(name string) (*softwarecompositionv1beta1.CollapseConfiguration, error) + CollapseConfigurationListerExpansion +} + +// collapseConfigurationLister implements the CollapseConfigurationLister interface. +type collapseConfigurationLister struct { + listers.ResourceIndexer[*softwarecompositionv1beta1.CollapseConfiguration] +} + +// NewCollapseConfigurationLister returns a new CollapseConfigurationLister. +func NewCollapseConfigurationLister(indexer cache.Indexer) CollapseConfigurationLister { + return &collapseConfigurationLister{listers.New[*softwarecompositionv1beta1.CollapseConfiguration](indexer, softwarecompositionv1beta1.Resource("collapseconfiguration"))} +} diff --git a/pkg/generated/listers/softwarecomposition/v1beta1/expansion_generated.go b/pkg/generated/listers/softwarecomposition/v1beta1/expansion_generated.go index a8f9e605e..64fe9fc09 100644 --- a/pkg/generated/listers/softwarecomposition/v1beta1/expansion_generated.go +++ b/pkg/generated/listers/softwarecomposition/v1beta1/expansion_generated.go @@ -26,6 +26,10 @@ type ApplicationProfileListerExpansion interface{} // ApplicationProfileNamespaceLister. type ApplicationProfileNamespaceListerExpansion interface{} +// CollapseConfigurationListerExpansion allows custom methods to be added to +// CollapseConfigurationLister. +type CollapseConfigurationListerExpansion interface{} + // ConfigurationScanSummaryListerExpansion allows custom methods to be added to // ConfigurationScanSummaryLister. type ConfigurationScanSummaryListerExpansion interface{} diff --git a/pkg/generated/openapi/zz_generated.openapi.go b/pkg/generated/openapi/zz_generated.openapi.go index efde072a9..a103a3b2a 100644 --- a/pkg/generated/openapi/zz_generated.openapi.go +++ b/pkg/generated/openapi/zz_generated.openapi.go @@ -43,6 +43,10 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA v1beta1.CPE{}.OpenAPIModelName(): schema_pkg_apis_softwarecomposition_v1beta1_CPE(ref), v1beta1.CallStack{}.OpenAPIModelName(): schema_pkg_apis_softwarecomposition_v1beta1_CallStack(ref), v1beta1.CallStackNode{}.OpenAPIModelName(): schema_pkg_apis_softwarecomposition_v1beta1_CallStackNode(ref), + v1beta1.CollapseConfigEntry{}.OpenAPIModelName(): schema_pkg_apis_softwarecomposition_v1beta1_CollapseConfigEntry(ref), + v1beta1.CollapseConfiguration{}.OpenAPIModelName(): schema_pkg_apis_softwarecomposition_v1beta1_CollapseConfiguration(ref), + v1beta1.CollapseConfigurationList{}.OpenAPIModelName(): schema_pkg_apis_softwarecomposition_v1beta1_CollapseConfigurationList(ref), + v1beta1.CollapseConfigurationSpec{}.OpenAPIModelName(): schema_pkg_apis_softwarecomposition_v1beta1_CollapseConfigurationSpec(ref), v1beta1.Component{}.OpenAPIModelName(): schema_pkg_apis_softwarecomposition_v1beta1_Component(ref), v1beta1.Condition{}.OpenAPIModelName(): schema_pkg_apis_softwarecomposition_v1beta1_Condition(ref), v1beta1.ConditionedStatus{}.OpenAPIModelName(): schema_pkg_apis_softwarecomposition_v1beta1_ConditionedStatus(ref), @@ -757,6 +761,181 @@ func schema_pkg_apis_softwarecomposition_v1beta1_CallStackNode(ref common.Refere } } +func schema_pkg_apis_softwarecomposition_v1beta1_CollapseConfigEntry(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "CollapseConfigEntry is one per-prefix threshold override.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "prefix": { + SchemaProps: spec.SchemaProps{ + Description: "Prefix is the path prefix to match (e.g. \"/etc\", \"/opt\").", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "threshold": { + SchemaProps: spec.SchemaProps{ + Description: "Threshold is the maximum number of unique children allowed at any trie node under Prefix before that node collapses to a single dynamic identifier.", + Default: 0, + Type: []string{"integer"}, + Format: "int32", + }, + }, + }, + Required: []string{"prefix", "threshold"}, + }, + }, + } +} + +func schema_pkg_apis_softwarecomposition_v1beta1_CollapseConfiguration(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "CollapseConfiguration is a cluster-scoped resource carrying per-prefix thresholds for the dynamic-path-detector's open/endpoint collapse step. The storage server's deflate path reads the singleton (name \"default\") and feeds its entries into NewPathAnalyzerWithConfigs at runtime.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref(v1.ObjectMeta{}.OpenAPIModelName()), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref(v1beta1.CollapseConfigurationSpec{}.OpenAPIModelName()), + }, + }, + }, + Required: []string{"spec"}, + }, + }, + Dependencies: []string{ + v1beta1.CollapseConfigurationSpec{}.OpenAPIModelName(), v1.ObjectMeta{}.OpenAPIModelName()}, + } +} + +func schema_pkg_apis_softwarecomposition_v1beta1_CollapseConfigurationList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "CollapseConfigurationList is a list of CollapseConfiguration objects.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref(v1.ListMeta{}.OpenAPIModelName()), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref(v1beta1.CollapseConfiguration{}.OpenAPIModelName()), + }, + }, + }, + }, + }, + }, + Required: []string{"items"}, + }, + }, + Dependencies: []string{ + v1beta1.CollapseConfiguration{}.OpenAPIModelName(), v1.ListMeta{}.OpenAPIModelName()}, + } +} + +func schema_pkg_apis_softwarecomposition_v1beta1_CollapseConfigurationSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "CollapseConfigurationSpec carries the cluster-wide collapse thresholds.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "openDynamicThreshold": { + SchemaProps: spec.SchemaProps{ + Description: "OpenDynamicThreshold is the fallback threshold for AnalyzeOpens when no per-prefix entry matches the walked path.", + Default: 0, + Type: []string{"integer"}, + Format: "int32", + }, + }, + "endpointDynamicThreshold": { + SchemaProps: spec.SchemaProps{ + Description: "EndpointDynamicThreshold is the counterpart for AnalyzeEndpoints.", + Default: 0, + Type: []string{"integer"}, + Format: "int32", + }, + }, + "collapseConfigs": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-map-keys": []interface{}{ + "prefix", + }, + "x-kubernetes-list-type": "map", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "CollapseConfigs is the per-prefix threshold override list, evaluated longest-prefix-wins. Each entry is keyed by Prefix so server-side apply patches one entry at a time instead of replacing the slice.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref(v1beta1.CollapseConfigEntry{}.OpenAPIModelName()), + }, + }, + }, + }, + }, + }, + Required: []string{"openDynamicThreshold", "endpointDynamicThreshold"}, + }, + }, + Dependencies: []string{ + v1beta1.CollapseConfigEntry{}.OpenAPIModelName()}, + } +} + func schema_pkg_apis_softwarecomposition_v1beta1_Component(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/pkg/registry/file/applicationprofile_processor.go b/pkg/registry/file/applicationprofile_processor.go index ad19b0817..e37c6ae50 100644 --- a/pkg/registry/file/applicationprofile_processor.go +++ b/pkg/registry/file/applicationprofile_processor.go @@ -17,22 +17,38 @@ import ( "k8s.io/apimachinery/pkg/runtime" ) -const ( - OpenDynamicThreshold = 50 - EndpointDynamicThreshold = 100 -) +// Thresholds are defined in dynamicpathdetector.OpenDynamicThreshold and +// dynamicpathdetector.EndpointDynamicThreshold (single source of truth). type ApplicationProfileProcessor struct { defaultNamespace string maxApplicationProfileSize int storageImpl ContainerProfileStorage + // collapseSettings is the lookup hook the deflate path consults for + // per-prefix thresholds. Defaults to dynamicpathdetector.DefaultCollapseSettings; + // production wiring may override via SetCollapseSettings to a provider that + // reads the cluster-scoped CollapseConfiguration "default" CR. + collapseSettings dynamicpathdetector.CollapseSettingsProvider } func NewApplicationProfileProcessor(cfg config.Config) *ApplicationProfileProcessor { return &ApplicationProfileProcessor{ defaultNamespace: cfg.DefaultNamespace, maxApplicationProfileSize: cfg.MaxApplicationProfileSize, + collapseSettings: dynamicpathdetector.DefaultCollapseSettings, + } +} + +// SetCollapseSettings overrides the provider the deflate path uses to fetch +// effective thresholds. Pass dynamicpathdetector.DefaultCollapseSettings to +// fall back to compiled-in defaults; production wiring passes a provider +// that reads the CollapseConfiguration CR. +func (a *ApplicationProfileProcessor) SetCollapseSettings(p dynamicpathdetector.CollapseSettingsProvider) { + if p == nil { + a.collapseSettings = dynamicpathdetector.DefaultCollapseSettings + return } + a.collapseSettings = p } var _ Processor = (*ApplicationProfileProcessor)(nil) @@ -73,7 +89,7 @@ func (a *ApplicationProfileProcessor) PreSave(ctx context.Context, object runtim } else { logger.L().Debug("failed to get sbom name", loggerhelpers.Error(err), loggerhelpers.String("imageTag", container.ImageTag), loggerhelpers.String("imageID", container.ImageID)) } - containers[i] = deflateApplicationProfileContainer(container, sbomSet) + containers[i] = deflateApplicationProfileContainer(container, sbomSet, a.collapseSettings()) size += len(containers[i].Execs) size += len(containers[i].Opens) size += len(containers[i].Syscalls) @@ -108,13 +124,13 @@ func (a *ApplicationProfileProcessor) SetStorage(containerProfileStorage Contain a.storageImpl = containerProfileStorage } -func deflateApplicationProfileContainer(container softwarecomposition.ApplicationProfileContainer, sbomSet mapset.Set[string]) softwarecomposition.ApplicationProfileContainer { - opens, err := dynamicpathdetector.AnalyzeOpens(container.Opens, dynamicpathdetector.NewPathAnalyzer(OpenDynamicThreshold), sbomSet) +func deflateApplicationProfileContainer(container softwarecomposition.ApplicationProfileContainer, sbomSet mapset.Set[string], settings dynamicpathdetector.CollapseSettings) softwarecomposition.ApplicationProfileContainer { + opens, err := dynamicpathdetector.AnalyzeOpens(container.Opens, dynamicpathdetector.NewPathAnalyzerWithConfigs(settings.OpenDynamicThreshold, settings.CollapseConfigs), sbomSet) if err != nil { logger.L().Debug("falling back to DeflateStringer for opens", loggerhelpers.Error(err)) opens = DeflateStringer(container.Opens) } - endpoints := dynamicpathdetector.AnalyzeEndpoints(&container.Endpoints, dynamicpathdetector.NewPathAnalyzer(EndpointDynamicThreshold)) + endpoints := dynamicpathdetector.AnalyzeEndpoints(&container.Endpoints, dynamicpathdetector.NewPathAnalyzerWithConfigs(settings.EndpointDynamicThreshold, nil)) identifiedCallStacks := callstack.UnifyIdentifiedCallStacks(container.IdentifiedCallStacks) return softwarecomposition.ApplicationProfileContainer{ diff --git a/pkg/registry/file/applicationprofile_processor_collapse_provider_test.go b/pkg/registry/file/applicationprofile_processor_collapse_provider_test.go new file mode 100644 index 000000000..172003fa3 --- /dev/null +++ b/pkg/registry/file/applicationprofile_processor_collapse_provider_test.go @@ -0,0 +1,170 @@ +/* +Copyright 2024 The Kubescape Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package file + +import ( + "fmt" + "testing" + + "github.com/kubescape/storage/pkg/apis/softwarecomposition" + "github.com/kubescape/storage/pkg/config" + "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" + "github.com/stretchr/testify/assert" +) + +// TestApplicationProfileProcessor_DefaultCollapseSettings_Wired pins that +// a freshly-constructed ApplicationProfileProcessor uses the compiled +// defaults — i.e. the deflate path collapses /etc paths at the default +// /etc threshold, not at some accidental zero value. Also pins that +// the constructor wires the provider field (no nil-pointer panic on +// PreSave when the cluster has no CollapseConfiguration CR). +func TestApplicationProfileProcessor_DefaultCollapseSettings_Wired(t *testing.T) { + a := NewApplicationProfileProcessor(config.Config{DefaultNamespace: "kubescape", MaxApplicationProfileSize: 40000}) + assert.NotNil(t, a) + // The provider field should have been initialised — test by deflating + // a small profile and asserting the result has the expected shape. + // We can't directly inspect the unexported field, so we exercise it. + settings := dynamicpathdetector.DefaultCollapseSettings() + require := assertSettingsMatchProcessor(t, a, settings) + _ = require +} + +// TestApplicationProfileProcessor_SetCollapseSettings_NilFallsBack pins +// the defensive nil-handling on the setter — passing a nil provider +// must NOT replace the working default with nil (which would crash on +// PreSave). It must restore the compiled defaults. +func TestApplicationProfileProcessor_SetCollapseSettings_NilFallsBack(t *testing.T) { + a := NewApplicationProfileProcessor(config.Config{DefaultNamespace: "kubescape", MaxApplicationProfileSize: 40000}) + + // Override with a custom provider that returns custom settings. + a.SetCollapseSettings(func() dynamicpathdetector.CollapseSettings { + return dynamicpathdetector.CollapseSettings{OpenDynamicThreshold: 7} + }) + // Now pass nil — must restore defaults, not crash. + a.SetCollapseSettings(nil) + + // Pull what the processor would actually pass to deflate at PreSave time. + // If the setter had stored nil, this call would panic. + got := a.collapseSettings() + want := dynamicpathdetector.DefaultCollapseSettings() + assert.Equal(t, want.OpenDynamicThreshold, got.OpenDynamicThreshold, + "nil provider must restore default OpenDynamicThreshold, not the prior custom 7") + assert.Equal(t, want.EndpointDynamicThreshold, got.EndpointDynamicThreshold) +} + +// TestApplicationProfileProcessor_SetCollapseSettings_CustomProviderUsed +// pins that a custom provider's settings actually reach the deflate +// path *via the processor's collapseSettings field*. We deflate twice +// against the same input — once before SetCollapseSettings (defaults, +// no collapse) and once after (custom threshold 3, collapse). Both +// calls fetch settings via `a.collapseSettings()`, so the assertion +// exercises the wiring CodeRabbit flagged. +func TestApplicationProfileProcessor_SetCollapseSettings_CustomProviderUsed(t *testing.T) { + a := NewApplicationProfileProcessor(config.Config{DefaultNamespace: "kubescape", MaxApplicationProfileSize: 40000}) + + // Build a container whose Opens has 4 distinct /etc children. + container := softwarecomposition.ApplicationProfileContainer{ + Name: "test", + Opens: []softwarecomposition.OpenCalls{ + {Path: "/etc/file1", Flags: []string{"O_RDONLY"}}, + {Path: "/etc/file2", Flags: []string{"O_RDONLY"}}, + {Path: "/etc/file3", Flags: []string{"O_RDONLY"}}, + {Path: "/etc/file4", Flags: []string{"O_RDONLY"}}, + }, + } + + // Default provider (threshold 100 for /etc) — paths stay distinct. + // The settings come from the processor's wired-up provider. + defResult := deflateApplicationProfileContainer(container, nil, a.collapseSettings()) + assert.Greater(t, len(defResult.Opens), 1, "with default /etc threshold of 100, four files should NOT collapse") + + // Now install a custom provider with a tight /etc threshold and re-deflate. + a.SetCollapseSettings(func() dynamicpathdetector.CollapseSettings { + return dynamicpathdetector.CollapseSettings{ + OpenDynamicThreshold: 50, + EndpointDynamicThreshold: 100, + CollapseConfigs: []dynamicpathdetector.CollapseConfig{ + {Prefix: "/etc", Threshold: 3}, + }, + } + }) + customResult := deflateApplicationProfileContainer(container, nil, a.collapseSettings()) + collapsed := false + for _, o := range customResult.Opens { + if o.Path == "/etc/"+dynamicpathdetector.DynamicIdentifier { + collapsed = true + break + } + } + assert.True(t, collapsed, + "after SetCollapseSettings(threshold 3), four /etc files MUST collapse to /etc/⋯ via the processor's provider") +} + +// TestApplicationProfileProcessor_SetCollapseSettings_DefensiveSetterCopy +// pins that the setter does not store a reference to a slice the caller +// can later mutate. The provider is a function value so by Go semantics +// it captures the closure's referenced state — defensiveness lives in +// the PROVIDER's body. This test documents that contract by installing +// a provider that returns a captured slice, mutating that slice, and +// verifying the deflate path uses the MUTATED state — i.e. the contract +// is "the provider is the source of truth at every call". A wrapper +// provider that wants snapshot semantics must clone its captured slice. +func TestApplicationProfileProcessor_SetCollapseSettings_DefensiveSetterCopy(t *testing.T) { + captured := []dynamicpathdetector.CollapseConfig{{Prefix: "/etc", Threshold: 3}} + a := NewApplicationProfileProcessor(config.Config{DefaultNamespace: "kubescape", MaxApplicationProfileSize: 40000}) + a.SetCollapseSettings(func() dynamicpathdetector.CollapseSettings { + return dynamicpathdetector.CollapseSettings{ + OpenDynamicThreshold: 50, + CollapseConfigs: captured, + } + }) + + // Mutate the captured slice — the provider sees the new threshold on + // the next call. Documenting this in a test makes the contract explicit + // for production wiring (informer-backed providers should always + // snapshot). + captured[0].Threshold = 999 + + // Build 5 /etc paths. + container := softwarecomposition.ApplicationProfileContainer{Name: "test"} + for i := 0; i < 5; i++ { + container.Opens = append(container.Opens, softwarecomposition.OpenCalls{ + Path: fmt.Sprintf("/etc/file%d", i), + Flags: []string{"O_RDONLY"}, + }) + } + // With threshold now 999, paths should NOT collapse. + result := deflateApplicationProfileContainer(container, nil, a.collapseSettings()) + assert.Equal(t, 5, len(result.Opens), + "after mutating the captured slice, the provider returns the new threshold and paths stay distinct") +} + +// assertSettingsMatchProcessor is a placeholder for richer wiring assertions. +// The function exercises a non-nil-provider invocation as a smoke test. +func assertSettingsMatchProcessor(t *testing.T, a *ApplicationProfileProcessor, want dynamicpathdetector.CollapseSettings) bool { + t.Helper() + got := a.collapseSettings() + if got.OpenDynamicThreshold != want.OpenDynamicThreshold { + t.Errorf("OpenDynamicThreshold = %d, want %d", got.OpenDynamicThreshold, want.OpenDynamicThreshold) + return false + } + if got.EndpointDynamicThreshold != want.EndpointDynamicThreshold { + t.Errorf("EndpointDynamicThreshold = %d, want %d", got.EndpointDynamicThreshold, want.EndpointDynamicThreshold) + return false + } + return true +} diff --git a/pkg/registry/file/applicationprofile_processor_test.go b/pkg/registry/file/applicationprofile_processor_test.go index e727d20b6..d7ebe3b97 100644 --- a/pkg/registry/file/applicationprofile_processor_test.go +++ b/pkg/registry/file/applicationprofile_processor_test.go @@ -4,17 +4,26 @@ import ( "context" "fmt" "slices" + "strings" "testing" + mapset "github.com/deckarep/golang-set/v2" "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" "github.com/kubescape/storage/pkg/apis/softwarecomposition" "github.com/kubescape/storage/pkg/apis/softwarecomposition/consts" "github.com/kubescape/storage/pkg/config" + "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" "github.com/stretchr/testify/assert" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ) +// openThreshold returns the collapse threshold used by deflateApplicationProfileContainer +// for file-open paths. NewPathAnalyzerWithConfigs uses OpenDynamicThreshold as the default. +func openThreshold() int { + return dynamicpathdetector.OpenDynamicThreshold +} + var ap = softwarecomposition.ApplicationProfile{ ObjectMeta: v1.ObjectMeta{ Annotations: map[string]string{}, @@ -247,3 +256,203 @@ func TestDeflateRulePolicies(t *testing.T) { }) } } + +// generateSOOpens creates N unique .so OpenCalls under /usr/lib/x86_64-linux-gnu/ +func generateSOOpens(n int) []softwarecomposition.OpenCalls { + opens := make([]softwarecomposition.OpenCalls, n) + for i := 0; i < n; i++ { + opens[i] = softwarecomposition.OpenCalls{ + Path: fmt.Sprintf("/usr/lib/x86_64-linux-gnu/lib%d.so.%d", i, i%5), + Flags: []string{"O_RDONLY", "O_CLOEXEC"}, + } + } + return opens +} + +func TestDeflateApplicationProfileContainer_CollapsesManyOpens(t *testing.T) { + // Generate enough opens to exceed the default threshold used by NewPathAnalyzerWithConfigs + numOpens := openThreshold() + 1 + opens := generateSOOpens(numOpens) + + container := softwarecomposition.ApplicationProfileContainer{ + Name: "test-container", + Opens: opens, + } + + result := deflateApplicationProfileContainer(container, nil, dynamicpathdetector.DefaultCollapseSettings()) + + assert.Less(t, len(result.Opens), numOpens, + "%d .so files should be collapsed, got %d opens", numOpens, len(result.Opens)) + + // Verify collapsed paths contain dynamic or wildcard segments + for _, open := range result.Opens { + if strings.HasPrefix(open.Path, "/usr/lib/x86_64-linux-gnu/") { + assert.True(t, + strings.Contains(open.Path, "\u22ef") || strings.Contains(open.Path, "*"), + "path %q should contain a dynamic or wildcard segment", open.Path) + } + } + + // Flags should be preserved and merged + for _, open := range result.Opens { + assert.NotEmpty(t, open.Flags, "flags should be preserved after collapse") + } +} + +func TestDeflateApplicationProfileContainer_SbomPathsPreserved(t *testing.T) { + numOpens := openThreshold() + 1 + opens := generateSOOpens(numOpens) + + // Build sbomSet containing ALL the .so paths (realistic scenario: + // these are library files referenced by the SBOM for vulnerability scanning) + sbomSet := mapset.NewSet[string]() + for _, open := range opens { + sbomSet.Add(open.Path) + } + + container := softwarecomposition.ApplicationProfileContainer{ + Name: "test-container", + Opens: opens, + } + + result := deflateApplicationProfileContainer(container, sbomSet, dynamicpathdetector.DefaultCollapseSettings()) + + // SBOM paths must NEVER be collapsed — they map to specific library files + // used for vulnerability scanning. Collapsing them makes vuln results + // non-reproducible. + assert.Equal(t, numOpens, len(result.Opens), + "SBOM paths must be preserved verbatim, got %d opens (expected %d)", len(result.Opens), numOpens) + resultPaths := make(map[string]bool) + for _, r := range result.Opens { + resultPaths[r.Path] = true + } + for _, open := range opens { + assert.True(t, resultPaths[open.Path], + "SBOM path %q must be preserved in output", open.Path) + } +} + +func TestDeflateApplicationProfileContainer_MixedPathsCollapse(t *testing.T) { + var opens []softwarecomposition.OpenCalls + + // /usr/lib uses the default threshold from NewPathAnalyzerWithConfigs(OpenDynamicThreshold, ...) + usrLibThreshold := openThreshold() + for i := 0; i < usrLibThreshold+1; i++ { + opens = append(opens, softwarecomposition.OpenCalls{ + Path: fmt.Sprintf("/usr/lib/lib%d.so", i), + Flags: []string{"O_RDONLY"}, + }) + } + + // /etc uses the /etc config threshold from DefaultCollapseConfigs. + // Derive from the live config so this test stays in sync if the + // production threshold for /etc ever changes — hardcoding 100 here + // previously meant the test would silently pass even when + // DefaultCollapseConfigs drifted (CodeRabbit C5). + etcAnalyzer := dynamicpathdetector.NewPathAnalyzerWithConfigs( + dynamicpathdetector.OpenDynamicThreshold, + dynamicpathdetector.DefaultCollapseConfigs(), + ) + etcThreshold := etcAnalyzer.FindConfigForPath("/etc/file").Threshold + for i := 0; i < etcThreshold+1; i++ { + opens = append(opens, softwarecomposition.OpenCalls{ + Path: fmt.Sprintf("/etc/conf%d.cfg", i), + Flags: []string{"O_RDONLY"}, + }) + } + + opens = append(opens, + softwarecomposition.OpenCalls{Path: "/tmp/file1.txt", Flags: []string{"O_RDWR"}}, + softwarecomposition.OpenCalls{Path: "/tmp/file2.txt", Flags: []string{"O_RDWR"}}, + ) + + container := softwarecomposition.ApplicationProfileContainer{ + Name: "test-container", + Opens: opens, + } + + result := deflateApplicationProfileContainer(container, nil, dynamicpathdetector.DefaultCollapseSettings()) + + // Count paths by prefix + var usrLibPaths, etcPaths, tmpPaths int + for _, open := range result.Opens { + switch { + case strings.HasPrefix(open.Path, "/usr/lib/"): + usrLibPaths++ + case strings.HasPrefix(open.Path, "/etc/"): + etcPaths++ + case strings.HasPrefix(open.Path, "/tmp/"): + tmpPaths++ + } + } + + assert.LessOrEqual(t, usrLibPaths, 1, "/usr/lib/ paths should collapse to 1, got %d", usrLibPaths) + assert.LessOrEqual(t, etcPaths, 1, "/etc/ paths should collapse to 1, got %d", etcPaths) + assert.Equal(t, 2, tmpPaths, "/tmp/ paths should remain individual (below threshold)") +} + +// TestDeflateApplicationProfileContainer_NilSbomNoError verifies that nil sbomSet +// with a small number of opens (below threshold) works without error. +func TestDeflateApplicationProfileContainer_NilSbomNoError(t *testing.T) { + container := softwarecomposition.ApplicationProfileContainer{ + Name: "test-container", + Opens: []softwarecomposition.OpenCalls{ + {Path: "/etc/hosts", Flags: []string{"O_RDONLY"}}, + {Path: "/etc/resolv.conf", Flags: []string{"O_RDONLY"}}, + {Path: "/usr/lib/libc.so.6", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, + }, + } + + result := deflateApplicationProfileContainer(container, nil, dynamicpathdetector.DefaultCollapseSettings()) + + // All 3 paths should remain (below any threshold) + assert.Equal(t, 3, len(result.Opens), "paths below threshold should not collapse") + // Paths should be sorted + for i := 1; i < len(result.Opens); i++ { + assert.True(t, result.Opens[i-1].Path <= result.Opens[i].Path, + "opens should be sorted, got %q before %q", result.Opens[i-1].Path, result.Opens[i].Path) + } +} + +// TestDeflateApplicationProfileContainer_PreSaveEndToEnd verifies the full +// PreSave flow with an ApplicationProfile containing many opens that should collapse. +func TestDeflateApplicationProfileContainer_PreSaveEndToEnd(t *testing.T) { + numOpens := openThreshold() + 1 + opens := generateSOOpens(numOpens) + + profile := &softwarecomposition.ApplicationProfile{ + ObjectMeta: v1.ObjectMeta{ + Annotations: map[string]string{}, + }, + Spec: softwarecomposition.ApplicationProfileSpec{ + Containers: []softwarecomposition.ApplicationProfileContainer{ + { + Name: "main", + Opens: opens, + }, + }, + }, + } + + processor := NewApplicationProfileProcessor(config.Config{ + DefaultNamespace: "kubescape", + MaxApplicationProfileSize: 100000, + }) + + err := processor.PreSave(context.TODO(), profile) + assert.NoError(t, err) + + resultOpens := profile.Spec.Containers[0].Opens + assert.Less(t, len(resultOpens), numOpens, + "PreSave should collapse %d .so files, got %d opens", numOpens, len(resultOpens)) + + // The collapsed path should contain dynamic or wildcard segments + hasCollapsed := false + for _, open := range resultOpens { + if strings.Contains(open.Path, "\u22ef") || strings.Contains(open.Path, "*") { + hasCollapsed = true + break + } + } + assert.True(t, hasCollapsed, "at least one path should contain a dynamic/wildcard segment after PreSave") +} diff --git a/pkg/registry/file/cleanup.go b/pkg/registry/file/cleanup.go index 3c0122fe1..8f5de6d59 100644 --- a/pkg/registry/file/cleanup.go +++ b/pkg/registry/file/cleanup.go @@ -185,6 +185,11 @@ func (h *ResourcesCleanupHandler) cleanupNamespace(ctx context.Context, ns strin return nil } + // Skip user-managed resources (e.g., user-defined profiles). + if isUserManaged(metadata) { + return nil + } + // either run single handler, or perform OR operation on multiple handlers var toDelete bool if len(handlers) == 1 { @@ -212,6 +217,19 @@ func (h *ResourcesCleanupHandler) cleanupNamespace(ctx context.Context, ns strin return nil } +// isUserManaged reports whether the given resource metadata carries the +// "user-managed" marker. The marker lives on Annotations by codebase +// convention (see pkg/apis/softwarecomposition/networkpolicy/v2/ +// networkpolicy.go for the canonical read-site) — NOT on Labels. +// Reading from Labels would silently miss every user-managed resource +// and defeat the cleanup skip entirely. +func isUserManaged(metadata *metav1.ObjectMeta) bool { + if metadata == nil { + return false + } + return metadata.Annotations[helpersv1.ManagedByMetadataKey] == helpersv1.ManagedByUserValue +} + func or(funcs []TypeCleanupHandlerFunc, kind, path string, metadata *metav1.ObjectMeta, resourceMaps ResourceMaps) bool { for _, f := range funcs { if f(kind, path, metadata, resourceMaps) { diff --git a/pkg/registry/file/cleanup_test.go b/pkg/registry/file/cleanup_test.go index f67e5570d..20ac74de3 100644 --- a/pkg/registry/file/cleanup_test.go +++ b/pkg/registry/file/cleanup_test.go @@ -214,3 +214,71 @@ func unzipFile(f *zip.File, destination string, appFs afero.Fs) error { } return nil } + +// TestIsUserManaged pins the invariant that user-managed resources are +// identified by an ANNOTATION (not a label). A previous version of the +// cleanup skip read the marker from metadata.Labels, which silently +// matched nothing (the marker is set as an annotation across the +// codebase) and allowed user-defined profiles to be garbage-collected. +// These cases would have passed with the Labels-reading implementation, +// so keeping them green guards against re-introducing that regression. +func TestIsUserManaged(t *testing.T) { + tests := []struct { + name string + metadata *metav1.ObjectMeta + want bool + }{ + { + name: "annotation_marker_present_true", + metadata: &metav1.ObjectMeta{ + Annotations: map[string]string{ + helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, + }, + }, + want: true, + }, + { + name: "only_label_marker_not_annotation_false", + metadata: &metav1.ObjectMeta{ + Labels: map[string]string{ + helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, + }, + }, + want: false, + }, + { + name: "annotation_marker_different_value_false", + metadata: &metav1.ObjectMeta{ + Annotations: map[string]string{ + helpersv1.ManagedByMetadataKey: "something-else", + }, + }, + want: false, + }, + { + name: "no_annotations_no_labels_false", + metadata: &metav1.ObjectMeta{}, + want: false, + }, + { + name: "nil_metadata_false", + metadata: nil, + want: false, + }, + { + name: "other_annotation_without_managed_by_false", + metadata: &metav1.ObjectMeta{ + Annotations: map[string]string{ + "unrelated/key": "value", + }, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isUserManaged(tt.metadata)) + }) + } +} diff --git a/pkg/registry/file/containerprofile_processor.go b/pkg/registry/file/containerprofile_processor.go index 22eed312d..6f2e73162 100644 --- a/pkg/registry/file/containerprofile_processor.go +++ b/pkg/registry/file/containerprofile_processor.go @@ -45,6 +45,11 @@ type ContainerProfileProcessor struct { MaxContainerProfileSize int ContainerProfileStorage ContainerProfileStorage ConsolidatedSlugChannel chan ConsolidatedSlugData + // CollapseSettings is the lookup hook the deflate path consults for + // per-prefix thresholds. Defaults to dynamicpathdetector.DefaultCollapseSettings; + // production wiring may swap to a provider that reads the cluster-scoped + // CollapseConfiguration "default" CR. + CollapseSettings dynamicpathdetector.CollapseSettingsProvider } func NewContainerProfileProcessor(cfg config.Config, cleanupHandler *ResourcesCleanupHandler) *ContainerProfileProcessor { @@ -60,6 +65,7 @@ func NewContainerProfileProcessor(cfg config.Config, cleanupHandler *ResourcesCl HostType: hostType, Interval: 30 * time.Second, MaxContainerProfileSize: cfg.MaxApplicationProfileSize, + CollapseSettings: dynamicpathdetector.DefaultCollapseSettings, } } @@ -178,7 +184,11 @@ func (a *ContainerProfileProcessor) PreSave(ctx context.Context, object runtime. } else { logger.L().Debug("ContainerProfileProcessor.PreSave - failed to get sbom name", loggerhelpers.Error(err), loggerhelpers.String("imageTag", profile.Spec.ImageTag), loggerhelpers.String("imageID", profile.Spec.ImageID)) } - profile.Spec = DeflateContainerProfileSpec(profile.Spec, sbomSet) + settings := dynamicpathdetector.DefaultCollapseSettings() + if a.CollapseSettings != nil { + settings = a.CollapseSettings() + } + profile.Spec = DeflateContainerProfileSpec(profile.Spec, sbomSet, settings) size += len(profile.Spec.Execs) size += len(profile.Spec.Opens) size += len(profile.Spec.Syscalls) @@ -742,13 +752,13 @@ func (a *ContainerProfileProcessor) getAggregatedData(ctx context.Context, key s return status, completion, hash } -func DeflateContainerProfileSpec(container softwarecomposition.ContainerProfileSpec, sbomSet mapset.Set[string]) softwarecomposition.ContainerProfileSpec { - opens, err := dynamicpathdetector.AnalyzeOpens(container.Opens, dynamicpathdetector.NewPathAnalyzer(OpenDynamicThreshold), sbomSet) +func DeflateContainerProfileSpec(container softwarecomposition.ContainerProfileSpec, sbomSet mapset.Set[string], settings dynamicpathdetector.CollapseSettings) softwarecomposition.ContainerProfileSpec { + opens, err := dynamicpathdetector.AnalyzeOpens(container.Opens, dynamicpathdetector.NewPathAnalyzerWithConfigs(settings.OpenDynamicThreshold, settings.CollapseConfigs), sbomSet) if err != nil { logger.L().Debug("ContainerProfileProcessor.deflateContainerProfileSpec - falling back to DeflateStringer for opens", loggerhelpers.Error(err)) opens = DeflateStringer(container.Opens) } - endpoints := dynamicpathdetector.AnalyzeEndpoints(&container.Endpoints, dynamicpathdetector.NewPathAnalyzer(EndpointDynamicThreshold)) + endpoints := dynamicpathdetector.AnalyzeEndpoints(&container.Endpoints, dynamicpathdetector.NewPathAnalyzerWithConfigs(settings.EndpointDynamicThreshold, nil)) identifiedCallStacks := callstack.UnifyIdentifiedCallStacks(container.IdentifiedCallStacks) return softwarecomposition.ContainerProfileSpec{ diff --git a/pkg/registry/file/containerprofile_processor_collapse_provider_test.go b/pkg/registry/file/containerprofile_processor_collapse_provider_test.go new file mode 100644 index 000000000..af0341876 --- /dev/null +++ b/pkg/registry/file/containerprofile_processor_collapse_provider_test.go @@ -0,0 +1,123 @@ +/* +Copyright 2024 The Kubescape Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package file + +import ( + "fmt" + "testing" + + "github.com/kubescape/storage/pkg/apis/softwarecomposition" + "github.com/kubescape/storage/pkg/config" + "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" + "github.com/stretchr/testify/assert" +) + +// TestContainerProfileProcessor_CollapseSettings_NilProviderFallsBack pins +// the nil-safety inside PreSave: the field is exported, so an external +// caller may leave it unset (zero-value struct literal). The processor +// must NOT panic and must fall back to compiled defaults — i.e. tight +// /etc thresholds shouldn't appear out of nowhere. +func TestContainerProfileProcessor_CollapseSettings_NilProviderFallsBack(t *testing.T) { + c := NewContainerProfileProcessor(config.Config{ + DefaultNamespace: "kubescape", + MaxApplicationProfileSize: 40000, + }, nil) + // Force the field nil to simulate an external caller that bypassed the + // constructor's defaulting. + c.CollapseSettings = nil + + // Build a spec with 4 /etc children. With the compiled default of 100, + // none should collapse — proving PreSave's nil branch reached the + // fallback rather than crashing or producing a degenerate result. + spec := softwarecomposition.ContainerProfileSpec{} + for i := 0; i < 4; i++ { + spec.Opens = append(spec.Opens, softwarecomposition.OpenCalls{ + Path: fmt.Sprintf("/etc/file%d", i), + Flags: []string{"O_RDONLY"}, + }) + } + + // Mirror PreSave's nil-handling exactly to exercise the fallback path. + settings := dynamicpathdetector.DefaultCollapseSettings() + if c.CollapseSettings != nil { + settings = c.CollapseSettings() + } + result := DeflateContainerProfileSpec(spec, nil, settings) + assert.Greater(t, len(result.Opens), 1, + "nil provider must fall back to defaults; default /etc=100 keeps 4 files distinct") +} + +// TestContainerProfileProcessor_CustomCollapseSettings_ReachDeflate pins +// that a custom provider installed on ContainerProfileProcessor.CollapseSettings +// actually reaches the deflate path. Both deflate calls fetch settings via +// the processor's field, so the assertion exercises the wiring CodeRabbit +// flagged. +func TestContainerProfileProcessor_CustomCollapseSettings_ReachDeflate(t *testing.T) { + c := NewContainerProfileProcessor(config.Config{ + DefaultNamespace: "kubescape", + MaxApplicationProfileSize: 40000, + }, nil) + + spec := softwarecomposition.ContainerProfileSpec{} + for i := 0; i < 4; i++ { + spec.Opens = append(spec.Opens, softwarecomposition.OpenCalls{ + Path: fmt.Sprintf("/etc/file%d", i), + Flags: []string{"O_RDONLY"}, + }) + } + + // Default provider — paths stay distinct. + defResult := DeflateContainerProfileSpec(spec, nil, c.CollapseSettings()) + assert.Greater(t, len(defResult.Opens), 1, "default threshold 100: four /etc files should NOT collapse") + + // Install a tight custom provider and re-deflate via the same field. + c.CollapseSettings = func() dynamicpathdetector.CollapseSettings { + return dynamicpathdetector.CollapseSettings{ + OpenDynamicThreshold: 50, + EndpointDynamicThreshold: 100, + CollapseConfigs: []dynamicpathdetector.CollapseConfig{ + {Prefix: "/etc", Threshold: 3}, + }, + } + } + customResult := DeflateContainerProfileSpec(spec, nil, c.CollapseSettings()) + collapsed := false + for _, o := range customResult.Opens { + if o.Path == "/etc/"+dynamicpathdetector.DynamicIdentifier { + collapsed = true + break + } + } + assert.True(t, collapsed, + "custom provider on c.CollapseSettings (threshold 3): four /etc files MUST collapse to /etc/⋯") +} + +// TestContainerProfileProcessor_DefaultConstructorWiresProvider pins the +// constructor contract — a freshly-constructed processor must have a +// non-nil CollapseSettings provider that returns the compiled defaults. +func TestContainerProfileProcessor_DefaultConstructorWiresProvider(t *testing.T) { + c := NewContainerProfileProcessor(config.Config{ + DefaultNamespace: "kubescape", + MaxApplicationProfileSize: 40000, + }, nil) + assert.NotNil(t, c.CollapseSettings, "constructor must wire a default provider") + got := c.CollapseSettings() + want := dynamicpathdetector.DefaultCollapseSettings() + assert.Equal(t, want.OpenDynamicThreshold, got.OpenDynamicThreshold) + assert.Equal(t, want.EndpointDynamicThreshold, got.EndpointDynamicThreshold) + assert.Equal(t, len(want.CollapseConfigs), len(got.CollapseConfigs)) +} diff --git a/pkg/registry/file/dynamicpathdetector/collapse_config_from_crd.go b/pkg/registry/file/dynamicpathdetector/collapse_config_from_crd.go new file mode 100644 index 000000000..edc4ad3d0 --- /dev/null +++ b/pkg/registry/file/dynamicpathdetector/collapse_config_from_crd.go @@ -0,0 +1,116 @@ +/* +Copyright 2024 The Kubescape Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package dynamicpathdetector + +import ( + "math" + + "github.com/kubescape/storage/pkg/apis/softwarecomposition" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// clampInt32 clamps a runtime int into the int32 wire range used by the +// CollapseConfiguration CRD. Thresholds are physically small (single- or +// double-digit counts of trie children); clamping defends only against +// the autotune path being handed a pathological value. +func clampInt32(v int) int32 { + if v < 0 { + return 0 + } + if v > math.MaxInt32 { + return math.MaxInt32 + } + return int32(v) +} + +// CollapseSettings is the runtime form of the CollapseConfiguration CRD — +// a single value carrying the thresholds the deflate path needs to build +// its analyzer. Use DefaultCollapseSettings for the built-in baseline, +// CollapseSettingsFromCRD to project a CRD into runtime settings, and +// CRDFromCollapseSettings to round-trip back when tooling (e.g. bobctl +// autotune) needs to write the CRD. +type CollapseSettings struct { + OpenDynamicThreshold int + EndpointDynamicThreshold int + CollapseConfigs []CollapseConfig +} + +// DefaultCollapseSettings returns the built-in baseline. The returned +// value is a fresh copy on every call — callers may freely mutate the +// CollapseConfigs slice without affecting the package state. This +// mirrors the defensive-copy contract the bare DefaultCollapseConfigs() +// accessor already enforces. +func DefaultCollapseSettings() CollapseSettings { + return CollapseSettings{ + OpenDynamicThreshold: OpenDynamicThreshold, + EndpointDynamicThreshold: EndpointDynamicThreshold, + CollapseConfigs: DefaultCollapseConfigs(), + } +} + +// CollapseSettingsFromCRD projects a CollapseConfiguration custom resource +// into the runtime form. Both threshold fields are taken verbatim; the +// per-prefix override slice is converted entry-by-entry. Returns a value +// that does not alias the CRD's internal slice. +func CollapseSettingsFromCRD(crd *softwarecomposition.CollapseConfiguration) CollapseSettings { + if crd == nil { + return DefaultCollapseSettings() + } + configs := make([]CollapseConfig, len(crd.Spec.CollapseConfigs)) + for i, entry := range crd.Spec.CollapseConfigs { + configs[i] = CollapseConfig{ + Prefix: entry.Prefix, + Threshold: int(entry.Threshold), + } + } + return CollapseSettings{ + OpenDynamicThreshold: int(crd.Spec.OpenDynamicThreshold), + EndpointDynamicThreshold: int(crd.Spec.EndpointDynamicThreshold), + CollapseConfigs: configs, + } +} + +// CRDFromCollapseSettings is the inverse of CollapseSettingsFromCRD. It +// produces a fresh CollapseConfiguration suitable for client-go Create / +// Update calls. Tooling (notably bobctl autotune) uses it to push tuned +// thresholds back into a running cluster. +func CRDFromCollapseSettings(name string, settings CollapseSettings) *softwarecomposition.CollapseConfiguration { + entries := make([]softwarecomposition.CollapseConfigEntry, len(settings.CollapseConfigs)) + for i, cfg := range settings.CollapseConfigs { + entries[i] = softwarecomposition.CollapseConfigEntry{ + Prefix: cfg.Prefix, + Threshold: clampInt32(cfg.Threshold), + } + } + return &softwarecomposition.CollapseConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: softwarecomposition.CollapseConfigurationSpec{ + OpenDynamicThreshold: clampInt32(settings.OpenDynamicThreshold), + EndpointDynamicThreshold: clampInt32(settings.EndpointDynamicThreshold), + CollapseConfigs: entries, + }, + } +} + +// CollapseSettingsProvider is the lookup hook the deflate path uses to +// fetch effective collapse thresholds at processing time. Production +// wiring can swap the default for a provider that reads the +// CollapseConfiguration CR from the apiserver's storage; tests and the +// default constructor return DefaultCollapseSettings. +type CollapseSettingsProvider func() CollapseSettings diff --git a/pkg/registry/file/dynamicpathdetector/tests/collapse_config_crd_test.go b/pkg/registry/file/dynamicpathdetector/tests/collapse_config_crd_test.go new file mode 100644 index 000000000..b5162b1ce --- /dev/null +++ b/pkg/registry/file/dynamicpathdetector/tests/collapse_config_crd_test.go @@ -0,0 +1,162 @@ +/* +Copyright 2024 The Kubescape Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package dynamicpathdetectortests + +import ( + "testing" + + "github.com/kubescape/storage/pkg/apis/softwarecomposition" + "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TestDefaultCollapseSettings_FreshCopyPerCall pins the contract that +// DefaultCollapseSettings returns a value whose CollapseConfigs slice is +// freshly allocated on every call. Without this, a caller mutating the +// returned slice could leak into a subsequent call's result and +// silently change collapse thresholds across the whole storage server. +func TestDefaultCollapseSettings_FreshCopyPerCall(t *testing.T) { + first := dynamicpathdetector.DefaultCollapseSettings() + require.NotEmpty(t, first.CollapseConfigs, "default settings must have per-prefix entries") + + originalThreshold := first.CollapseConfigs[0].Threshold + first.CollapseConfigs[0].Threshold = 999_999 + first.CollapseConfigs[0].Prefix = "/poisoned" + first.CollapseConfigs = append(first.CollapseConfigs, dynamicpathdetector.CollapseConfig{ + Prefix: "/poisoned-tail", Threshold: 1, + }) + + second := dynamicpathdetector.DefaultCollapseSettings() + assert.Equal(t, originalThreshold, second.CollapseConfigs[0].Threshold, + "mutating the first call's slice must not change the package state") + for _, cfg := range second.CollapseConfigs { + assert.NotEqual(t, "/poisoned", cfg.Prefix, "prefix mutation must not leak") + assert.NotEqual(t, "/poisoned-tail", cfg.Prefix, "appended entries must not leak") + } + if len(first.CollapseConfigs) > 0 && len(second.CollapseConfigs) > 0 { + assert.NotSame(t, &first.CollapseConfigs[0], &second.CollapseConfigs[0], + "DefaultCollapseSettings must return a fresh CollapseConfigs backing array") + } +} + +// TestCollapseSettingsFromCRD_NilFallsBackToDefaults documents the +// defensive nil-handling: when the storage server can't read the CRD +// (NotFound, or pre-cluster bootstrap), the deflate path must still +// produce sensible thresholds. Returning an empty struct here would +// mean "collapse never fires" which is a worse failure mode than +// "fall back to compiled defaults". +func TestCollapseSettingsFromCRD_NilFallsBackToDefaults(t *testing.T) { + got := dynamicpathdetector.CollapseSettingsFromCRD(nil) + want := dynamicpathdetector.DefaultCollapseSettings() + assert.Equal(t, want.OpenDynamicThreshold, got.OpenDynamicThreshold, + "nil CRD must produce the default OpenDynamicThreshold") + assert.Equal(t, want.EndpointDynamicThreshold, got.EndpointDynamicThreshold, + "nil CRD must produce the default EndpointDynamicThreshold") + assert.Equal(t, len(want.CollapseConfigs), len(got.CollapseConfigs), + "nil CRD must produce the default CollapseConfigs entries") +} + +// TestCollapseSettingsFromCRD_RoundTrip pins the conversion contract: +// CRD spec values land verbatim in the runtime settings, the entries +// are converted entry-by-entry preserving order, and the resulting +// slice does NOT alias the CRD's internal slice. +func TestCollapseSettingsFromCRD_RoundTrip(t *testing.T) { + crd := &softwarecomposition.CollapseConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: "default"}, + Spec: softwarecomposition.CollapseConfigurationSpec{ + OpenDynamicThreshold: 42, + EndpointDynamicThreshold: 84, + CollapseConfigs: []softwarecomposition.CollapseConfigEntry{ + {Prefix: "/etc", Threshold: 100}, + {Prefix: "/var/log", Threshold: 50}, + {Prefix: "/opt", Threshold: 25}, + }, + }, + } + + settings := dynamicpathdetector.CollapseSettingsFromCRD(crd) + assert.Equal(t, 42, settings.OpenDynamicThreshold) + assert.Equal(t, 84, settings.EndpointDynamicThreshold) + require.Len(t, settings.CollapseConfigs, 3) + assert.Equal(t, "/etc", settings.CollapseConfigs[0].Prefix) + assert.Equal(t, 100, settings.CollapseConfigs[0].Threshold) + assert.Equal(t, "/var/log", settings.CollapseConfigs[1].Prefix) + assert.Equal(t, "/opt", settings.CollapseConfigs[2].Prefix) + + // Mutate the converted settings — the CRD must not see the change. + settings.CollapseConfigs[0].Threshold = 999 + settings.CollapseConfigs[0].Prefix = "/poisoned" + assert.Equal(t, "/etc", crd.Spec.CollapseConfigs[0].Prefix, + "settings → CRD aliasing must not leak: mutating settings must not change CRD") + assert.EqualValues(t, 100, crd.Spec.CollapseConfigs[0].Threshold, + "settings → CRD aliasing must not leak: threshold") +} + +// TestCRDFromCollapseSettings_RoundTrip is the inverse of the above — +// pins that CRD construction also makes a fresh slice and is +// faithful to the source settings. +func TestCRDFromCollapseSettings_RoundTrip(t *testing.T) { + settings := dynamicpathdetector.CollapseSettings{ + OpenDynamicThreshold: 11, + EndpointDynamicThreshold: 22, + CollapseConfigs: []dynamicpathdetector.CollapseConfig{ + {Prefix: "/etc", Threshold: 7}, + {Prefix: "/srv", Threshold: 3}, + }, + } + + crd := dynamicpathdetector.CRDFromCollapseSettings("default", settings) + require.NotNil(t, crd) + assert.Equal(t, "default", crd.Name) + assert.EqualValues(t, 11, crd.Spec.OpenDynamicThreshold) + assert.EqualValues(t, 22, crd.Spec.EndpointDynamicThreshold) + require.Len(t, crd.Spec.CollapseConfigs, 2) + assert.Equal(t, "/etc", crd.Spec.CollapseConfigs[0].Prefix) + assert.EqualValues(t, 7, crd.Spec.CollapseConfigs[0].Threshold) + assert.Equal(t, "/srv", crd.Spec.CollapseConfigs[1].Prefix) + + // Mutate the produced CRD — the source settings must not see the change. + crd.Spec.CollapseConfigs[0].Prefix = "/poisoned" + crd.Spec.CollapseConfigs[0].Threshold = 999 + assert.Equal(t, "/etc", settings.CollapseConfigs[0].Prefix, + "CRD → settings aliasing must not leak: mutating CRD must not change settings") + assert.Equal(t, 7, settings.CollapseConfigs[0].Threshold, + "CRD → settings aliasing must not leak: threshold") +} + +// TestCollapseSettings_FullRoundTrip pins that going CRD → settings → +// CRD is faithful (idempotent on canonical content). +func TestCollapseSettings_FullRoundTrip(t *testing.T) { + original := &softwarecomposition.CollapseConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: "default"}, + Spec: softwarecomposition.CollapseConfigurationSpec{ + OpenDynamicThreshold: 50, + EndpointDynamicThreshold: 100, + CollapseConfigs: []softwarecomposition.CollapseConfigEntry{ + {Prefix: "/etc", Threshold: 100}, + {Prefix: "/var/run", Threshold: 50}, + }, + }, + } + + settings := dynamicpathdetector.CollapseSettingsFromCRD(original) + roundTripped := dynamicpathdetector.CRDFromCollapseSettings("default", settings) + assert.Equal(t, original.Spec, roundTripped.Spec, + "CRD → settings → CRD must preserve spec content") +} diff --git a/pkg/registry/softwarecomposition/collapseconfiguration/etcd.go b/pkg/registry/softwarecomposition/collapseconfiguration/etcd.go new file mode 100644 index 000000000..77fc01934 --- /dev/null +++ b/pkg/registry/softwarecomposition/collapseconfiguration/etcd.go @@ -0,0 +1,59 @@ +/* +Copyright 2024 The Kubescape Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package collapseconfiguration + +import ( + "github.com/kubescape/storage/pkg/apis/softwarecomposition" + "github.com/kubescape/storage/pkg/registry" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic" + genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" + "k8s.io/apiserver/pkg/registry/rest" + "k8s.io/apiserver/pkg/storage" +) + +// NewREST returns a RESTStorage object that exposes CollapseConfiguration +// resources. The CRD is cluster-scoped (NamespaceScoped() == false in +// strategy.go) and is normally read by the storage server's deflate path +// at deflateApplicationProfileContainer / DeflateContainerProfileSpec time. +func NewREST(scheme *runtime.Scheme, storageImpl storage.Interface, optsGetter generic.RESTOptionsGetter) (*registry.REST, error) { + strategy := NewStrategy(scheme) + + dryRunnableStorage := genericregistry.DryRunnableStorage{Codec: nil, Storage: storageImpl} + + store := &genericregistry.Store{ + NewFunc: func() runtime.Object { return &softwarecomposition.CollapseConfiguration{} }, + NewListFunc: func() runtime.Object { return &softwarecomposition.CollapseConfigurationList{} }, + PredicateFunc: MatchCollapseConfiguration, + DefaultQualifiedResource: softwarecomposition.Resource("collapseconfigurations"), + SingularQualifiedResource: softwarecomposition.Resource("collapseconfiguration"), + + Storage: dryRunnableStorage, + + CreateStrategy: strategy, + UpdateStrategy: strategy, + DeleteStrategy: strategy, + + TableConvertor: rest.NewDefaultTableConvertor(softwarecomposition.Resource("collapseconfigurations")), + } + options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: GetAttrs} + if err := store.CompleteWithOptions(options); err != nil { + return nil, err + } + + return ®istry.REST{Store: store}, nil +} diff --git a/pkg/registry/softwarecomposition/collapseconfiguration/strategy.go b/pkg/registry/softwarecomposition/collapseconfiguration/strategy.go new file mode 100644 index 000000000..46fd2ab9a --- /dev/null +++ b/pkg/registry/softwarecomposition/collapseconfiguration/strategy.go @@ -0,0 +1,156 @@ +/* +Copyright 2024 The Kubescape Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package collapseconfiguration + +import ( + "context" + "fmt" + "strings" + + "github.com/kubescape/storage/pkg/apis/softwarecomposition" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apiserver/pkg/registry/generic" + "k8s.io/apiserver/pkg/storage" + "k8s.io/apiserver/pkg/storage/names" +) + +// NewStrategy creates and returns a CollapseConfigurationStrategy instance. +func NewStrategy(typer runtime.ObjectTyper) CollapseConfigurationStrategy { + return CollapseConfigurationStrategy{typer, names.SimpleNameGenerator} +} + +// GetAttrs returns labels.Set, fields.Set, and error in case the given +// runtime.Object is not a CollapseConfiguration. +func GetAttrs(obj runtime.Object) (labels.Set, fields.Set, error) { + cc, ok := obj.(*softwarecomposition.CollapseConfiguration) + if !ok { + return nil, nil, fmt.Errorf("given object is not a CollapseConfiguration") + } + return cc.Labels, SelectableFields(cc), nil +} + +// MatchCollapseConfiguration returns a generic SelectionPredicate that pairs +// the supplied label/field selectors with the type's GetAttrs. +func MatchCollapseConfiguration(label labels.Selector, field fields.Selector) storage.SelectionPredicate { + return storage.SelectionPredicate{ + Label: label, + Field: field, + GetAttrs: GetAttrs, + } +} + +// SelectableFields returns a field set that represents the object. +// CollapseConfiguration is cluster-scoped, so the namespaceScoped flag +// is false — `metadata.namespace` is intentionally absent from the +// selectable set. +func SelectableFields(obj *softwarecomposition.CollapseConfiguration) fields.Set { + return generic.ObjectMetaFieldsSet(&obj.ObjectMeta, false) +} + +// CollapseConfigurationStrategy carries the per-object lifecycle hooks the +// generic registry calls during Create/Update/Delete. CollapseConfiguration +// is cluster-scoped, has no immutable fields, and validates that each +// per-prefix entry has a non-empty Prefix and a positive Threshold. +type CollapseConfigurationStrategy struct { + runtime.ObjectTyper + names.NameGenerator +} + +// NamespaceScoped declares the resource as cluster-scoped. +func (CollapseConfigurationStrategy) NamespaceScoped() bool { + return false +} + +func (CollapseConfigurationStrategy) PrepareForCreate(_ context.Context, _ runtime.Object) { +} + +func (CollapseConfigurationStrategy) PrepareForUpdate(_ context.Context, _, _ runtime.Object) { +} + +// Validate runs spec-level checks on a Create. Returns an empty list when the +// object is well-formed. +func (CollapseConfigurationStrategy) Validate(_ context.Context, obj runtime.Object) field.ErrorList { + cc, ok := obj.(*softwarecomposition.CollapseConfiguration) + if !ok { + return field.ErrorList{field.InternalError(field.NewPath(""), fmt.Errorf("expected *CollapseConfiguration"))} + } + return validateCollapseConfigurationSpec(&cc.Spec, field.NewPath("spec")) +} + +func (CollapseConfigurationStrategy) WarningsOnCreate(_ context.Context, _ runtime.Object) []string { + return nil +} + +func (CollapseConfigurationStrategy) AllowCreateOnUpdate() bool { + return false +} + +func (CollapseConfigurationStrategy) AllowUnconditionalUpdate() bool { + return false +} + +func (CollapseConfigurationStrategy) Canonicalize(_ runtime.Object) { +} + +// ValidateUpdate runs the same spec-level checks as Validate; the spec is +// fully mutable on update. +func (CollapseConfigurationStrategy) ValidateUpdate(_ context.Context, obj, _ runtime.Object) field.ErrorList { + cc, ok := obj.(*softwarecomposition.CollapseConfiguration) + if !ok { + return field.ErrorList{field.InternalError(field.NewPath(""), fmt.Errorf("expected *CollapseConfiguration"))} + } + return validateCollapseConfigurationSpec(&cc.Spec, field.NewPath("spec")) +} + +func (CollapseConfigurationStrategy) WarningsOnUpdate(_ context.Context, _, _ runtime.Object) []string { + return nil +} + +// validateCollapseConfigurationSpec enforces the per-entry invariants and +// rejects duplicate prefixes (which would silently produce a non-deterministic +// longest-prefix-wins outcome at runtime). +func validateCollapseConfigurationSpec(spec *softwarecomposition.CollapseConfigurationSpec, fp *field.Path) field.ErrorList { + var errs field.ErrorList + if spec.OpenDynamicThreshold < 0 { + errs = append(errs, field.Invalid(fp.Child("openDynamicThreshold"), spec.OpenDynamicThreshold, "must be >= 0")) + } + if spec.EndpointDynamicThreshold < 0 { + errs = append(errs, field.Invalid(fp.Child("endpointDynamicThreshold"), spec.EndpointDynamicThreshold, "must be >= 0")) + } + seen := make(map[string]int, len(spec.CollapseConfigs)) + cfgsPath := fp.Child("collapseConfigs") + for i, e := range spec.CollapseConfigs { + ip := cfgsPath.Index(i) + if e.Prefix == "" { + errs = append(errs, field.Required(ip.Child("prefix"), "prefix must not be empty")) + } else if !strings.HasPrefix(e.Prefix, "/") { + errs = append(errs, field.Invalid(ip.Child("prefix"), e.Prefix, "prefix must begin with /")) + } + if e.Threshold < 1 { + errs = append(errs, field.Invalid(ip.Child("threshold"), e.Threshold, "must be >= 1")) + } + if dup, ok := seen[e.Prefix]; ok { + errs = append(errs, field.Duplicate(ip.Child("prefix"), fmt.Sprintf("%s (also at index %d)", e.Prefix, dup))) + } else { + seen[e.Prefix] = i + } + } + return errs +} diff --git a/pkg/registry/softwarecomposition/collapseconfiguration/strategy_test.go b/pkg/registry/softwarecomposition/collapseconfiguration/strategy_test.go new file mode 100644 index 000000000..f8224bf7e --- /dev/null +++ b/pkg/registry/softwarecomposition/collapseconfiguration/strategy_test.go @@ -0,0 +1,148 @@ +/* +Copyright 2024 The Kubescape Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package collapseconfiguration + +import ( + "context" + "testing" + + "github.com/kubescape/storage/pkg/apis/softwarecomposition" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// runtimeSchemeStub is the minimal ObjectTyper the strategy embeds; we never +// actually call its methods in these tests, so a plain new scheme suffices. +func newScheme() runtime.ObjectTyper { + return runtime.NewScheme() +} + +func TestNamespaceScoped(t *testing.T) { + s := NewStrategy(newScheme()) + if s.NamespaceScoped() { + t.Fatalf("CollapseConfiguration must be cluster-scoped") + } +} + +func TestValidate_Valid(t *testing.T) { + s := NewStrategy(newScheme()) + cc := &softwarecomposition.CollapseConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: "default"}, + Spec: softwarecomposition.CollapseConfigurationSpec{ + OpenDynamicThreshold: 50, + EndpointDynamicThreshold: 100, + CollapseConfigs: []softwarecomposition.CollapseConfigEntry{ + {Prefix: "/etc", Threshold: 100}, + {Prefix: "/var/log", Threshold: 50}, + {Prefix: "/opt", Threshold: 50}, + }, + }, + } + if errs := s.Validate(context.Background(), cc); len(errs) != 0 { + t.Fatalf("expected no validation errors, got: %v", errs) + } +} + +func TestValidate_NegativeThresholds(t *testing.T) { + s := NewStrategy(newScheme()) + cc := &softwarecomposition.CollapseConfiguration{ + Spec: softwarecomposition.CollapseConfigurationSpec{ + OpenDynamicThreshold: -1, + EndpointDynamicThreshold: -1, + }, + } + errs := s.Validate(context.Background(), cc) + if len(errs) != 2 { + t.Fatalf("expected 2 errors for the two negative defaults, got %d: %v", len(errs), errs) + } +} + +func TestValidate_EntryRules(t *testing.T) { + s := NewStrategy(newScheme()) + cc := &softwarecomposition.CollapseConfiguration{ + Spec: softwarecomposition.CollapseConfigurationSpec{ + OpenDynamicThreshold: 50, + EndpointDynamicThreshold: 100, + CollapseConfigs: []softwarecomposition.CollapseConfigEntry{ + {Prefix: "", Threshold: 50}, // empty prefix + {Prefix: "etc", Threshold: 50}, // missing leading slash + {Prefix: "/opt", Threshold: 0}, // threshold below 1 + {Prefix: "/etc", Threshold: 100}, // first /etc + {Prefix: "/etc", Threshold: 50}, // duplicate /etc + }, + }, + } + errs := s.Validate(context.Background(), cc) + // We expect 4 errors: empty prefix, missing leading slash, threshold<1, + // duplicate. (The first /etc entry is fine on its own.) + if len(errs) < 4 { + t.Fatalf("expected at least 4 entry-level errors, got %d: %v", len(errs), errs) + } +} + +func TestValidate_RejectsNonCC(t *testing.T) { + s := NewStrategy(newScheme()) + // Pass a different type to confirm the type assertion fails cleanly. + notACC := &softwarecomposition.ApplicationProfile{} + errs := s.Validate(context.Background(), notACC) + if len(errs) != 1 { + t.Fatalf("expected 1 internal error for type mismatch, got: %v", errs) + } +} + +func TestValidateUpdate_SameRules(t *testing.T) { + s := NewStrategy(newScheme()) + old := &softwarecomposition.CollapseConfiguration{Spec: softwarecomposition.CollapseConfigurationSpec{OpenDynamicThreshold: 50}} + updated := &softwarecomposition.CollapseConfiguration{ + Spec: softwarecomposition.CollapseConfigurationSpec{ + OpenDynamicThreshold: 50, + CollapseConfigs: []softwarecomposition.CollapseConfigEntry{ + {Prefix: "/etc", Threshold: -1}, // bad threshold on update + }, + }, + } + errs := s.ValidateUpdate(context.Background(), updated, old) + if len(errs) == 0 { + t.Fatalf("expected ValidateUpdate to flag threshold < 1") + } +} + +func TestSelectableFieldsAndAttrs(t *testing.T) { + cc := &softwarecomposition.CollapseConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: "default", Labels: map[string]string{"k": "v"}}, + } + lbl, _, err := GetAttrs(cc) + if err != nil { + t.Fatalf("GetAttrs: %v", err) + } + if lbl.Get("k") != "v" { + t.Fatalf("labels round-trip broken: got %q", lbl.Get("k")) + } + // Sanity: SelectableFields includes the name. + fs := SelectableFields(cc) + if fs.Get("metadata.name") != "default" { + t.Fatalf("SelectableFields name = %q, want %q", fs.Get("metadata.name"), "default") + } +} + +func TestGetAttrs_RejectsNonCC(t *testing.T) { + notACC := &softwarecomposition.ApplicationProfile{} + _, _, err := GetAttrs(notACC) + if err == nil { + t.Fatalf("GetAttrs should reject non-CollapseConfiguration objects") + } +} From 4986919e7258000e8128ff57e0e6e52cd6e178cb Mon Sep 17 00:00:00 2001 From: entlein Date: Sat, 16 May 2026 13:12:50 +0200 Subject: [PATCH 6/7] apply rabbit feedback: align CollapseConfig CRD + processors with rc1 final state Signed-off-by: entlein --- .../file/applicationprofile_processor.go | 15 ++++++++- ...rofile_processor_collapse_provider_test.go | 32 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/pkg/registry/file/applicationprofile_processor.go b/pkg/registry/file/applicationprofile_processor.go index e37c6ae50..ad4b75aad 100644 --- a/pkg/registry/file/applicationprofile_processor.go +++ b/pkg/registry/file/applicationprofile_processor.go @@ -51,6 +51,19 @@ func (a *ApplicationProfileProcessor) SetCollapseSettings(p dynamicpathdetector. a.collapseSettings = p } +// effectiveCollapseSettings is the safe accessor for the deflate path. It +// returns the result of the configured provider, or — when the processor +// was constructed without using NewApplicationProfileProcessor (zero-value +// field, no factory call) — the compiled-in defaults. Without this guard, +// any direct struct-literal construction would nil-deref at deflate time. +// CodeRabbit upstream PR #326 finding #3. +func (a *ApplicationProfileProcessor) effectiveCollapseSettings() dynamicpathdetector.CollapseSettings { + if a.collapseSettings == nil { + return dynamicpathdetector.DefaultCollapseSettings() + } + return a.collapseSettings() +} + var _ Processor = (*ApplicationProfileProcessor)(nil) func (a *ApplicationProfileProcessor) AfterCreate(_ context.Context, _ runtime.Object) error { @@ -89,7 +102,7 @@ func (a *ApplicationProfileProcessor) PreSave(ctx context.Context, object runtim } else { logger.L().Debug("failed to get sbom name", loggerhelpers.Error(err), loggerhelpers.String("imageTag", container.ImageTag), loggerhelpers.String("imageID", container.ImageID)) } - containers[i] = deflateApplicationProfileContainer(container, sbomSet, a.collapseSettings()) + containers[i] = deflateApplicationProfileContainer(container, sbomSet, a.effectiveCollapseSettings()) size += len(containers[i].Execs) size += len(containers[i].Opens) size += len(containers[i].Syscalls) diff --git a/pkg/registry/file/applicationprofile_processor_collapse_provider_test.go b/pkg/registry/file/applicationprofile_processor_collapse_provider_test.go index 172003fa3..78f79f525 100644 --- a/pkg/registry/file/applicationprofile_processor_collapse_provider_test.go +++ b/pkg/registry/file/applicationprofile_processor_collapse_provider_test.go @@ -24,6 +24,7 @@ import ( "github.com/kubescape/storage/pkg/config" "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // TestApplicationProfileProcessor_DefaultCollapseSettings_Wired pins that @@ -153,6 +154,37 @@ func TestApplicationProfileProcessor_SetCollapseSettings_DefensiveSetterCopy(t * "after mutating the captured slice, the provider returns the new threshold and paths stay distinct") } +// TestApplicationProfileProcessor_ZeroValue_NoPanicOnCollapseSettings pins +// the defensive contract that a zero-valued ApplicationProfileProcessor +// — constructed with `&ApplicationProfileProcessor{...}` instead of via +// the NewApplicationProfileProcessor factory — must not panic when +// PreSave reaches the deflate path. The compiled-in defaults are an +// acceptable fallback; a nil-function dereference is not. CodeRabbit +// upstream PR #326 finding #3 (applicationprofile_processor.go:92). +func TestApplicationProfileProcessor_ZeroValue_NoPanicOnCollapseSettings(t *testing.T) { + // Direct struct literal — collapseSettings is left as the zero value (nil). + a := &ApplicationProfileProcessor{} + + // The safe accessor must NOT panic. The result must match the + // compiled-in defaults across ALL fields, not just OpenDynamicThreshold — + // otherwise a regression that resets EndpointDynamicThreshold (or any + // future field added to CollapseSettings) to its zero value would + // silently pass this guard. CodeRabbit follow-up review on storage PR #33. + require.NotPanics(t, func() { + got := a.effectiveCollapseSettings() + want := dynamicpathdetector.DefaultCollapseSettings() + assert.Equal(t, want, got, + "zero-valued processor must fall back to the FULL DefaultCollapseSettings struct, got %+v want %+v", + got, want) + }) + + // Direct field-call still panics — that's an "I know what I'm doing" + // path. The contract is only that the safe accessor (used by PreSave + // → deflate) is panic-free. + assert.Panics(t, func() { _ = a.collapseSettings() }, + "raw field-call on zero-valued processor still panics; only the safe accessor is guarded") +} + // assertSettingsMatchProcessor is a placeholder for richer wiring assertions. // The function exercises a non-nil-provider invocation as a smoke test. func assertSettingsMatchProcessor(t *testing.T, a *ApplicationProfileProcessor, want dynamicpathdetector.CollapseSettings) bool { From 69c895e7538e6d72bcf569303ea6cb6307a7829c Mon Sep 17 00:00:00 2001 From: Entlein Date: Wed, 27 May 2026 18:40:54 +0200 Subject: [PATCH 7/7] fix(apiserver): wire CollapseConfiguration CRD into deflate path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CRD endpoint registered at /apis/.../collapseconfigurations stored operator-supplied CollapseConfiguration manifests but never consulted them — applicationProfileStorageImpl and containerProfileStorageImpl were constructed with the compiled-in DefaultCollapseSettings provider, and no non-test caller invoked SetCollapseSettings / assigned CollapseSettings. Applying artifacts/collapseconfiguration-default- sample.yaml was a no-op. Adds NewCRDCollapseSettingsProvider(storage.Interface) — a closure that reads CollapseConfiguration/default on every deflate call, projects through dynamicpathdetector.CollapseSettingsFromCRD, and falls back to DefaultCollapseSettings when the CR is absent or storage is nil. apiserver.go refactors the processor construction so both processors are reachable after the storage backend is built, then wires the same provider into both via SetCollapseSettings (ApplicationProfileProcessor) and direct field assignment (ContainerProfileProcessor). One shared closure keeps the two compaction paths consistent on every CR edit. The no-cache design lets bobctl autotune-style write→read cycles take effect immediately without restart or invalidation. Deflate frequency is low compared to disk Get latency; if benchmarks ever surface this as hot, wrap with a watched cache. Tests pin five contract points: fall-back on absent CR, faithful projection of an applied CR, nil-storage defence, transient-error fall-back, and live-update without invalidation. Resolves matthyx review on apiserver.go:164 (2026-05-27). Signed-off-by: entlein --- pkg/apiserver/apiserver.go | 34 +++- pkg/registry/file/collapse_config_provider.go | 77 ++++++++ .../file/collapse_config_provider_test.go | 173 ++++++++++++++++++ 3 files changed, 278 insertions(+), 6 deletions(-) create mode 100644 pkg/registry/file/collapse_config_provider.go create mode 100644 pkg/registry/file/collapse_config_provider_test.go diff --git a/pkg/apiserver/apiserver.go b/pkg/apiserver/apiserver.go index 0a463c3b9..28d0b042b 100644 --- a/pkg/apiserver/apiserver.go +++ b/pkg/apiserver/apiserver.go @@ -140,15 +140,23 @@ func (c completedConfig) New() (*WardleServer, error) { apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(softwarecomposition.GroupName, Scheme, metav1.ParameterCodec, Codecs) + // Construct processors first so we can wire the CollapseConfiguration + // CRD provider into them AFTER the application/container storage + // backends are built — chicken-and-egg: the provider needs storage to + // read the CR, processors are baked into the storage backend. + applicationProfileProcessor := file.NewApplicationProfileProcessor(c.ExtraConfig.StorageConfig) + containerProfileProcessor := file.NewContainerProfileProcessor(c.ExtraConfig.StorageConfig, c.ExtraConfig.CleanupHandler) + var ( storageImpl = file.NewStorageImpl(c.ExtraConfig.OsFs, file.DefaultStorageRoot, c.ExtraConfig.Pool, c.ExtraConfig.WatchDispatcher, Scheme) - applicationProfileStorageImpl = file.NewApplicationProfileStorage(file.NewStorageImplWithCollector(c.ExtraConfig.OsFs, file.DefaultStorageRoot, c.ExtraConfig.Pool, c.ExtraConfig.WatchDispatcher, Scheme, file.NewApplicationProfileProcessor(c.ExtraConfig.StorageConfig))) - containerProfileStorageImpl = file.NewStorageImplWithCollector(c.ExtraConfig.OsFs, file.DefaultStorageRoot, c.ExtraConfig.Pool, c.ExtraConfig.WatchDispatcher, Scheme, file.NewContainerProfileProcessor(c.ExtraConfig.StorageConfig, c.ExtraConfig.CleanupHandler)) - networkNeighborhoodStorageImpl = file.NewNetworkNeighborhoodStorage(file.NewStorageImplWithCollector(c.ExtraConfig.OsFs, file.DefaultStorageRoot, c.ExtraConfig.Pool, c.ExtraConfig.WatchDispatcher, Scheme, file.NewNetworkNeighborhoodProcessor(c.ExtraConfig.StorageConfig))) - configScanStorageImpl = file.NewConfigurationScanSummaryStorage(storageImpl) - vulnerabilitySummaryStorage = file.NewVulnerabilitySummaryStorage(storageImpl) - generatedNetworkPolicyStorage = file.NewGeneratedNetworkPolicyStorage(storageImpl, networkNeighborhoodStorageImpl) + applicationProfileStorageBackend = file.NewStorageImplWithCollector(c.ExtraConfig.OsFs, file.DefaultStorageRoot, c.ExtraConfig.Pool, c.ExtraConfig.WatchDispatcher, Scheme, applicationProfileProcessor) + applicationProfileStorageImpl = file.NewApplicationProfileStorage(applicationProfileStorageBackend) + containerProfileStorageImpl = file.NewStorageImplWithCollector(c.ExtraConfig.OsFs, file.DefaultStorageRoot, c.ExtraConfig.Pool, c.ExtraConfig.WatchDispatcher, Scheme, containerProfileProcessor) + networkNeighborhoodStorageImpl = file.NewNetworkNeighborhoodStorage(file.NewStorageImplWithCollector(c.ExtraConfig.OsFs, file.DefaultStorageRoot, c.ExtraConfig.Pool, c.ExtraConfig.WatchDispatcher, Scheme, file.NewNetworkNeighborhoodProcessor(c.ExtraConfig.StorageConfig))) + configScanStorageImpl = file.NewConfigurationScanSummaryStorage(storageImpl) + vulnerabilitySummaryStorage = file.NewVulnerabilitySummaryStorage(storageImpl) + generatedNetworkPolicyStorage = file.NewGeneratedNetworkPolicyStorage(storageImpl, networkNeighborhoodStorageImpl) // REST endpoint registration, defaults to storageImpl. ep = func(f func(*runtime.Scheme, storage.Interface, generic.RESTOptionsGetter) (*registry.REST, error), s ...storage.Interface) *registry.REST { @@ -159,6 +167,20 @@ func (c completedConfig) New() (*WardleServer, error) { return sbomregistry.RESTInPeace(f(Scheme, si, c.GenericConfig.RESTOptionsGetter)) } ) + + // Wire the CollapseConfiguration CRD into the live deflate path: both + // processors read effective thresholds from CollapseConfiguration/default + // (cluster-scoped) on every compaction, falling back to compiled-in + // defaults when the CR is absent. Without this the CRD endpoint + // registered below stores the manifest but never consults it — applying + // the artifacts/collapseconfiguration-default-sample.yaml manifest would + // be a no-op (matthyx review on apiserver.go:164, 2026-05-27). + // + // One shared provider closure is wired into both processors so a single + // CR update affects both compaction paths consistently. + collapseSettingsFromCRD := file.NewCRDCollapseSettingsProvider(applicationProfileStorageBackend) + applicationProfileProcessor.SetCollapseSettings(collapseSettingsFromCRD) + containerProfileProcessor.CollapseSettings = collapseSettingsFromCRD apiGroupInfo.VersionedResourcesStorageMap["v1beta1"] = map[string]rest.Storage{ "applicationprofiles": ep(applicationprofile.NewREST, applicationProfileStorageImpl), "collapseconfigurations": ep(collapseconfiguration.NewREST), diff --git a/pkg/registry/file/collapse_config_provider.go b/pkg/registry/file/collapse_config_provider.go new file mode 100644 index 000000000..005236505 --- /dev/null +++ b/pkg/registry/file/collapse_config_provider.go @@ -0,0 +1,77 @@ +/* +Copyright 2024 The Kubescape Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package file + +import ( + "context" + + "github.com/kubescape/storage/pkg/apis/softwarecomposition" + "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" + "k8s.io/apiserver/pkg/storage" +) + +// DefaultCollapseConfigurationName is the cluster-scoped CR name the +// deflate path reads to learn effective collapse thresholds. Operators +// (and the bobctl autotune flow) write/edit this CR; if it is absent +// the provider falls back to dynamicpathdetector.DefaultCollapseSettings. +const DefaultCollapseConfigurationName = "default" + +// collapseConfigurationKey is the in-storage key for the cluster-scoped +// CollapseConfiguration/default CR. Built from the same K8sKeysToPath +// helper the rest of this package uses, so it stays in sync with how +// the CR is written by the apiserver's REST endpoint. +// +// CollapseConfiguration is cluster-scoped (NamespaceScoped() == false +// in pkg/registry/softwarecomposition/collapseconfiguration/strategy.go), +// so namespace is the empty string. +func collapseConfigurationKey(name string) string { + return K8sKeysToPath("", "spdx.softwarecomposition.kubescape.io", "collapseconfigurations", "", "", name) +} + +// NewCRDCollapseSettingsProvider returns a CollapseSettingsProvider +// closure that, on each invocation, looks up the cluster-scoped +// CollapseConfiguration/ in storage +// and projects it via dynamicpathdetector.CollapseSettingsFromCRD. If +// the CR is missing, unreadable, or storage is nil, the provider +// returns dynamicpathdetector.DefaultCollapseSettings so the deflate +// path always has working thresholds. +// +// This is the wire between the apiserver's CRD endpoint (registered at +// /apis/.../collapseconfigurations in pkg/apiserver/apiserver.go) and +// the in-process application/container profile processors that perform +// compaction. Without this provider the CRD is stored but never +// consulted — applying a CollapseConfiguration manifest would be a +// no-op (matthyx review on pkg/apiserver/apiserver.go:164, 2026-05-27). +// +// The closure performs a storage Get per call rather than caching, so +// edits to the CR take effect on the next deflate without restart or +// manual invalidation. Deflate frequency is low compared to disk Get +// latency, so the simplicity wins; if benchmarks ever surface this +// as hot, wrap with a watched cache. +func NewCRDCollapseSettingsProvider(s storage.Interface) dynamicpathdetector.CollapseSettingsProvider { + if s == nil { + return dynamicpathdetector.DefaultCollapseSettings + } + key := collapseConfigurationKey(DefaultCollapseConfigurationName) + return func() dynamicpathdetector.CollapseSettings { + crd := &softwarecomposition.CollapseConfiguration{} + // IgnoreNotFound returns the zero-valued CR with nil error when + // the CR is missing — the operator hasn't applied a manifest + // yet, which is the common bootstrap case. Distinguish by + // checking ObjectMeta.Name (the storage layer only populates + // it when a real CR was decoded). + err := s.Get(context.Background(), key, storage.GetOptions{IgnoreNotFound: true}, crd) + if err != nil || crd.Name == "" { + return dynamicpathdetector.DefaultCollapseSettings() + } + return dynamicpathdetector.CollapseSettingsFromCRD(crd) + } +} diff --git a/pkg/registry/file/collapse_config_provider_test.go b/pkg/registry/file/collapse_config_provider_test.go new file mode 100644 index 000000000..0d46a0233 --- /dev/null +++ b/pkg/registry/file/collapse_config_provider_test.go @@ -0,0 +1,173 @@ +/* +Copyright 2024 The Kubescape Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package file + +import ( + "context" + "fmt" + "testing" + + "github.com/kubescape/storage/pkg/apis/softwarecomposition" + "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/apiserver/pkg/storage" +) + +// fakeCollapseStorage is the minimal storage.Interface that NewCRDCollapseSettingsProvider +// exercises — Get only. Everything else returns "not implemented" so that any +// accidental dependency surfaces immediately rather than silently no-oping. +type fakeCollapseStorage struct { + storage.Interface // nil — panics on unimplemented methods if called + stored map[string]runtime.Object + getErr error +} + +func (f *fakeCollapseStorage) Get(_ context.Context, key string, opts storage.GetOptions, out runtime.Object) error { + if f.getErr != nil { + return f.getErr + } + obj, ok := f.stored[key] + if !ok { + if opts.IgnoreNotFound { + // Mimic the real storage IgnoreNotFound contract: zero the out and + // return nil. Caller must distinguish "not found" via empty + // ObjectMeta.Name. + return nil + } + return storage.NewKeyNotFoundError(key, 0) + } + // Copy into out via reflect to satisfy the Get(out runtime.Object) contract. + switch dst := out.(type) { + case *softwarecomposition.CollapseConfiguration: + src := obj.(*softwarecomposition.CollapseConfiguration) + *dst = *src + default: + return fmt.Errorf("fakeCollapseStorage: unhandled out type %T", dst) + } + return nil +} + +// Watch is required by storage.Interface but not exercised here. +func (f *fakeCollapseStorage) Watch(_ context.Context, _ string, _ storage.ListOptions) (watch.Interface, error) { + return nil, fmt.Errorf("fakeCollapseStorage: Watch not implemented") +} + +// TestNewCRDCollapseSettingsProvider_FallsBackOnAbsentCR pins matthyx's +// blocker fix: the provider must fall back to DefaultCollapseSettings when +// the CollapseConfiguration/default CR is not present in storage, so a +// fresh cluster boots with working thresholds before any operator applies +// the manifest. +func TestNewCRDCollapseSettingsProvider_FallsBackOnAbsentCR(t *testing.T) { + s := &fakeCollapseStorage{stored: map[string]runtime.Object{}} + provider := NewCRDCollapseSettingsProvider(s) + require.NotNil(t, provider) + + got := provider() + want := dynamicpathdetector.DefaultCollapseSettings() + assert.Equal(t, want.OpenDynamicThreshold, got.OpenDynamicThreshold, "OpenDynamicThreshold falls back to default") + assert.Equal(t, want.EndpointDynamicThreshold, got.EndpointDynamicThreshold, "EndpointDynamicThreshold falls back to default") + assert.Equal(t, want.CollapseConfigs, got.CollapseConfigs, "CollapseConfigs falls back to default") +} + +// TestNewCRDCollapseSettingsProvider_ReadsAppliedCR pins the core wiring +// contract matthyx asked for: when a CollapseConfiguration manifest IS +// applied, the deflate path's effective settings reflect the CR rather +// than the compiled-in defaults. Without this wiring the CRD endpoint +// would be a no-op (matthyx review on apiserver.go:164, 2026-05-27). +func TestNewCRDCollapseSettingsProvider_ReadsAppliedCR(t *testing.T) { + applied := &softwarecomposition.CollapseConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: DefaultCollapseConfigurationName}, + Spec: softwarecomposition.CollapseConfigurationSpec{ + OpenDynamicThreshold: 1234, + EndpointDynamicThreshold: 5678, + CollapseConfigs: []softwarecomposition.CollapseConfigEntry{ + {Prefix: "/etc", Threshold: 9}, + {Prefix: "/app", Threshold: 1}, + }, + }, + } + s := &fakeCollapseStorage{ + stored: map[string]runtime.Object{ + collapseConfigurationKey(DefaultCollapseConfigurationName): applied, + }, + } + + provider := NewCRDCollapseSettingsProvider(s) + got := provider() + + assert.Equal(t, 1234, got.OpenDynamicThreshold) + assert.Equal(t, 5678, got.EndpointDynamicThreshold) + require.Len(t, got.CollapseConfigs, 2) + assert.Equal(t, "/etc", got.CollapseConfigs[0].Prefix) + assert.Equal(t, 9, got.CollapseConfigs[0].Threshold) + assert.Equal(t, "/app", got.CollapseConfigs[1].Prefix) + assert.Equal(t, 1, got.CollapseConfigs[1].Threshold) +} + +// TestNewCRDCollapseSettingsProvider_NilStorageReturnsDefault pins the +// defensive contract: if a caller wires a nil storage the provider must +// silently degrade to defaults rather than panic at deflate time. +func TestNewCRDCollapseSettingsProvider_NilStorageReturnsDefault(t *testing.T) { + provider := NewCRDCollapseSettingsProvider(nil) + require.NotNil(t, provider) + + got := provider() + want := dynamicpathdetector.DefaultCollapseSettings() + assert.Equal(t, want.OpenDynamicThreshold, got.OpenDynamicThreshold) + assert.Equal(t, want.EndpointDynamicThreshold, got.EndpointDynamicThreshold) + assert.Equal(t, want.CollapseConfigs, got.CollapseConfigs) +} + +// TestNewCRDCollapseSettingsProvider_GetErrorFallsBackToDefault pins +// that transient storage errors do not crash the deflate path — the +// provider returns the compiled-in defaults so compaction continues. +func TestNewCRDCollapseSettingsProvider_GetErrorFallsBackToDefault(t *testing.T) { + s := &fakeCollapseStorage{ + stored: map[string]runtime.Object{}, + getErr: fmt.Errorf("simulated read error"), + } + provider := NewCRDCollapseSettingsProvider(s) + + got := provider() + want := dynamicpathdetector.DefaultCollapseSettings() + assert.Equal(t, want.OpenDynamicThreshold, got.OpenDynamicThreshold) +} + +// TestNewCRDCollapseSettingsProvider_LiveUpdate pins the no-cache +// design: edits to the CR take effect on the very next provider call, +// without restart or manual invalidation. bobctl autotune relies on +// this when it pushes tuned thresholds back into the cluster. +func TestNewCRDCollapseSettingsProvider_LiveUpdate(t *testing.T) { + v1 := &softwarecomposition.CollapseConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: DefaultCollapseConfigurationName}, + Spec: softwarecomposition.CollapseConfigurationSpec{OpenDynamicThreshold: 100}, + } + s := &fakeCollapseStorage{ + stored: map[string]runtime.Object{ + collapseConfigurationKey(DefaultCollapseConfigurationName): v1, + }, + } + provider := NewCRDCollapseSettingsProvider(s) + + assert.Equal(t, 100, provider().OpenDynamicThreshold) + + // Operator edits the CR (or bobctl autotune writes a new value). + s.stored[collapseConfigurationKey(DefaultCollapseConfigurationName)] = &softwarecomposition.CollapseConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: DefaultCollapseConfigurationName}, + Spec: softwarecomposition.CollapseConfigurationSpec{OpenDynamicThreshold: 200}, + } + + assert.Equal(t, 200, provider().OpenDynamicThreshold, "next call reflects the CR edit without invalidation") +}