diff --git a/api/v1beta1/clusterclass_types.go b/api/v1beta1/clusterclass_types.go index 38c2530cbdd6..0ccc09795893 100644 --- a/api/v1beta1/clusterclass_types.go +++ b/api/v1beta1/clusterclass_types.go @@ -583,6 +583,54 @@ type JSONSchemaProps struct { // It can be used to add additional data for higher level tools. // +optional XMetadata *VariableSchemaMetadata `json:"x-metadata,omitempty"` + + // x-kubernetes-int-or-string specifies that this value is + // either an integer or a string. If this is true, an empty + // type is allowed and type as child of anyOf is permitted + // if following one of the following patterns: + // + // 1) anyOf: + // - type: integer + // - type: string + // 2) allOf: + // - anyOf: + // - type: integer + // - type: string + // - ... zero or more + // +optional + XIntOrString bool `json:"x-kubernetes-int-or-string,omitempty"` + + // AllOf specifies that the variable must validate against all of the subschemas in the array. + // NOTE: This field uses PreserveUnknownFields and Schemaless, + // because recursive validation is not possible. + // +optional + // +kubebuilder:pruning:PreserveUnknownFields + // +kubebuilder:validation:Schemaless + AllOf []JSONSchemaProps `json:"allOf,omitempty"` + + // OneOf specifies that the variable must validate against exactly one of the subschemas in the array. + // NOTE: This field uses PreserveUnknownFields and Schemaless, + // because recursive validation is not possible. + // +optional + // +kubebuilder:pruning:PreserveUnknownFields + // +kubebuilder:validation:Schemaless + OneOf []JSONSchemaProps `json:"oneOf,omitempty"` + + // AnyOf specifies that the variable must validate against one or more of the subschemas in the array. + // NOTE: This field uses PreserveUnknownFields and Schemaless, + // because recursive validation is not possible. + // +optional + // +kubebuilder:pruning:PreserveUnknownFields + // +kubebuilder:validation:Schemaless + AnyOf []JSONSchemaProps `json:"anyOf,omitempty"` + + // Not specifies that the variable must not validate against the subschema. + // NOTE: This field uses PreserveUnknownFields and Schemaless, + // because recursive validation is not possible. + // +optional + // +kubebuilder:pruning:PreserveUnknownFields + // +kubebuilder:validation:Schemaless + Not *JSONSchemaProps `json:"not,omitempty"` } // VariableSchemaMetadata is the metadata of a variable or a nested field within a variable. diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index bb0ecf062264..6ce8bedc935f 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -880,6 +880,32 @@ func (in *JSONSchemaProps) DeepCopyInto(out *JSONSchemaProps) { *out = new(VariableSchemaMetadata) (*in).DeepCopyInto(*out) } + if in.AllOf != nil { + in, out := &in.AllOf, &out.AllOf + *out = make([]JSONSchemaProps, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.OneOf != nil { + in, out := &in.OneOf, &out.OneOf + *out = make([]JSONSchemaProps, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.AnyOf != nil { + in, out := &in.AnyOf, &out.AnyOf + *out = make([]JSONSchemaProps, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Not != nil { + in, out := &in.Not, &out.Not + *out = new(JSONSchemaProps) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JSONSchemaProps. diff --git a/api/v1beta1/zz_generated.openapi.go b/api/v1beta1/zz_generated.openapi.go index 4e1017dec6d6..3740af393ca2 100644 --- a/api/v1beta1/zz_generated.openapi.go +++ b/api/v1beta1/zz_generated.openapi.go @@ -1542,6 +1542,61 @@ func schema_sigsk8sio_cluster_api_api_v1beta1_JSONSchemaProps(ref common.Referen Ref: ref("sigs.k8s.io/cluster-api/api/v1beta1.VariableSchemaMetadata"), }, }, + "x-kubernetes-int-or-string": { + SchemaProps: spec.SchemaProps{ + Description: "x-kubernetes-int-or-string specifies that this value is either an integer or a string. If this is true, an empty type is allowed and type as child of anyOf is permitted if following one of the following patterns:\n\n1) anyOf:\n - type: integer\n - type: string\n2) allOf:\n - anyOf:\n - type: integer\n - type: string\n - ... zero or more", + Type: []string{"boolean"}, + Format: "", + }, + }, + "allOf": { + SchemaProps: spec.SchemaProps{ + Description: "AllOf specifies that the variable must validate against all of the subschemas in the array. NOTE: This field uses PreserveUnknownFields and Schemaless, because recursive validation is not possible.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("sigs.k8s.io/cluster-api/api/v1beta1.JSONSchemaProps"), + }, + }, + }, + }, + }, + "oneOf": { + SchemaProps: spec.SchemaProps{ + Description: "OneOf specifies that the variable must validate against exactly one of the subschemas in the array. NOTE: This field uses PreserveUnknownFields and Schemaless, because recursive validation is not possible.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("sigs.k8s.io/cluster-api/api/v1beta1.JSONSchemaProps"), + }, + }, + }, + }, + }, + "anyOf": { + SchemaProps: spec.SchemaProps{ + Description: "AnyOf specifies that the variable must validate against one or more of the subschemas in the array. NOTE: This field uses PreserveUnknownFields and Schemaless, because recursive validation is not possible.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("sigs.k8s.io/cluster-api/api/v1beta1.JSONSchemaProps"), + }, + }, + }, + }, + }, + "not": { + SchemaProps: spec.SchemaProps{ + Description: "Not specifies that the variable must not validate against the subschema. NOTE: This field uses PreserveUnknownFields and Schemaless, because recursive validation is not possible.", + Ref: ref("sigs.k8s.io/cluster-api/api/v1beta1.JSONSchemaProps"), + }, + }, }, Required: []string{"type"}, }, diff --git a/config/crd/bases/cluster.x-k8s.io_clusterclasses.yaml b/config/crd/bases/cluster.x-k8s.io_clusterclasses.yaml index 80cd56ba7d17..32a113536b91 100644 --- a/config/crd/bases/cluster.x-k8s.io_clusterclasses.yaml +++ b/config/crd/bases/cluster.x-k8s.io_clusterclasses.yaml @@ -1024,6 +1024,18 @@ spec: NOTE: This field uses PreserveUnknownFields and Schemaless, because recursive validation is not possible. x-kubernetes-preserve-unknown-fields: true + allOf: + description: |- + AllOf specifies that the variable must validate against all of the subschemas in the array. + NOTE: This field uses PreserveUnknownFields and Schemaless, + because recursive validation is not possible. + x-kubernetes-preserve-unknown-fields: true + anyOf: + description: |- + AnyOf specifies that the variable must validate against one or more of the subschemas in the array. + NOTE: This field uses PreserveUnknownFields and Schemaless, + because recursive validation is not possible. + x-kubernetes-preserve-unknown-fields: true default: description: |- Default is the default value of the variable. @@ -1119,6 +1131,18 @@ spec: NOTE: Can only be set if type is integer or number. format: int64 type: integer + not: + description: |- + Not specifies that the variable must not validate against the subschema. + NOTE: This field uses PreserveUnknownFields and Schemaless, + because recursive validation is not possible. + x-kubernetes-preserve-unknown-fields: true + oneOf: + description: |- + OneOf specifies that the variable must validate against exactly one of the subschemas in the array. + NOTE: This field uses PreserveUnknownFields and Schemaless, + because recursive validation is not possible. + x-kubernetes-preserve-unknown-fields: true pattern: description: |- Pattern is the regex which a string variable must match. @@ -1149,6 +1173,22 @@ spec: UniqueItems specifies if items in an array must be unique. NOTE: Can only be set if type is array. type: boolean + x-kubernetes-int-or-string: + description: |- + x-kubernetes-int-or-string specifies that this value is + either an integer or a string. If this is true, an empty + type is allowed and type as child of anyOf is permitted + if following one of the following patterns: + + 1) anyOf: + - type: integer + - type: string + 2) allOf: + - anyOf: + - type: integer + - type: string + - ... zero or more + type: boolean x-kubernetes-preserve-unknown-fields: description: |- XPreserveUnknownFields allows setting fields in a variable object @@ -2069,6 +2109,18 @@ spec: NOTE: This field uses PreserveUnknownFields and Schemaless, because recursive validation is not possible. x-kubernetes-preserve-unknown-fields: true + allOf: + description: |- + AllOf specifies that the variable must validate against all of the subschemas in the array. + NOTE: This field uses PreserveUnknownFields and Schemaless, + because recursive validation is not possible. + x-kubernetes-preserve-unknown-fields: true + anyOf: + description: |- + AnyOf specifies that the variable must validate against one or more of the subschemas in the array. + NOTE: This field uses PreserveUnknownFields and Schemaless, + because recursive validation is not possible. + x-kubernetes-preserve-unknown-fields: true default: description: |- Default is the default value of the variable. @@ -2164,6 +2216,18 @@ spec: NOTE: Can only be set if type is integer or number. format: int64 type: integer + not: + description: |- + Not specifies that the variable must not validate against the subschema. + NOTE: This field uses PreserveUnknownFields and Schemaless, + because recursive validation is not possible. + x-kubernetes-preserve-unknown-fields: true + oneOf: + description: |- + OneOf specifies that the variable must validate against exactly one of the subschemas in the array. + NOTE: This field uses PreserveUnknownFields and Schemaless, + because recursive validation is not possible. + x-kubernetes-preserve-unknown-fields: true pattern: description: |- Pattern is the regex which a string variable must match. @@ -2194,6 +2258,22 @@ spec: UniqueItems specifies if items in an array must be unique. NOTE: Can only be set if type is array. type: boolean + x-kubernetes-int-or-string: + description: |- + x-kubernetes-int-or-string specifies that this value is + either an integer or a string. If this is true, an empty + type is allowed and type as child of anyOf is permitted + if following one of the following patterns: + + 1) anyOf: + - type: integer + - type: string + 2) allOf: + - anyOf: + - type: integer + - type: string + - ... zero or more + type: boolean x-kubernetes-preserve-unknown-fields: description: |- XPreserveUnknownFields allows setting fields in a variable object diff --git a/internal/topology/variables/cluster_variable_validation_test.go b/internal/topology/variables/cluster_variable_validation_test.go index 6cef0946daed..9c274bc955cb 100644 --- a/internal/topology/variables/cluster_variable_validation_test.go +++ b/internal/topology/variables/cluster_variable_validation_test.go @@ -1899,6 +1899,324 @@ func Test_ValidateClusterVariable(t *testing.T) { }, }, }, + { + name: "Valid object with oneOf schema", + clusterClassVariable: &clusterv1.ClusterClassVariable{ + Name: "test", + Required: true, + Schema: clusterv1.VariableSchema{ + OpenAPIV3Schema: clusterv1.JSONSchemaProps{ + Type: "object", + OneOf: []clusterv1.JSONSchemaProps{{ + Required: []string{"propertyA"}, + }, { + Required: []string{"propertyB"}, + }}, + Properties: map[string]clusterv1.JSONSchemaProps{ + "propertyA": { + Type: "boolean", + }, + "propertyB": { + Type: "boolean", + }, + }, + }, + }, + }, + clusterVariable: &clusterv1.ClusterVariable{ + Name: "test", + Value: apiextensionsv1.JSON{ + Raw: []byte(`{"propertyA":true}`), + }, + }, + }, + { + name: "Fails, value does not match exactly one of the oneOf schemas", + wantErrs: []validationMatch{ + invalid( + `Invalid value: "{\"propertyA\":true, \"propertyB\":true}": "" must validate one and only one schema (oneOf). Found 2 valid alternatives`, + "spec.topology.variables[test].value", + ), + }, + clusterClassVariable: &clusterv1.ClusterClassVariable{ + Name: "test", + Required: true, + Schema: clusterv1.VariableSchema{ + OpenAPIV3Schema: clusterv1.JSONSchemaProps{ + Type: "object", + OneOf: []clusterv1.JSONSchemaProps{{ + Required: []string{"propertyA"}, + }, { + Required: []string{"propertyB"}, + }}, + Properties: map[string]clusterv1.JSONSchemaProps{ + "propertyA": { + Type: "boolean", + }, + "propertyB": { + Type: "boolean", + }, + }, + }, + }, + }, + clusterVariable: &clusterv1.ClusterVariable{ + Name: "test", + Value: apiextensionsv1.JSON{ + Raw: []byte(`{"propertyA":true, "propertyB":true}`), + }, + }, + }, + { + name: "Valid object with allOf schema", + clusterClassVariable: &clusterv1.ClusterClassVariable{ + Name: "test", + Required: true, + Schema: clusterv1.VariableSchema{ + OpenAPIV3Schema: clusterv1.JSONSchemaProps{ + Type: "object", + AllOf: []clusterv1.JSONSchemaProps{{ + Required: []string{"propertyA"}, + }, { + Required: []string{"propertyB"}, + }}, + Properties: map[string]clusterv1.JSONSchemaProps{ + "propertyA": { + Type: "boolean", + }, + "propertyB": { + Type: "boolean", + }, + }, + }, + }, + }, + clusterVariable: &clusterv1.ClusterVariable{ + Name: "test", + Value: apiextensionsv1.JSON{ + Raw: []byte(`{"propertyA":true, "propertyB":true}`), + }, + }, + }, + { + name: "Fails, value does not match all of the allOf schemas", + wantErrs: []validationMatch{ + required( + `Required value`, + "spec.topology.variables[test].value.propertyB", + ), + invalid( + `Invalid value: "{\"propertyA\":true}": "" must validate all the schemas (allOf)`, + "spec.topology.variables[test].value", + ), + }, + clusterClassVariable: &clusterv1.ClusterClassVariable{ + Name: "test", + Required: true, + Schema: clusterv1.VariableSchema{ + OpenAPIV3Schema: clusterv1.JSONSchemaProps{ + Type: "object", + AllOf: []clusterv1.JSONSchemaProps{{ + Required: []string{"propertyA"}, + }, { + Required: []string{"propertyB"}, + }}, + Properties: map[string]clusterv1.JSONSchemaProps{ + "propertyA": { + Type: "boolean", + }, + "propertyB": { + Type: "boolean", + }, + }, + }, + }, + }, + clusterVariable: &clusterv1.ClusterVariable{ + Name: "test", + Value: apiextensionsv1.JSON{ + Raw: []byte(`{"propertyA":true}`), + }, + }, + }, + { + name: "Valid object with anyOf schema and multiple specified properties", + clusterClassVariable: &clusterv1.ClusterClassVariable{ + Name: "test", + Required: true, + Schema: clusterv1.VariableSchema{ + OpenAPIV3Schema: clusterv1.JSONSchemaProps{ + Type: "object", + AnyOf: []clusterv1.JSONSchemaProps{{ + Required: []string{"propertyA"}, + }, { + Required: []string{"propertyB"}, + }, { + Required: []string{"propertyC"}, + }}, + Properties: map[string]clusterv1.JSONSchemaProps{ + "propertyA": { + Type: "boolean", + }, + "propertyB": { + Type: "boolean", + }, + "propertyC": { + Type: "boolean", + }, + }, + }, + }, + }, + clusterVariable: &clusterv1.ClusterVariable{ + Name: "test", + Value: apiextensionsv1.JSON{ + Raw: []byte(`{"propertyA":true, "propertyB":true}`), + }, + }, + }, + { + name: "Fails, value does not match any of the anyOf schemas", + wantErrs: []validationMatch{ + invalidType( + `Invalid value: "{\"propertyA\":\"invalid value\"}": propertyA in body must be of type boolean: "string"`, + "spec.topology.variables[test].value.propertyA", + ), + }, + clusterClassVariable: &clusterv1.ClusterClassVariable{ + Name: "test", + Required: true, + Schema: clusterv1.VariableSchema{ + OpenAPIV3Schema: clusterv1.JSONSchemaProps{ + Type: "object", + AnyOf: []clusterv1.JSONSchemaProps{{ + Required: []string{"propertyA"}, + }, { + Required: []string{"propertyB"}, + }}, + Properties: map[string]clusterv1.JSONSchemaProps{ + "propertyA": { + Type: "boolean", + }, + "propertyB": { + Type: "boolean", + }, + }, + }, + }, + }, + clusterVariable: &clusterv1.ClusterVariable{ + Name: "test", + Value: apiextensionsv1.JSON{ + Raw: []byte(`{"propertyA":"invalid value"}`), + }, + }, + }, + { + name: "Valid object with anyOf and not schema", + clusterClassVariable: &clusterv1.ClusterClassVariable{ + Name: "test", + Required: true, + Schema: clusterv1.VariableSchema{ + OpenAPIV3Schema: clusterv1.JSONSchemaProps{ + Type: "object", + AnyOf: []clusterv1.JSONSchemaProps{{ + Required: []string{"propertyA"}, + }, { + Required: []string{"propertyB"}, + }, { + Required: []string{"propertyC"}, + }}, + Not: &clusterv1.JSONSchemaProps{ + Required: []string{"propertyA", "propertyB"}, + }, + Properties: map[string]clusterv1.JSONSchemaProps{ + "propertyA": { + Type: "boolean", + }, + "propertyB": { + Type: "boolean", + }, + "propertyC": { + Type: "boolean", + }, + }, + }, + }, + }, + clusterVariable: &clusterv1.ClusterVariable{ + Name: "test", + Value: apiextensionsv1.JSON{ + Raw: []byte(`{"propertyA":true, "propertyC":true}`), + }, + }, + }, + { + name: "Fails, value does match not schema", + wantErrs: []validationMatch{ + invalid( + `Invalid value: "{\"propertyA\":true, \"propertyB\":true}": "" must not validate the schema (not)`, + "spec.topology.variables[test].value", + ), + }, + clusterClassVariable: &clusterv1.ClusterClassVariable{ + Name: "test", + Required: true, + Schema: clusterv1.VariableSchema{ + OpenAPIV3Schema: clusterv1.JSONSchemaProps{ + Type: "object", + AnyOf: []clusterv1.JSONSchemaProps{{ + Required: []string{"propertyA"}, + }, { + Required: []string{"propertyB"}, + }}, + Not: &clusterv1.JSONSchemaProps{ + Required: []string{"propertyA", "propertyB"}, + }, + Properties: map[string]clusterv1.JSONSchemaProps{ + "propertyA": { + Type: "boolean", + }, + "propertyB": { + Type: "boolean", + }, + }, + }, + }, + }, + clusterVariable: &clusterv1.ClusterVariable{ + Name: "test", + Value: apiextensionsv1.JSON{ + Raw: []byte(`{"propertyA":true, "propertyB":true}`), + }, + }, + }, { + name: "Valid object for int-or-string (resource.Quantity)", + clusterClassVariable: &clusterv1.ClusterClassVariable{ + Name: "quantityArray", + Required: true, + Schema: clusterv1.VariableSchema{ + OpenAPIV3Schema: clusterv1.JSONSchemaProps{ + Type: "array", + Items: &clusterv1.JSONSchemaProps{ + XIntOrString: true, + AnyOf: []clusterv1.JSONSchemaProps{{ + Type: "integer", + }, { + Type: "string", + }}, + Pattern: `^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$`, + }, + }, + }, + }, + clusterVariable: &clusterv1.ClusterVariable{ + Name: "quantity", + Value: apiextensionsv1.JSON{ + Raw: []byte(`["500000G", "200M", "0.2G", 1000]`), + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/topology/variables/clusterclass_variable_validation.go b/internal/topology/variables/clusterclass_variable_validation.go index 7433f3e99039..98e89bd6fccf 100644 --- a/internal/topology/variables/clusterclass_variable_validation.go +++ b/internal/topology/variables/clusterclass_variable_validation.go @@ -312,12 +312,8 @@ var supportedValidationReason = sets.NewString( func validateSchema(ctx context.Context, schema *apiextensions.JSONSchemaProps, fldPath *field.Path, opts *validationOptions, celContext *apiextensionsvalidation.CELSchemaContext, uncorrelatablePath *field.Path) *OpenAPISchemaErrorList { allErrs := &OpenAPISchemaErrorList{SchemaErrors: field.ErrorList{}, CELErrors: field.ErrorList{}} - // Validate that type is one of the validVariableTypes. - switch { - case schema.Type == "": - allErrs.SchemaErrors = append(allErrs.SchemaErrors, field.Required(fldPath.Child("type"), "type cannot be empty")) - return allErrs - case !validVariableTypes.Has(schema.Type): + // Validate in case type is not empty, that it is one of the validVariableTypes. + if len(schema.Type) > 0 && !validVariableTypes.Has(schema.Type) { allErrs.SchemaErrors = append(allErrs.SchemaErrors, field.NotSupported(fldPath.Child("type"), schema.Type, sets.List(validVariableTypes))) return allErrs } @@ -363,6 +359,28 @@ func validateSchema(ctx context.Context, schema *apiextensions.JSONSchemaProps, allErrs.AppendErrors(validateSchema(ctx, schema.Items.Schema, fldPath.Child("items"), opts, celContext.ChildItemsContext(schema.Items.Schema), uncorrelatablePath)) } + if schema.Not != nil { + allErrs.AppendErrors(validateSchema(ctx, schema.Not, fldPath.Child("not"), opts, nil, uncorrelatablePath)) + } + + if len(schema.AllOf) != 0 { + for i, jsonSchema := range schema.AllOf { + allErrs.AppendErrors(validateSchema(ctx, &jsonSchema, fldPath.Child("allOf").Index(i), opts, nil, uncorrelatablePath)) + } + } + + if len(schema.OneOf) != 0 { + for i, jsonSchema := range schema.OneOf { + allErrs.AppendErrors(validateSchema(ctx, &jsonSchema, fldPath.Child("oneOf").Index(i), opts, nil, uncorrelatablePath)) + } + } + + if len(schema.AnyOf) != 0 { + for i, jsonSchema := range schema.AnyOf { + allErrs.AppendErrors(validateSchema(ctx, &jsonSchema, fldPath.Child("anyOf").Index(i), opts, nil, uncorrelatablePath)) + } + } + // This validation is duplicated from upstream CRD validation at // https://github.com/kubernetes/apiextensions-apiserver/blob/v0.30.0/pkg/apis/apiextensions/validation/validation.go#L1178. if len(schema.XValidations) > 0 { diff --git a/internal/topology/variables/clusterclass_variable_validation_test.go b/internal/topology/variables/clusterclass_variable_validation_test.go index 0c561344b363..38ad783bd2bc 100644 --- a/internal/topology/variables/clusterclass_variable_validation_test.go +++ b/internal/topology/variables/clusterclass_variable_validation_test.go @@ -2609,6 +2609,204 @@ func Test_ValidateClusterClassVariable(t *testing.T) { "spec.variables[cpu].schema.openAPIV3Schema"), }, }, + { + name: "fail if x-kubernetes-validations are under oneOf/anyOf/allOf/not", + clusterClassVariable: &clusterv1.ClusterClassVariable{ + Name: "variableA", + Schema: clusterv1.VariableSchema{ + OpenAPIV3Schema: clusterv1.JSONSchemaProps{ + Type: "number", + Not: &clusterv1.JSONSchemaProps{ + XValidations: []clusterv1.ValidationRule{{ + Rule: "should be forbidden", + }}, + }, + AnyOf: []clusterv1.JSONSchemaProps{{ + XValidations: []clusterv1.ValidationRule{{ + Rule: "should be forbidden", + }}, + }}, + AllOf: []clusterv1.JSONSchemaProps{{ + XValidations: []clusterv1.ValidationRule{{ + Rule: "should be forbidden", + }}, + }}, + OneOf: []clusterv1.JSONSchemaProps{{ + XValidations: []clusterv1.ValidationRule{{ + Rule: "should be forbidden", + }}, + }}, + }, + }, + }, + wantErrs: []validationMatch{ + forbidden("must be empty to be structural", "spec.variables[variableA].schema.openAPIV3Schema.allOf[0].x-kubernetes-validations"), + forbidden("must be empty to be structural", "spec.variables[variableA].schema.openAPIV3Schema.anyOf[0].x-kubernetes-validations"), + forbidden("must be empty to be structural", "spec.variables[variableA].schema.openAPIV3Schema.not.x-kubernetes-validations"), + forbidden("must be empty to be structural", "spec.variables[variableA].schema.openAPIV3Schema.oneOf[0].x-kubernetes-validations"), + }, + }, + { + name: "fail if defaults are under oneOf/anyOf/allOf/not", + clusterClassVariable: &clusterv1.ClusterClassVariable{ + Name: "variableA", + Schema: clusterv1.VariableSchema{ + OpenAPIV3Schema: clusterv1.JSONSchemaProps{ + Type: "number", + Not: &clusterv1.JSONSchemaProps{ + Default: &apiextensionsv1.JSON{ + Raw: []byte(`42.0`), + }, + }, + AnyOf: []clusterv1.JSONSchemaProps{{ + Default: &apiextensionsv1.JSON{ + Raw: []byte(`42.0`), + }, + }}, + AllOf: []clusterv1.JSONSchemaProps{{ + Default: &apiextensionsv1.JSON{ + Raw: []byte(`42.0`), + }, + }}, + OneOf: []clusterv1.JSONSchemaProps{{ + Default: &apiextensionsv1.JSON{ + Raw: []byte(`42.0`), + }, + }}, + }, + }, + }, + wantErrs: []validationMatch{ + forbidden("must be undefined to be structural", "spec.variables[variableA].schema.openAPIV3Schema.allOf[0].default"), + forbidden("must be undefined to be structural", "spec.variables[variableA].schema.openAPIV3Schema.anyOf[0].default"), + forbidden("must be undefined to be structural", "spec.variables[variableA].schema.openAPIV3Schema.not.default"), + forbidden("must be undefined to be structural", "spec.variables[variableA].schema.openAPIV3Schema.oneOf[0].default"), + }, + }, { + name: "fail if there are types set under oneOf/anyOf/allOf/not", + clusterClassVariable: &clusterv1.ClusterClassVariable{ + Name: "variableA", + Schema: clusterv1.VariableSchema{ + OpenAPIV3Schema: clusterv1.JSONSchemaProps{ + Type: "number", + Not: &clusterv1.JSONSchemaProps{ + Properties: map[string]clusterv1.JSONSchemaProps{ + "testField": { + Type: "number", + }, + }, + }, + AnyOf: []clusterv1.JSONSchemaProps{{ + Properties: map[string]clusterv1.JSONSchemaProps{ + "testField": { + Type: "number", + }, + }, + }}, + AllOf: []clusterv1.JSONSchemaProps{{ + Properties: map[string]clusterv1.JSONSchemaProps{ + "testField": { + Type: "number", + }, + }, + }}, + OneOf: []clusterv1.JSONSchemaProps{{ + Properties: map[string]clusterv1.JSONSchemaProps{ + "testField": { + Type: "number", + }, + }, + }}, + }, + }, + }, + wantErrs: []validationMatch{ + forbidden("must be empty to be structural", "spec.variables[variableA].schema.openAPIV3Schema.allOf[0].properties[testField].type"), + forbidden("must be empty to be structural", "spec.variables[variableA].schema.openAPIV3Schema.anyOf[0].properties[testField].type"), + forbidden("must be empty to be structural", "spec.variables[variableA].schema.openAPIV3Schema.not.properties[testField].type"), + forbidden("must be empty to be structural", "spec.variables[variableA].schema.openAPIV3Schema.oneOf[0].properties[testField].type"), + }, + }, { + name: "pass if oneOf/anyOf/allOf/not schemas are valid", + clusterClassVariable: &clusterv1.ClusterClassVariable{ + Name: "variableA", + Schema: clusterv1.VariableSchema{ + OpenAPIV3Schema: clusterv1.JSONSchemaProps{ + Type: "string", + Not: &clusterv1.JSONSchemaProps{ + Format: "email", + }, + AnyOf: []clusterv1.JSONSchemaProps{{ + Format: "ipv4", + }, { + Format: "ipv6", + }}, + AllOf: []clusterv1.JSONSchemaProps{{ + Format: "ipv4", + }, { + Format: "ipv6", + }}, + OneOf: []clusterv1.JSONSchemaProps{{ + Format: "ipv4", + }, { + Format: "ipv6", + }}, + }, + }, + }, + }, { + name: "pass & fail correctly for int-or-string in oneOf/anyOf/allOf schemas", + clusterClassVariable: &clusterv1.ClusterClassVariable{ + Name: "variableA", + Schema: clusterv1.VariableSchema{ + OpenAPIV3Schema: clusterv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]clusterv1.JSONSchemaProps{ + "anyOfExampleField": { // Valid variant + XIntOrString: true, + AnyOf: []clusterv1.JSONSchemaProps{{ + Type: "integer", + }, { + Type: "string", + }}, + }, + "allOfExampleFieldWithAnyOf": { // Valid variant + XIntOrString: true, + AllOf: []clusterv1.JSONSchemaProps{{ + AnyOf: []clusterv1.JSONSchemaProps{{ + Type: "integer", + }, { + Type: "string", + }}, + }}, + }, + "allOfExampleField": { + AllOf: []clusterv1.JSONSchemaProps{{ + Type: "integer", + }, { + Type: "string", + }}, + }, + "oneOfExampleField": { + OneOf: []clusterv1.JSONSchemaProps{{ + Type: "integer", + }, { + Type: "string", + }}, + }, + }, + }, + }, + }, + wantErrs: []validationMatch{ + forbidden("must be empty to be structural", "spec.variables[variableA].schema.openAPIV3Schema.properties[allOfExampleField].allOf[0].type"), + forbidden("must be empty to be structural", "spec.variables[variableA].schema.openAPIV3Schema.properties[allOfExampleField].allOf[1].type"), + required("must not be empty for specified object fields", "spec.variables[variableA].schema.openAPIV3Schema.properties[allOfExampleField].type"), + forbidden("must be empty to be structural", "spec.variables[variableA].schema.openAPIV3Schema.properties[oneOfExampleField].oneOf[0].type"), + forbidden("must be empty to be structural", "spec.variables[variableA].schema.openAPIV3Schema.properties[oneOfExampleField].oneOf[1].type"), + required("must not be empty for specified object fields", "spec.variables[variableA].schema.openAPIV3Schema.properties[oneOfExampleField].type"), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/topology/variables/schema.go b/internal/topology/variables/schema.go index e4690a46ae3d..9882cbc3cc2b 100644 --- a/internal/topology/variables/schema.go +++ b/internal/topology/variables/schema.go @@ -49,6 +49,7 @@ func convertToAPIExtensionsJSONSchemaProps(schema *clusterv1.JSONSchemaProps, fl Pattern: schema.Pattern, ExclusiveMaximum: schema.ExclusiveMaximum, ExclusiveMinimum: schema.ExclusiveMinimum, + XIntOrString: schema.XIntOrString, } // Only set XPreserveUnknownFields to true if it's true. @@ -162,6 +163,51 @@ func convertToAPIExtensionsJSONSchemaProps(schema *clusterv1.JSONSchemaProps, fl } } + if len(schema.OneOf) > 0 { + props.OneOf = make([]apiextensions.JSONSchemaProps, 0, len(schema.OneOf)) + for idx, oneOf := range schema.OneOf { + apiExtensionsSchema, errs := convertToAPIExtensionsJSONSchemaProps(&oneOf, fldPath.Child("oneOf").Index(idx)) + if len(errs) > 0 { + allErrs = append(allErrs, errs...) + } else { + props.OneOf = append(props.OneOf, *apiExtensionsSchema) + } + } + } + + if len(schema.AnyOf) > 0 { + props.AnyOf = make([]apiextensions.JSONSchemaProps, 0, len(schema.AnyOf)) + for idx, anyOf := range schema.AnyOf { + apiExtensionsSchema, errs := convertToAPIExtensionsJSONSchemaProps(&anyOf, fldPath.Child("anyOf").Index(idx)) + if len(errs) > 0 { + allErrs = append(allErrs, errs...) + } else { + props.AnyOf = append(props.AnyOf, *apiExtensionsSchema) + } + } + } + + if len(schema.AllOf) > 0 { + props.AllOf = make([]apiextensions.JSONSchemaProps, 0, len(schema.AllOf)) + for idx, allOf := range schema.AllOf { + apiExtensionsSchema, errs := convertToAPIExtensionsJSONSchemaProps(&allOf, fldPath.Child("allOf").Index(idx)) + if len(errs) > 0 { + allErrs = append(allErrs, errs...) + } else { + props.AllOf = append(props.AllOf, *apiExtensionsSchema) + } + } + } + + if schema.Not != nil { + apiExtensionsSchema, errs := convertToAPIExtensionsJSONSchemaProps(schema.Not, fldPath.Child("not")) + if len(errs) > 0 { + allErrs = append(allErrs, errs...) + } else { + props.Not = apiExtensionsSchema + } + } + if schema.XValidations != nil { props.XValidations = convertToAPIExtensionsXValidations(schema.XValidations) } diff --git a/internal/topology/variables/schema_test.go b/internal/topology/variables/schema_test.go index f93eed11558d..a6edfc100b5b 100644 --- a/internal/topology/variables/schema_test.go +++ b/internal/topology/variables/schema_test.go @@ -49,6 +49,24 @@ func Test_convertToAPIExtensionsJSONSchemaProps(t *testing.T) { ExclusiveMaximum: true, Minimum: ptr.To[int64](1), ExclusiveMinimum: false, + OneOf: []clusterv1.JSONSchemaProps{{ + Required: []string{"property1", "property2"}, + }, { + Required: []string{"property3", "property4"}, + }}, + AnyOf: []clusterv1.JSONSchemaProps{{ + Required: []string{"property1", "property2"}, + }, { + Required: []string{"property3", "property4"}, + }}, + AllOf: []clusterv1.JSONSchemaProps{{ + Required: []string{"property1", "property2"}, + }, { + Required: []string{"property3", "property4"}, + }}, + Not: &clusterv1.JSONSchemaProps{ + Required: []string{"property1", "property2"}, + }, }, want: &apiextensions.JSONSchemaProps{ Type: "integer", @@ -60,6 +78,24 @@ func Test_convertToAPIExtensionsJSONSchemaProps(t *testing.T) { ExclusiveMaximum: true, Minimum: ptr.To[float64](1), ExclusiveMinimum: false, + OneOf: []apiextensions.JSONSchemaProps{{ + Required: []string{"property1", "property2"}, + }, { + Required: []string{"property3", "property4"}, + }}, + AnyOf: []apiextensions.JSONSchemaProps{{ + Required: []string{"property1", "property2"}, + }, { + Required: []string{"property3", "property4"}, + }}, + AllOf: []apiextensions.JSONSchemaProps{{ + Required: []string{"property1", "property2"}, + }, { + Required: []string{"property3", "property4"}, + }}, + Not: &apiextensions.JSONSchemaProps{ + Required: []string{"property1", "property2"}, + }, }, }, { @@ -107,6 +143,24 @@ func Test_convertToAPIExtensionsJSONSchemaProps(t *testing.T) { Format: "uri", MinProperties: ptr.To[int64](2), MaxProperties: ptr.To[int64](4), + OneOf: []clusterv1.JSONSchemaProps{{ + Required: []string{"property1", "property2"}, + }, { + Required: []string{"property3", "property4"}, + }}, + AnyOf: []clusterv1.JSONSchemaProps{{ + Required: []string{"property1", "property2"}, + }, { + Required: []string{"property3", "property4"}, + }}, + AllOf: []clusterv1.JSONSchemaProps{{ + Required: []string{"property1", "property2"}, + }, { + Required: []string{"property3", "property4"}, + }}, + Not: &clusterv1.JSONSchemaProps{ + Required: []string{"property1", "property2"}, + }, }, }, }, @@ -121,6 +175,24 @@ func Test_convertToAPIExtensionsJSONSchemaProps(t *testing.T) { Format: "uri", MinProperties: ptr.To[int64](2), MaxProperties: ptr.To[int64](4), + OneOf: []apiextensions.JSONSchemaProps{{ + Required: []string{"property1", "property2"}, + }, { + Required: []string{"property3", "property4"}, + }}, + AnyOf: []apiextensions.JSONSchemaProps{{ + Required: []string{"property1", "property2"}, + }, { + Required: []string{"property3", "property4"}, + }}, + AllOf: []apiextensions.JSONSchemaProps{{ + Required: []string{"property1", "property2"}, + }, { + Required: []string{"property3", "property4"}, + }}, + Not: &apiextensions.JSONSchemaProps{ + Required: []string{"property1", "property2"}, + }, }, }, }, @@ -139,6 +211,24 @@ func Test_convertToAPIExtensionsJSONSchemaProps(t *testing.T) { Format: "uri", MinProperties: ptr.To[int64](2), MaxProperties: ptr.To[int64](4), + OneOf: []clusterv1.JSONSchemaProps{{ + Required: []string{"property1", "property2"}, + }, { + Required: []string{"property3", "property4"}, + }}, + AnyOf: []clusterv1.JSONSchemaProps{{ + Required: []string{"property1", "property2"}, + }, { + Required: []string{"property3", "property4"}, + }}, + AllOf: []clusterv1.JSONSchemaProps{{ + Required: []string{"property1", "property2"}, + }, { + Required: []string{"property3", "property4"}, + }}, + Not: &clusterv1.JSONSchemaProps{ + Required: []string{"property1", "property2"}, + }, }, }, }, @@ -157,6 +247,24 @@ func Test_convertToAPIExtensionsJSONSchemaProps(t *testing.T) { Format: "uri", MinProperties: ptr.To[int64](2), MaxProperties: ptr.To[int64](4), + OneOf: []apiextensions.JSONSchemaProps{{ + Required: []string{"property1", "property2"}, + }, { + Required: []string{"property3", "property4"}, + }}, + AnyOf: []apiextensions.JSONSchemaProps{{ + Required: []string{"property1", "property2"}, + }, { + Required: []string{"property3", "property4"}, + }}, + AllOf: []apiextensions.JSONSchemaProps{{ + Required: []string{"property1", "property2"}, + }, { + Required: []string{"property3", "property4"}, + }}, + Not: &apiextensions.JSONSchemaProps{ + Required: []string{"property1", "property2"}, + }, }, }, }, @@ -172,6 +280,24 @@ func Test_convertToAPIExtensionsJSONSchemaProps(t *testing.T) { Format: "uri", MinLength: ptr.To[int64](2), MaxLength: ptr.To[int64](4), + OneOf: []clusterv1.JSONSchemaProps{{ + Required: []string{"property1", "property2"}, + }, { + Required: []string{"property3", "property4"}, + }}, + AnyOf: []clusterv1.JSONSchemaProps{{ + Required: []string{"property1", "property2"}, + }, { + Required: []string{"property3", "property4"}, + }}, + AllOf: []clusterv1.JSONSchemaProps{{ + Required: []string{"property1", "property2"}, + }, { + Required: []string{"property3", "property4"}, + }}, + Not: &clusterv1.JSONSchemaProps{ + Required: []string{"property1", "property2"}, + }, }, }, want: &apiextensions.JSONSchemaProps{ @@ -182,6 +308,24 @@ func Test_convertToAPIExtensionsJSONSchemaProps(t *testing.T) { Format: "uri", MinLength: ptr.To[int64](2), MaxLength: ptr.To[int64](4), + OneOf: []apiextensions.JSONSchemaProps{{ + Required: []string{"property1", "property2"}, + }, { + Required: []string{"property3", "property4"}, + }}, + AnyOf: []apiextensions.JSONSchemaProps{{ + Required: []string{"property1", "property2"}, + }, { + Required: []string{"property3", "property4"}, + }}, + AllOf: []apiextensions.JSONSchemaProps{{ + Required: []string{"property1", "property2"}, + }, { + Required: []string{"property3", "property4"}, + }}, + Not: &apiextensions.JSONSchemaProps{ + Required: []string{"property1", "property2"}, + }, }, }, }, @@ -232,6 +376,30 @@ func Test_convertToAPIExtensionsJSONSchemaProps(t *testing.T) { }, }, }, + }, { + name: "pass for schema validation with XIntOrString", + schema: &clusterv1.JSONSchemaProps{ + Items: &clusterv1.JSONSchemaProps{ + XIntOrString: true, + AnyOf: []clusterv1.JSONSchemaProps{{ + Type: "integer", + }, { + Type: "string", + }}, + }, + }, + want: &apiextensions.JSONSchemaProps{ + Items: &apiextensions.JSONSchemaPropsOrArray{ + Schema: &apiextensions.JSONSchemaProps{ + XIntOrString: true, + AnyOf: []apiextensions.JSONSchemaProps{{ + Type: "integer", + }, { + Type: "string", + }}, + }, + }, + }, }, } for _, tt := range tests {