Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pkg/runner/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func init() {
property.RegisterMinItems(defaultRegistry)
property.RegisterMinLength(defaultRegistry)
property.RegisterMinProperties(defaultRegistry)
property.RegisterOneOf(defaultRegistry)
property.RegisterRequired(defaultRegistry)
property.RegisterType(defaultRegistry)
property.RegisterDescription(defaultRegistry)
Expand Down
152 changes: 152 additions & 0 deletions pkg/validations/property/oneof.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// Copyright 2025 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.

package property

import (
"encoding/json"
"errors"
"fmt"

utilerrors "k8s.io/apimachinery/pkg/util/errors"
"k8s.io/apimachinery/pkg/util/sets"

apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"sigs.k8s.io/crdify/pkg/config"
"sigs.k8s.io/crdify/pkg/validations"
)

const oneOfValidationName = "oneOf"

var (
_ validations.Validation = (*OneOf)(nil)
_ validations.Comparator[apiextensionsv1.JSONSchemaProps] = (*OneOf)(nil)
)

// RegisterOneOf registers the OneOf validation with the provided validation registry.
func RegisterOneOf(registry validations.Registry) {
registry.Register(oneOfValidationName, oneOfFactory)
}

// oneOfFactory initializes an OneOf validation from configuration.
func oneOfFactory(cfg map[string]interface{}) (validations.Validation, error) {
oneOfCfg := &OneOfConfig{}

err := ConfigToType(cfg, oneOfCfg)
if err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}

err = ValidateOneOfConfig(oneOfCfg)
if err != nil {
return nil, fmt.Errorf("validating oneOf config: %w", err)
}

return &OneOf{OneOfConfig: *oneOfCfg}, nil
}

// ValidateOneOfConfig validates the OneOfConfig, setting defaults.
func ValidateOneOfConfig(in *OneOfConfig) error {
return nil
}

// OneOfConfig contains configurations for the OneOf validation.
type OneOfConfig struct {
}

// OneOf validation identifies incompatible changes to oneOf constraints.
type OneOf struct {
OneOfConfig
enforcement config.EnforcementPolicy
}

// Name returns the name of the validation.
func (o *OneOf) Name() string {
return oneOfValidationName
}

// SetEnforcement sets the enforcement policy.
func (o *OneOf) SetEnforcement(policy config.EnforcementPolicy) {
o.enforcement = policy
}

// Compare checks for incompatible changes in the oneOf constraint.
func (o *OneOf) Compare(a, b *apiextensionsv1.JSONSchemaProps) validations.ComparisonResult {
oldSubSchemas := sets.New[string]()

for _, schema := range a.OneOf {
marshalled, err := marshallSchema(schema)
if err != nil {
return validations.HandleErrors(o.Name(), o.enforcement, fmt.Errorf("failed to marshal old oneOf subschema: %w", err))
}

oldSubSchemas.Insert(marshalled)
}

newSubSchemas := sets.New[string]()

for _, schema := range b.OneOf {
marshalled, err := marshallSchema(schema)
if err != nil {
return validations.HandleErrors(o.Name(), o.enforcement, fmt.Errorf("failed to marshal new oneOf schema: %w", err))
}

newSubSchemas.Insert(marshalled)
}

var errs []error
if oldSubSchemas.Len() == 0 && newSubSchemas.Len() > 0 {
errs = append(errs, ErrNetNewOneOfConstraint)
}

removed := oldSubSchemas.Difference(newSubSchemas)
if removed.Len() > 0 {
errs = append(errs, fmt.Errorf("%w: %v", ErrRemovedOneOf, sets.List(removed)))
}

added := newSubSchemas.Difference(oldSubSchemas)
if added.Len() > 0 {
errs = append(errs, fmt.Errorf("%w: %v", ErrAddedOneOf, sets.List(added)))
}

a.OneOf = nil
b.OneOf = nil

return validations.HandleErrors(o.Name(), o.enforcement, utilerrors.NewAggregate(errs))
}

// marshallSchema converts a schema reprsented as aJSONSchemaProps into a JSON string which captures the structure of the schema.
func marshallSchema(schema apiextensionsv1.JSONSchemaProps) (string, error) {
// Use a copy to avoid modifying the original
schemaCopy := schema.DeepCopy()
// Remove fields that don't affect the structure of the schema
schemaCopy.Description = ""
schemaCopy.Example = nil

bytes, err := json.Marshal(schemaCopy)
if err != nil {
return "", fmt.Errorf("failed to marshal schema: %w", err)
}

return string(bytes), nil
}

var (
// ErrNetNewOneOfConstraint represents an error state where a new oneOf constraint where there was none previously.
ErrNetNewOneOfConstraint = errors.New("oneOf constraint added when there was none previously")
// ErrRemovedOneOf represents an error state where at least one previously allowed oneOf schema was removed.
ErrRemovedOneOf = errors.New("allowed oneOf schemas removed")
// ErrAddedOneOf represents an error state where at least one oneOf schema, that was not previously allowed, was added.
ErrAddedOneOf = errors.New("allowed oneOf schemas added")
)
97 changes: 97 additions & 0 deletions pkg/validations/property/oneof_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Copyright 2025 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.

package property

import (
"testing"

"github.com/stretchr/testify/require"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"sigs.k8s.io/crdify/pkg/config"
)

func TestOneOf(t *testing.T) {
intSchema := &apiextensionsv1.JSONSchemaProps{Type: "integer"}
stringSchema := &apiextensionsv1.JSONSchemaProps{Type: "string"}

tests := []struct {
name string
config OneOfConfig
oldSchema *apiextensionsv1.JSONSchemaProps
newSchema *apiextensionsv1.JSONSchemaProps
expectError bool
expectErrorMsgs []string
}{
{
name: "net new oneOf",
config: OneOfConfig{},
oldSchema: &apiextensionsv1.JSONSchemaProps{},
newSchema: &apiextensionsv1.JSONSchemaProps{OneOf: []apiextensionsv1.JSONSchemaProps{*intSchema}},
expectError: true,
expectErrorMsgs: []string{"oneOf constraint added when there was none previously"},
},
{
name: "removed oneOf",
config: OneOfConfig{},
oldSchema: &apiextensionsv1.JSONSchemaProps{OneOf: []apiextensionsv1.JSONSchemaProps{*intSchema, *stringSchema}},
newSchema: &apiextensionsv1.JSONSchemaProps{OneOf: []apiextensionsv1.JSONSchemaProps{*intSchema}},
expectError: true,
expectErrorMsgs: []string{"allowed oneOf schemas removed"},
},
{
name: "added oneOf, disallowed",
config: OneOfConfig{},
oldSchema: &apiextensionsv1.JSONSchemaProps{OneOf: []apiextensionsv1.JSONSchemaProps{*intSchema}},
newSchema: &apiextensionsv1.JSONSchemaProps{OneOf: []apiextensionsv1.JSONSchemaProps{*intSchema, *stringSchema}},
expectError: true,
expectErrorMsgs: []string{"allowed oneOf schemas added"},
},
{
name: "no change",
config: OneOfConfig{},
oldSchema: &apiextensionsv1.JSONSchemaProps{OneOf: []apiextensionsv1.JSONSchemaProps{*intSchema, *stringSchema}},
newSchema: &apiextensionsv1.JSONSchemaProps{OneOf: []apiextensionsv1.JSONSchemaProps{*stringSchema, *intSchema}},
expectError: false,
},
{
name: "valid change with other fields",
config: OneOfConfig{},
oldSchema: &apiextensionsv1.JSONSchemaProps{OneOf: []apiextensionsv1.JSONSchemaProps{*intSchema}, Description: "old"},
newSchema: &apiextensionsv1.JSONSchemaProps{OneOf: []apiextensionsv1.JSONSchemaProps{*intSchema}, Description: "new"},
expectError: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
comparator := &OneOf{OneOfConfig: tt.config}
comparator.SetEnforcement(config.EnforcementPolicyError)

result := comparator.Compare(tt.oldSchema, tt.newSchema)

if tt.expectError {
require.NotEmpty(t, result.Errors)

for _, msg := range tt.expectErrorMsgs {
require.Contains(t, result.Errors[0], msg)
}
} else {
require.Empty(t, result.Errors)
}
// Ensure the field is cleared after handling
require.Nil(t, tt.newSchema.OneOf)
})
}
}
2 changes: 1 addition & 1 deletion pkg/validations/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ func LoadValidationsFromRegistry(registry Registry) (map[string]Validation, erro
}

// ConfigureValidations is a utility function for configuring the provided set of validations
// using the provided registyr and configuration.
// using the provided registry and configuration.
// It returns a copy of the original validations mapping with validations that had specific
// configurations replaced with a newly initialized validation.
// Any errors encountered during the initialization process are aggregated and returned as a single error.
Expand Down