Skip to content
Open
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 @@ -42,6 +42,7 @@ func init() {
property.RegisterRequired(defaultRegistry)
property.RegisterType(defaultRegistry)
property.RegisterDescription(defaultRegistry)
property.RegisterUniqueItems(defaultRegistry)
}

// DefaultRegistry returns a pre-configured validations.Registry.
Expand Down
88 changes: 88 additions & 0 deletions pkg/validations/property/uniqueitems.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// 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 (
"errors"
"fmt"

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

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

const uniqueItemsValidationName = "uniqueItems"

// RegisterUniqueItems registers the UniqueItems validation
// with the provided validation registry.
func RegisterUniqueItems(registry validations.Registry) {
registry.Register(uniqueItemsValidationName, uniqueItemsFactory)
}

// uniqueItemsFactory is a function used to initialize a UniqueItems validation
// implementation based on the provided configuration.
func uniqueItemsFactory(_ map[string]any) (validations.Validation, error) {
return &UniqueItems{}, nil
}

// UniqueItems is a Validation that can be used to identify
// incompatible changes to the uniqueItems constraints of CRD properties.
// The uniqueItems constraint enforces that lists should only contain unique items.
// Going from non-unique to unique is more restrictive and breaking.
// Going from unique to non-unique is less restrictive and OK.
type UniqueItems struct {
enforcement config.EnforcementPolicy
}

// Name returns the name of the UniqueItems validation.
func (u *UniqueItems) Name() string {
return uniqueItemsValidationName
}

// SetEnforcement sets the EnforcementPolicy for the UniqueItems validation.
func (u *UniqueItems) SetEnforcement(policy config.EnforcementPolicy) {
u.enforcement = policy
}

// Compare compares an old and a new JSONSchemaProps, checking for incompatible changes to the uniqueItems constraints of a property.
// In order for callers to determine if diffs to a JSONSchemaProps have been handled by this validation
// the JSONSchemaProps.UniqueItems field will be reset to 'false' as part of this method.
// It is highly recommended that only copies of the JSONSchemaProps to compare are provided to this method
// to prevent unintentional modifications.
func (u *UniqueItems) Compare(a, b *apiextensionsv1.JSONSchemaProps) validations.ComparisonResult {
var err error

// Going from non-unique (false) to unique (true) is breaking
if !a.UniqueItems && b.UniqueItems {
err = fmt.Errorf("%w", ErrUniqueItemsConstraintAdded)
}
// Going from unique (true) to non-unique (false) is OK (less restrictive)
// No error needed for this case

a.UniqueItems = false
b.UniqueItems = false

return validations.HandleErrors(u.Name(), u.enforcement, err)
}

var (
// ErrUniqueItemsConstraintAdded represents an error state where a uniqueItems constraint was added to a property.
ErrUniqueItemsConstraintAdded = errors.New("uniqueItems constraint added when there was none previously")
)
103 changes: 103 additions & 0 deletions pkg/validations/property/uniqueitems_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// 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"

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

func TestUniqueItems(t *testing.T) {
testcases := []internaltesting.Testcase[apiextensionsv1.JSONSchemaProps]{
{
Name: "no diff, not flagged",
Old: &apiextensionsv1.JSONSchemaProps{
Type: "array",
},
New: &apiextensionsv1.JSONSchemaProps{
Type: "array",
},
Flagged: false,
ComparableValidation: &UniqueItems{},
},
{
Name: "uniqueItems constraint added (false to true), flagged",
Old: &apiextensionsv1.JSONSchemaProps{
Type: "array",
UniqueItems: false,
},
New: &apiextensionsv1.JSONSchemaProps{
Type: "array",
UniqueItems: true,
},
Flagged: true,
ComparableValidation: &UniqueItems{},
},
{
Name: "uniqueItems constraint removed (true to false), not flagged",
Old: &apiextensionsv1.JSONSchemaProps{
Type: "array",
UniqueItems: true,
},
New: &apiextensionsv1.JSONSchemaProps{
Type: "array",
UniqueItems: false,
},
Flagged: false,
ComparableValidation: &UniqueItems{},
},
{
Name: "uniqueItems unchanged (both false), not flagged",
Old: &apiextensionsv1.JSONSchemaProps{
Type: "array",
UniqueItems: false,
},
New: &apiextensionsv1.JSONSchemaProps{
Type: "array",
UniqueItems: false,
},
Flagged: false,
ComparableValidation: &UniqueItems{},
},
{
Name: "uniqueItems unchanged (both true), not flagged",
Old: &apiextensionsv1.JSONSchemaProps{
Type: "array",
UniqueItems: true,
},
New: &apiextensionsv1.JSONSchemaProps{
Type: "array",
UniqueItems: true,
},
Flagged: false,
ComparableValidation: &UniqueItems{},
},
{
Name: "different field changed, not flagged",
Old: &apiextensionsv1.JSONSchemaProps{
ID: "foo",
},
New: &apiextensionsv1.JSONSchemaProps{
ID: "bar",
},
Flagged: false,
ComparableValidation: &UniqueItems{},
},
}

internaltesting.RunTestcases(t, testcases...)
}