diff --git a/api/config/appstudio.redhat.com_enterprisecontractpolicies.yaml b/api/config/appstudio.redhat.com_enterprisecontractpolicies.yaml index ccdfef3b..aee7763c 100644 --- a/api/config/appstudio.redhat.com_enterprisecontractpolicies.yaml +++ b/api/config/appstudio.redhat.com_enterprisecontractpolicies.yaml @@ -159,6 +159,17 @@ spec: items: description: VolatileCriteria includes or excludes a policy rule with effective dates as an option. properties: + componentNames: + description: |- + ComponentNames is used to specify component names from + ApplicationSnapshot. This allows filtering in scenarios where + multiple components share the same image repository. + items: + type: string + minLength: 1 + minItems: 1 + type: array + x-kubernetes-list-type: set effectiveOn: format: date-time type: string @@ -195,6 +206,17 @@ spec: items: description: VolatileCriteria includes or excludes a policy rule with effective dates as an option. properties: + componentNames: + description: |- + ComponentNames is used to specify component names from + ApplicationSnapshot. This allows filtering in scenarios where + multiple components share the same image repository. + items: + type: string + minLength: 1 + minItems: 1 + type: array + x-kubernetes-list-type: set effectiveOn: format: date-time type: string diff --git a/api/v1alpha1/enterprisecontractpolicy_types.go b/api/v1alpha1/enterprisecontractpolicy_types.go index 3e937275..644281f1 100644 --- a/api/v1alpha1/enterprisecontractpolicy_types.go +++ b/api/v1alpha1/enterprisecontractpolicy_types.go @@ -116,6 +116,15 @@ type VolatileCriteria struct { // +kubebuilder:validation:Pattern=`^[a-z0-9][a-z0-9.-]*[a-z0-9](?:\/[a-z0-9][a-z0-9-]*[a-z0-9]){2,}$` ImageUrl string `json:"imageUrl,omitempty"` + // ComponentNames is used to specify component names from + // ApplicationSnapshot. This allows filtering in scenarios where + // multiple components share the same image repository. + // +optional + // +kubebuilder:validation:MinItems:=1 + // +kubebuilder:validation:items:MinLength=1 + // +listType:=set + ComponentNames []string `json:"componentNames,omitempty"` + // Reference is used to include a link to related information such as a Jira issue URL. // +optional Reference string `json:"reference,omitempty"` diff --git a/api/v1alpha1/enterprisecontractpolicy_types_test.go b/api/v1alpha1/enterprisecontractpolicy_types_test.go index 29c956ef..8351f772 100644 --- a/api/v1alpha1/enterprisecontractpolicy_types_test.go +++ b/api/v1alpha1/enterprisecontractpolicy_types_test.go @@ -396,3 +396,129 @@ func TestReferenceField(t *testing.T) { }) } } + +func TestComponentNamesField(t *testing.T) { + tests := []struct { + name string + componentNames []string + wantValid bool + omitField bool // true if the field should be omitted entirely + }{ + // Valid cases + {"Single component", []string{"component1"}, true, false}, + {"Multiple components", []string{"component1", "component2", "component3"}, true, false}, + {"Component with hyphens", []string{"my-component"}, true, false}, + {"Component with numbers", []string{"component123"}, true, false}, + {"Omitted field", nil, true, true}, // Field is omitted entirely + + // Invalid cases + {"Empty array", []string{}, false, false}, // Violates MinItems:=1 + {"Empty string", []string{""}, false, false}, // Violates items:MinLength:=1 + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a policy with the test component names + policy := EnterpriseContractPolicy{ + Spec: EnterpriseContractPolicySpec{ + Sources: []Source{ + { + VolatileConfig: &VolatileSourceConfig{ + Exclude: []VolatileCriteria{ + { + Value: "test-rule", + }, + }, + }, + }, + }, + }, + } + if !tt.omitField { + policy.Spec.Sources[0].VolatileConfig.Exclude[0].ComponentNames = tt.componentNames + } + + // Create a CRD validation schema + crd := v1.CustomResourceDefinition{} + bytes, err := os.ReadFile("../../config/crd/bases/appstudio.redhat.com_enterprisecontractpolicies.yaml") + if err != nil { + t.Fatalf("unexpected error reading CRD: %s", err) + } + if err := yaml.Unmarshal(bytes, &crd); err != nil { + t.Fatalf("unexpected error when decoding schema: %s", err) + } + + crdv := apiextensions.CustomResourceValidation{} + if err := v1.Convert_v1_CustomResourceValidation_To_apiextensions_CustomResourceValidation(crd.Spec.Versions[0].Schema, &crdv, nil); err != nil { + t.Fatalf("failed in CRD validation conversion: %s", err) + } + + s, err := schema.NewStructural(crdv.OpenAPIV3Schema) + if err != nil { + t.Fatalf("unexpected error when creating structural: %s", err) + } + + v := validation.NewSchemaValidatorFromOpenAPI(s.ToKubeOpenAPI()) + + // Convert policy to unstructured for validation + obj := unstructured.Unstructured{} + obj.SetUnstructuredContent(map[string]interface{}{ + "apiVersion": "appstudio.redhat.com/v1alpha1", + "kind": "EnterpriseContractPolicy", + "spec": map[string]interface{}{ + "sources": []interface{}{ + map[string]interface{}{ + "volatileConfig": map[string]interface{}{ + "exclude": []interface{}{ + func() map[string]interface{} { + m := map[string]interface{}{ + "value": "test-rule", + } + if !tt.omitField { + // Convert []string to []interface{} + componentNamesInterface := make([]interface{}, len(tt.componentNames)) + for i, name := range tt.componentNames { + componentNamesInterface[i] = name + } + m["componentNames"] = componentNamesInterface + } + return m + }(), + }, + }, + }, + }, + }, + }) + + // Validate the object + result := v.Validate(&obj) + isValid := result.IsValid() + + if isValid != tt.wantValid { + t.Errorf("Validation for %v = %v, want %v. Errors: %v", tt.componentNames, isValid, tt.wantValid, result.Errors) + } + + // Also validate the actual policy object + // Note: Empty arrays with omitempty are omitted during JSON marshaling, + // so they're treated as omitted fields and pass validation + if tt.wantValid && !tt.omitField { + policyObj := unstructured.Unstructured{} + policyBytes, err := json.Marshal(policy) + if err != nil { + t.Fatalf("unexpected error marshaling policy: %s", err) + } + if err := json.Unmarshal(policyBytes, &policyObj.Object); err != nil { + t.Fatalf("unexpected error unmarshaling policy: %s", err) + } + + policyResult := v.Validate(&policyObj) + policyIsValid := policyResult.IsValid() + + if policyIsValid != tt.wantValid { + t.Errorf("Policy validation for %v = %v, want %v. Errors: %v", tt.componentNames, policyIsValid, tt.wantValid, policyResult.Errors) + } + } + }) + } +} diff --git a/api/v1alpha1/policy_spec.json b/api/v1alpha1/policy_spec.json index 1b7cf03c..e3a1e6d4 100644 --- a/api/v1alpha1/policy_spec.json +++ b/api/v1alpha1/policy_spec.json @@ -180,6 +180,13 @@ "type": "string", "description": "ImageUrl is used to specify an image by its URL without a tag.\n+optional\n+kubebuilder:validation:Pattern=`^[a-z0-9][a-z0-9.-]*[a-z0-9](?:\\/[a-z0-9][a-z0-9-]*[a-z0-9]){2,}$`" }, + "componentNames": { + "items": { + "type": "string" + }, + "type": "array", + "description": "ComponentNames is used to specify component names from\nApplicationSnapshot. This allows filtering in scenarios where\nmultiple components share the same image repository.\n+optional\n+kubebuilder:validation:MinItems:=1\n+kubebuilder:validation:items:MinLength=1\n+listType:=set" + }, "reference": { "type": "string", "description": "Reference is used to include a link to related information such as a Jira issue URL.\n+optional" diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index d8f1c33a..a2170f52 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -244,6 +244,11 @@ func (in *SourceConfig) DeepCopy() *SourceConfig { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VolatileCriteria) DeepCopyInto(out *VolatileCriteria) { *out = *in + if in.ComponentNames != nil { + in, out := &in.ComponentNames, &out.ComponentNames + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VolatileCriteria. @@ -262,12 +267,16 @@ func (in *VolatileSourceConfig) DeepCopyInto(out *VolatileSourceConfig) { if in.Exclude != nil { in, out := &in.Exclude, &out.Exclude *out = make([]VolatileCriteria, len(*in)) - copy(*out, *in) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } if in.Include != nil { in, out := &in.Include, &out.Include *out = make([]VolatileCriteria, len(*in)) - copy(*out, *in) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } } diff --git a/config/crd/bases/appstudio.redhat.com_enterprisecontractpolicies.yaml b/config/crd/bases/appstudio.redhat.com_enterprisecontractpolicies.yaml index ccdfef3b..aee7763c 100644 --- a/config/crd/bases/appstudio.redhat.com_enterprisecontractpolicies.yaml +++ b/config/crd/bases/appstudio.redhat.com_enterprisecontractpolicies.yaml @@ -159,6 +159,17 @@ spec: items: description: VolatileCriteria includes or excludes a policy rule with effective dates as an option. properties: + componentNames: + description: |- + ComponentNames is used to specify component names from + ApplicationSnapshot. This allows filtering in scenarios where + multiple components share the same image repository. + items: + type: string + minLength: 1 + minItems: 1 + type: array + x-kubernetes-list-type: set effectiveOn: format: date-time type: string @@ -195,6 +206,17 @@ spec: items: description: VolatileCriteria includes or excludes a policy rule with effective dates as an option. properties: + componentNames: + description: |- + ComponentNames is used to specify component names from + ApplicationSnapshot. This allows filtering in scenarios where + multiple components share the same image repository. + items: + type: string + minLength: 1 + minItems: 1 + type: array + x-kubernetes-list-type: set effectiveOn: format: date-time type: string diff --git a/docs/modules/ROOT/pages/reference.adoc b/docs/modules/ROOT/pages/reference.adoc index 4d4f0b34..c696b8d0 100644 --- a/docs/modules/ROOT/pages/reference.adoc +++ b/docs/modules/ROOT/pages/reference.adoc @@ -205,6 +205,9 @@ Appears In: xref:{anchor_prefix}-github-com-enterprise-contract-enterprise-contr ImageRef is used to specify an image by its digest. + | *`imageDigest`* __string__ | ImageDigest is used to specify an image by its digest. + | *`imageUrl`* __string__ | ImageUrl is used to specify an image by its URL without a tag. + +| *`componentNames`* __string array__ | ComponentNames is used to specify component names from + +ApplicationSnapshot. This allows filtering in scenarios where + +multiple components share the same image repository. + | *`reference`* __string__ | Reference is used to include a link to related information such as a Jira issue URL. + |===