Skip to content

Commit b5b198b

Browse files
authored
Merge pull request #21 from Acepresso/exclude-components-EC-1513
Add componentNames field to VolatileCriteria
2 parents 875ef08 + 821261d commit b5b198b

File tree

6 files changed

+312
-2
lines changed

6 files changed

+312
-2
lines changed

api/config/appstudio.redhat.com_enterprisecontractpolicies.yaml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,16 @@ spec:
159159
items:
160160
description: VolatileCriteria includes or excludes a policy rule with effective dates as an option.
161161
properties:
162+
componentNames:
163+
description: |-
164+
ComponentNames is used to specify component names from
165+
ApplicationSnapshot. This allows filtering in scenarios where
166+
multiple components share the same image repository.
167+
items:
168+
minLength: 1
169+
type: string
170+
type: array
171+
x-kubernetes-list-type: set
162172
effectiveOn:
163173
format: date-time
164174
type: string
@@ -187,6 +197,9 @@ spec:
187197
required:
188198
- value
189199
type: object
200+
x-kubernetes-validations:
201+
- message: only one of imageUrl, imageDigest, imageRef, or componentNames may be set
202+
rule: '(has(self.imageUrl) ? 1 : 0) + (has(self.imageDigest) ? 1 : 0) + (has(self.imageRef) ? 1 : 0) + (has(self.componentNames) ? 1 : 0) <= 1'
190203
type: array
191204
include:
192205
description: |-
@@ -195,6 +208,16 @@ spec:
195208
items:
196209
description: VolatileCriteria includes or excludes a policy rule with effective dates as an option.
197210
properties:
211+
componentNames:
212+
description: |-
213+
ComponentNames is used to specify component names from
214+
ApplicationSnapshot. This allows filtering in scenarios where
215+
multiple components share the same image repository.
216+
items:
217+
minLength: 1
218+
type: string
219+
type: array
220+
x-kubernetes-list-type: set
198221
effectiveOn:
199222
format: date-time
200223
type: string
@@ -223,6 +246,9 @@ spec:
223246
required:
224247
- value
225248
type: object
249+
x-kubernetes-validations:
250+
- message: only one of imageUrl, imageDigest, imageRef, or componentNames may be set
251+
rule: '(has(self.imageUrl) ? 1 : 0) + (has(self.imageDigest) ? 1 : 0) + (has(self.imageRef) ? 1 : 0) + (has(self.componentNames) ? 1 : 0) <= 1'
226252
type: array
227253
type: object
228254
type: object

api/v1alpha1/enterprisecontractpolicy_types.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,11 @@ type SourceConfig struct {
9090
Include []string `json:"include,omitempty"`
9191
}
9292

93+
// +kubebuilder:validation:MinLength=1
94+
type ComponentName string
95+
9396
// VolatileCriteria includes or excludes a policy rule with effective dates as an option.
97+
// +kubebuilder:validation:XValidation:rule="(has(self.imageUrl) ? 1 : 0) + (has(self.imageDigest) ? 1 : 0) + (has(self.imageRef) ? 1 : 0) + (has(self.componentNames) ? 1 : 0) <= 1",message="only one of imageUrl, imageDigest, imageRef, or componentNames may be set"
9498
type VolatileCriteria struct {
9599
Value string `json:"value"`
96100
// +optional
@@ -116,6 +120,13 @@ type VolatileCriteria struct {
116120
// +kubebuilder:validation:Pattern=`^[a-z0-9][a-z0-9.-]*[a-z0-9](?:\/[a-z0-9][a-z0-9-]*[a-z0-9]){2,}$`
117121
ImageUrl string `json:"imageUrl,omitempty"`
118122

123+
// ComponentNames is used to specify component names from
124+
// ApplicationSnapshot. This allows filtering in scenarios where
125+
// multiple components share the same image repository.
126+
// +optional
127+
// +listType=set
128+
ComponentNames []ComponentName `json:"componentNames,omitempty"`
129+
119130
// Reference is used to include a link to related information such as a Jira issue URL.
120131
// +optional
121132
Reference string `json:"reference,omitempty"`

api/v1alpha1/enterprisecontractpolicy_types_test.go

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,3 +395,234 @@ func TestReferenceField(t *testing.T) {
395395
})
396396
}
397397
}
398+
399+
func TestVolatileCriteriaMutualExclusivity(t *testing.T) {
400+
tests := []struct {
401+
name string
402+
imageUrl string
403+
imageDigest string
404+
imageRef string
405+
componentNames []string
406+
wantValid bool
407+
}{
408+
// Valid cases - only one field set
409+
{"Only imageUrl", "quay.io/org/repo", "", "", nil, true},
410+
{"Only imageDigest", "", "sha256:cfe1335814d92eabecfe9802f13298539caa7bbd0a13b61f320dc45bdded473d", "", nil, true},
411+
{"Only imageRef", "", "", "sha256:cfe1335814d92eabecfe9802f13298539caa7bbd0a13b61f320dc45bdded473d", nil, true},
412+
{"Only componentNames", "", "", "", []string{"component1"}, true},
413+
{"No fields set", "", "", "", nil, true},
414+
415+
// Invalid cases - multiple fields set
416+
{"imageUrl and imageDigest", "quay.io/org/repo", "sha256:cfe1335814d92eabecfe9802f13298539caa7bbd0a13b61f320dc45bdded473d", "", nil, false},
417+
{"imageUrl and imageRef", "quay.io/org/repo", "", "sha256:cfe1335814d92eabecfe9802f13298539caa7bbd0a13b61f320dc45bdded473d", nil, false},
418+
{"imageUrl and componentNames", "quay.io/org/repo", "", "", []string{"component1"}, false},
419+
{"imageDigest and imageRef", "", "sha256:cfe1335814d92eabecfe9802f13298539caa7bbd0a13b61f320dc45bdded473d", "sha256:1f88f9fb4543eadf97afcbd417c258fdf1a02dd000a36e39e7e4649d1b083b4e", nil, false},
420+
{"imageDigest and componentNames", "", "sha256:cfe1335814d92eabecfe9802f13298539caa7bbd0a13b61f320dc45bdded473d", "", []string{"component1"}, false},
421+
{"imageRef and componentNames", "", "", "sha256:cfe1335814d92eabecfe9802f13298539caa7bbd0a13b61f320dc45bdded473d", []string{"component1"}, false},
422+
{"Three fields set", "quay.io/org/repo", "sha256:cfe1335814d92eabecfe9802f13298539caa7bbd0a13b61f320dc45bdded473d", "", []string{"component1"}, false},
423+
{"All fields set", "quay.io/org/repo", "sha256:cfe1335814d92eabecfe9802f13298539caa7bbd0a13b61f320dc45bdded473d", "sha256:1f88f9fb4543eadf97afcbd417c258fdf1a02dd000a36e39e7e4649d1b083b4e", []string{"component1"}, false},
424+
}
425+
426+
for _, tt := range tests {
427+
t.Run(tt.name, func(t *testing.T) {
428+
// Create a CRD validation schema
429+
crd := v1.CustomResourceDefinition{}
430+
bytes, err := os.ReadFile("../../config/crd/bases/appstudio.redhat.com_enterprisecontractpolicies.yaml")
431+
if err != nil {
432+
t.Fatalf("unexpected error reading CRD: %s", err)
433+
}
434+
if err := yaml.Unmarshal(bytes, &crd); err != nil {
435+
t.Fatalf("unexpected error when decoding schema: %s", err)
436+
}
437+
438+
crdv := apiextensions.CustomResourceValidation{}
439+
if err := v1.Convert_v1_CustomResourceValidation_To_apiextensions_CustomResourceValidation(crd.Spec.Versions[0].Schema, &crdv, nil); err != nil {
440+
t.Fatalf("failed in CRD validation conversion: %s", err)
441+
}
442+
443+
s, err := schema.NewStructural(crdv.OpenAPIV3Schema)
444+
if err != nil {
445+
t.Fatalf("unexpected error when creating structural: %s", err)
446+
}
447+
448+
validator := validation.NewSchemaValidatorFromOpenAPI(s.ToKubeOpenAPI())
449+
450+
// Build the volatile criteria map
451+
criteria := map[string]interface{}{
452+
"value": "test-rule",
453+
}
454+
if tt.imageUrl != "" {
455+
criteria["imageUrl"] = tt.imageUrl
456+
}
457+
if tt.imageDigest != "" {
458+
criteria["imageDigest"] = tt.imageDigest
459+
}
460+
if tt.imageRef != "" {
461+
criteria["imageRef"] = tt.imageRef
462+
}
463+
if tt.componentNames != nil {
464+
componentNamesInterface := make([]interface{}, len(tt.componentNames))
465+
for i, name := range tt.componentNames {
466+
componentNamesInterface[i] = name
467+
}
468+
criteria["componentNames"] = componentNamesInterface
469+
}
470+
471+
// Convert policy to unstructured for validation
472+
obj := unstructured.Unstructured{}
473+
obj.SetUnstructuredContent(map[string]interface{}{
474+
"apiVersion": "appstudio.redhat.com/v1alpha1",
475+
"kind": "EnterpriseContractPolicy",
476+
"metadata": map[string]interface{}{
477+
"name": "test-policy",
478+
},
479+
"spec": map[string]interface{}{
480+
"sources": []interface{}{
481+
map[string]interface{}{
482+
"volatileConfig": map[string]interface{}{
483+
"exclude": []interface{}{criteria},
484+
},
485+
},
486+
},
487+
},
488+
})
489+
490+
// Validate using custom resource strategy to include CEL validation
491+
gvk := rts.GroupVersionKind{Group: "appstudio.redhat.com", Version: "v1alpha1", Kind: "EnterpriseContractPolicy"}
492+
errs := customresource.NewStrategy(nil, false, gvk, validator, nil, s, nil, nil).Validate(context.Background(), &obj)
493+
494+
isValid := len(errs) == 0
495+
if isValid != tt.wantValid {
496+
t.Errorf("Validation = %v, want %v. Errors: %v", isValid, tt.wantValid, errs)
497+
}
498+
})
499+
}
500+
}
501+
502+
func TestComponentNamesField(t *testing.T) {
503+
tests := []struct {
504+
name string
505+
componentNames []ComponentName
506+
wantValid bool
507+
omitField bool // true if the field should be omitted entirely
508+
}{
509+
// Valid cases
510+
{"Single component", []ComponentName{"component1"}, true, false},
511+
{"Multiple components", []ComponentName{"component1", "component2", "component3"}, true, false},
512+
{"Component with hyphens", []ComponentName{"my-component"}, true, false},
513+
{"Component with numbers", []ComponentName{"component123"}, true, false},
514+
{"Omitted field", nil, true, true}, // Field is omitted entirely
515+
516+
// Valid edge case
517+
{"Empty array", []ComponentName{}, true, false}, // Empty array is allowed
518+
519+
// Invalid cases
520+
{"Empty string", []ComponentName{""}, false, false}, // Violates MinLength=1
521+
}
522+
523+
for _, tt := range tests {
524+
t.Run(tt.name, func(t *testing.T) {
525+
// Create a policy with the test component names
526+
policy := EnterpriseContractPolicy{
527+
Spec: EnterpriseContractPolicySpec{
528+
Sources: []Source{
529+
{
530+
VolatileConfig: &VolatileSourceConfig{
531+
Exclude: []VolatileCriteria{
532+
{
533+
Value: "test-rule",
534+
},
535+
},
536+
},
537+
},
538+
},
539+
},
540+
}
541+
if !tt.omitField {
542+
policy.Spec.Sources[0].VolatileConfig.Exclude[0].ComponentNames = tt.componentNames
543+
}
544+
545+
// Create a CRD validation schema
546+
crd := v1.CustomResourceDefinition{}
547+
bytes, err := os.ReadFile("../../config/crd/bases/appstudio.redhat.com_enterprisecontractpolicies.yaml")
548+
if err != nil {
549+
t.Fatalf("unexpected error reading CRD: %s", err)
550+
}
551+
if err := yaml.Unmarshal(bytes, &crd); err != nil {
552+
t.Fatalf("unexpected error when decoding schema: %s", err)
553+
}
554+
555+
crdv := apiextensions.CustomResourceValidation{}
556+
if err := v1.Convert_v1_CustomResourceValidation_To_apiextensions_CustomResourceValidation(crd.Spec.Versions[0].Schema, &crdv, nil); err != nil {
557+
t.Fatalf("failed in CRD validation conversion: %s", err)
558+
}
559+
560+
s, err := schema.NewStructural(crdv.OpenAPIV3Schema)
561+
if err != nil {
562+
t.Fatalf("unexpected error when creating structural: %s", err)
563+
}
564+
565+
v := validation.NewSchemaValidatorFromOpenAPI(s.ToKubeOpenAPI())
566+
567+
// Convert policy to unstructured for validation
568+
obj := unstructured.Unstructured{}
569+
obj.SetUnstructuredContent(map[string]interface{}{
570+
"apiVersion": "appstudio.redhat.com/v1alpha1",
571+
"kind": "EnterpriseContractPolicy",
572+
"spec": map[string]interface{}{
573+
"sources": []interface{}{
574+
map[string]interface{}{
575+
"volatileConfig": map[string]interface{}{
576+
"exclude": []interface{}{
577+
func() map[string]interface{} {
578+
m := map[string]interface{}{
579+
"value": "test-rule",
580+
}
581+
if !tt.omitField {
582+
// Convert []string to []interface{}
583+
componentNamesInterface := make([]interface{}, len(tt.componentNames))
584+
for i, name := range tt.componentNames {
585+
componentNamesInterface[i] = name
586+
}
587+
m["componentNames"] = componentNamesInterface
588+
}
589+
return m
590+
}(),
591+
},
592+
},
593+
},
594+
},
595+
},
596+
})
597+
598+
// Validate the object
599+
result := v.Validate(&obj)
600+
isValid := result.IsValid()
601+
602+
if isValid != tt.wantValid {
603+
t.Errorf("Validation for %v = %v, want %v. Errors: %v", tt.componentNames, isValid, tt.wantValid, result.Errors)
604+
}
605+
606+
// Also validate the actual policy object
607+
// Note: Empty arrays with omitempty are omitted during JSON marshaling,
608+
// so they're treated as omitted fields and pass validation
609+
if tt.wantValid && !tt.omitField {
610+
policyObj := unstructured.Unstructured{}
611+
policyBytes, err := json.Marshal(policy)
612+
if err != nil {
613+
t.Fatalf("unexpected error marshaling policy: %s", err)
614+
}
615+
if err := json.Unmarshal(policyBytes, &policyObj.Object); err != nil {
616+
t.Fatalf("unexpected error unmarshaling policy: %s", err)
617+
}
618+
619+
policyResult := v.Validate(&policyObj)
620+
policyIsValid := policyResult.IsValid()
621+
622+
if policyIsValid != tt.wantValid {
623+
t.Errorf("Policy validation for %v = %v, want %v. Errors: %v", tt.componentNames, policyIsValid, tt.wantValid, policyResult.Errors)
624+
}
625+
}
626+
})
627+
}
628+
}

api/v1alpha1/policy_spec.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,13 @@
180180
"type": "string",
181181
"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,}$`"
182182
},
183+
"componentNames": {
184+
"items": {
185+
"type": "string"
186+
},
187+
"type": "array",
188+
"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+listType=set"
189+
},
183190
"reference": {
184191
"type": "string",
185192
"description": "Reference is used to include a link to related information such as a Jira issue URL.\n+optional"

api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 11 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)