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/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/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 68922f90a..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) @@ -4199,6 +4371,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))) @@ -9015,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 @@ -10293,6 +10533,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 } @@ -12141,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" @@ -13133,6 +13434,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 @@ -16101,7 +16403,7 @@ func (m *CallStackNode) Unmarshal(dAtA []byte) error { } return nil } -func (m *Component) Unmarshal(dAtA []byte) error { +func (m *CollapseConfigEntry) Unmarshal(dAtA []byte) error { l := len(dAtA) iNdEx := 0 for iNdEx < l { @@ -16124,15 +16426,15 @@ func (m *Component) Unmarshal(dAtA []byte) error { fieldNum := int32(wire >> 3) wireType := int(wire & 0x7) if wireType == 4 { - return fmt.Errorf("proto: Component: wiretype end group for non-group") + return fmt.Errorf("proto: CollapseConfigEntry: wiretype end group for non-group") } if fieldNum <= 0 { - return fmt.Errorf("proto: Component: illegal tag %d (wire type %d)", fieldNum, wire) + 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 ID", wireType) + return fmt.Errorf("proto: wrong wireType = %d for field Prefix", wireType) } var stringLen uint64 for shift := uint(0); ; shift += 7 { @@ -16160,13 +16462,13 @@ func (m *Component) Unmarshal(dAtA []byte) error { if postIndex > l { return io.ErrUnexpectedEOF } - m.ID = string(dAtA[iNdEx:postIndex]) + m.Prefix = string(dAtA[iNdEx:postIndex]) iNdEx = postIndex case 2: - if wireType != 2 { - return fmt.Errorf("proto: wrong wireType = %d for field Hashes", wireType) + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Threshold", wireType) } - var msglen int + m.Threshold = 0 for shift := uint(0); ; shift += 7 { if shift >= 64 { return ErrIntOverflowGenerated @@ -16176,13 +16478,469 @@ func (m *Component) Unmarshal(dAtA []byte) error { } b := dAtA[iNdEx] iNdEx++ - msglen |= int(b&0x7F) << shift + m.Threshold |= int32(b&0x7F) << shift if b < 0x80 { break } } - if msglen < 0 { - return ErrInvalidLengthGenerated + 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 + 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: Component: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: Component: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ID", 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.ID = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Hashes", 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 { @@ -26856,6 +27614,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..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. @@ -869,6 +914,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/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/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/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 03d068836..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 } @@ -3684,6 +3819,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..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 @@ -1963,6 +2060,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/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 de6a86e0e..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 @@ -1970,6 +2067,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/apiserver/apiserver.go b/pkg/apiserver/apiserver.go index b043532ce..28d0b042b 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" @@ -139,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 { @@ -158,8 +167,23 @@ 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), "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..ad4b75aad 100644 --- a/pkg/registry/file/applicationprofile_processor.go +++ b/pkg/registry/file/applicationprofile_processor.go @@ -17,22 +17,51 @@ 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 +} + +// 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) @@ -73,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) + containers[i] = deflateApplicationProfileContainer(container, sbomSet, a.effectiveCollapseSettings()) size += len(containers[i].Execs) size += len(containers[i].Opens) size += len(containers[i].Syscalls) @@ -108,13 +137,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..78f79f525 --- /dev/null +++ b/pkg/registry/file/applicationprofile_processor_collapse_provider_test.go @@ -0,0 +1,202 @@ +/* +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" + "github.com/stretchr/testify/require" +) + +// 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") +} + +// 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 { + 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/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") +} 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/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/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") + } +} 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) + } + } +}