diff --git a/pkg/generators/markers.go b/pkg/generators/markers.go index 7f0fe985a..6b7f63b8a 100644 --- a/pkg/generators/markers.go +++ b/pkg/generators/markers.go @@ -61,6 +61,13 @@ func (c *CELTag) Validate() error { return nil } +type KindScope string + +var ( + KindScopeNamespaced KindScope = "Namespaced" + KindScopeCluster KindScope = "Cluster" +) + // commentTags represents the parsed comment tags for a given type. These types are then used to generate schema validations. // These only include the newer prefixed tags. The older tags are still supported, // but are not included in this struct. Comment Tags are transformed into a @@ -77,7 +84,8 @@ func (c *CELTag) Validate() error { type commentTags struct { spec.SchemaProps - CEL []CELTag `json:"cel,omitempty"` + CEL []CELTag `json:"cel,omitempty"` + Scope *KindScope `json:"scope,omitempty"` // Future markers can all be parsed into this centralized struct... // Optional bool `json:"optional,omitempty"` @@ -91,9 +99,30 @@ func (c commentTags) ValidationSchema() (*spec.Schema, error) { SchemaProps: c.SchemaProps, } - if len(c.CEL) > 0 { + celRules := c.CEL + + if c.Scope != nil { + switch *c.Scope { + case KindScopeCluster: + celRules = append(celRules, CELTag{ + Rule: "self.metadata.namespace.size() == 0", + Message: "not allowed on this type", + Reason: "FieldValueForbidden", + }) + case KindScopeNamespaced: + celRules = append(celRules, CELTag{ + Rule: "self.metadata.namespace.size() > 0", + Message: "", + Reason: "FieldValueRequired", + }) + default: + return nil, fmt.Errorf("invalid scope %q", *c.Scope) + } + } + + if len(celRules) > 0 { // Convert the CELTag to a map[string]interface{} via JSON - celTagJSON, err := json.Marshal(c.CEL) + celTagJSON, err := json.Marshal(celRules) if err != nil { return nil, fmt.Errorf("failed to marshal CEL tag: %w", err) } @@ -164,6 +193,10 @@ func (c commentTags) Validate() error { err = errors.Join(err, fmt.Errorf("invalid CEL tag at index %d: %w", i, celError)) } + if c.Scope != nil && *c.Scope != KindScopeNamespaced && *c.Scope != KindScopeCluster { + err = errors.Join(err, fmt.Errorf("invalid scope %q", *c.Scope)) + } + return err } diff --git a/pkg/generators/markers_test.go b/pkg/generators/markers_test.go index 36f0ff10d..0373356a4 100644 --- a/pkg/generators/markers_test.go +++ b/pkg/generators/markers_test.go @@ -494,6 +494,45 @@ func TestParseCommentTags(t *testing.T) { }, expectedError: `failed to parse marker comments: concatenations to key 'cel[0]:message' must be consecutive with its assignment`, }, + { + name: "namespaced scope", + t: structKind, + comments: []string{ + `+k8s:validation:scope>Namespaced`, + }, + expected: &spec.Schema{ + VendorExtensible: spec.VendorExtensible{ + Extensions: map[string]interface{}{ + "x-kubernetes-validations": []interface{}{ + map[string]interface{}{ + "rule": "self.metadata.namespace.size() > 0", + "reason": "FieldValueRequired", + }, + }, + }, + }, + }, + }, + { + name: "cluster scope", + t: structKind, + comments: []string{ + `+k8s:validation:scope>Cluster`, + }, + expected: &spec.Schema{ + VendorExtensible: spec.VendorExtensible{ + Extensions: map[string]interface{}{ + "x-kubernetes-validations": []interface{}{ + map[string]interface{}{ + "rule": "self.metadata.namespace.size() == 0", + "message": "not allowed on this type", + "reason": "FieldValueForbidden", + }, + }, + }, + }, + }, + }, } for _, tc := range cases { diff --git a/test/integration/pkg/generated/openapi_generated.go b/test/integration/pkg/generated/openapi_generated.go index 993ef5815..b4e37c470 100644 --- a/test/integration/pkg/generated/openapi_generated.go +++ b/test/integration/pkg/generated/openapi_generated.go @@ -1092,7 +1092,7 @@ func schema_test_integration_testdata_valuevalidation_Foo(ref common.ReferenceCa }, VendorExtensible: spec.VendorExtensible{ Extensions: spec.Extensions{ - "x-kubernetes-validations": []interface{}{map[string]interface{}{"message": "foo", "rule": "self == oldSelf"}}, + "x-kubernetes-validations": []interface{}{map[string]interface{}{"message": "foo", "rule": "self == oldSelf"}, map[string]interface{}{"reason": "FieldValueRequired", "rule": "self.metadata.namespace.size() > 0"}}, }, }, }, diff --git a/test/integration/testdata/golden.v2.json b/test/integration/testdata/golden.v2.json index 08f757e23..f2f3a45eb 100644 --- a/test/integration/testdata/golden.v2.json +++ b/test/integration/testdata/golden.v2.json @@ -1285,6 +1285,10 @@ { "message": "foo", "rule": "self == oldSelf" + }, + { + "reason": "FieldValueRequired", + "rule": "self.metadata.namespace.size() \u003e 0" } ] }, diff --git a/test/integration/testdata/golden.v3.json b/test/integration/testdata/golden.v3.json index 7a3b832a8..f42ffd6c0 100644 --- a/test/integration/testdata/golden.v3.json +++ b/test/integration/testdata/golden.v3.json @@ -1247,6 +1247,10 @@ { "message": "foo", "rule": "self == oldSelf" + }, + { + "reason": "FieldValueRequired", + "rule": "self.metadata.namespace.size() \u003e 0" } ] }, diff --git a/test/integration/testdata/valuevalidation/alpha.go b/test/integration/testdata/valuevalidation/alpha.go index cedf90aa2..eb1974a71 100644 --- a/test/integration/testdata/valuevalidation/alpha.go +++ b/test/integration/testdata/valuevalidation/alpha.go @@ -14,6 +14,7 @@ import ( // +k8s:openapi-gen=true // +k8s:validation:cel[0]:rule="self == oldSelf" // +k8s:validation:cel[0]:message="foo" +// +k8s:validation:scope>Namespaced type Foo struct { // +k8s:validation:maxLength=5 // +k8s:validation:minLength=1