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
6 changes: 6 additions & 0 deletions docs/validations.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,9 @@ Validates compatibility of changes to a property description. While most changes
description of a property are _generally_ safe, it is important to note that changing
the semantics of a field _is_ a breaking change as it breaks expectations clients/users
have made about what configuring the property does.

### pattern

Validates compatibility of changes to a property's pattern regular expression. Adding a pattern
that did not previously exist or modifying the expression can tighten validation, so these changes
are flagged for review. Removing a pattern is accepted because it broadens what values are allowed.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may want to allow users to restrict removal of a pattern as well (and maybe we default to restricting this too).

Removing a pattern is a compatible change for writers, but is likely a breaking change to readers.

Older clients may no longer be able to handle the values here if they were built on the assumption that valid values for that field always adhere to that pattern.

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.RegisterPattern(defaultRegistry)
}

// DefaultRegistry returns a pre-configured validations.Registry.
Expand Down
90 changes: 90 additions & 0 deletions pkg/validations/property/pattern.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// 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 = (*Pattern)(nil)
_ validations.Comparator[apiextensionsv1.JSONSchemaProps] = (*Pattern)(nil)
)

const patternValidationName = "pattern"

// RegisterPattern registers the Pattern validation
// with the provided validation registry.
func RegisterPattern(registry validations.Registry) {
registry.Register(patternValidationName, patternFactory)
}

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

// Pattern is a Validation that can be used to identify
// incompatible changes to the pattern constraints of CRD properties.
type Pattern struct {
enforcement config.EnforcementPolicy
}

// Name returns the name of the Pattern validation.
func (p *Pattern) Name() string {
return patternValidationName
}

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

// Compare compares an old and a new JSONSchemaProps, checking for incompatible changes to the pattern constraints of a property.
// In order for callers to determine if diffs to a JSONSchemaProps have been handled by this validation
// the JSONSchemaProps.pattern field will be reset to '""' 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 (p *Pattern) Compare(a, b *apiextensionsv1.JSONSchemaProps) validations.ComparisonResult {
var err error

switch {
case a.Pattern == b.Pattern:
// nothing to do
case a.Pattern == "" && b.Pattern != "":
err = fmt.Errorf("%w : %q -> %q", ErrPatternAdded, a.Pattern, b.Pattern)
case a.Pattern != "" && b.Pattern == "":
// removing a pattern is considered safe
case a.Pattern != b.Pattern:
err = fmt.Errorf("%w : %q -> %q", ErrPatternChanged, a.Pattern, b.Pattern)
}

a.Pattern = ""
b.Pattern = ""

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

// ErrPatternAdded represents an error state when a property Pattern was added.
var ErrPatternAdded = errors.New("pattern added")

// ErrPatternChanged represents an error state when a property Pattern changed.
var ErrPatternChanged = errors.New("pattern changed")
80 changes: 80 additions & 0 deletions pkg/validations/property/pattern_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// 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 TestPattern(t *testing.T) {
testcases := []internaltesting.Testcase[apiextensionsv1.JSONSchemaProps]{
{
Name: "no diff, not flagged",
Old: &apiextensionsv1.JSONSchemaProps{
Pattern: "^[a-z]+$",
},
New: &apiextensionsv1.JSONSchemaProps{
Pattern: "^[a-z]+$",
},
Flagged: false,
ComparableValidation: &Pattern{},
},
{
Name: "pattern added, flagged",
Old: &apiextensionsv1.JSONSchemaProps{},
New: &apiextensionsv1.JSONSchemaProps{
Pattern: "^[a-z]+$",
},
Flagged: true,
ComparableValidation: &Pattern{},
},
{
Name: "pattern changed, flagged",
Old: &apiextensionsv1.JSONSchemaProps{
Pattern: "^[a-z]+$",
},
New: &apiextensionsv1.JSONSchemaProps{
Pattern: "^[A-Z]+$",
},
Flagged: true,
ComparableValidation: &Pattern{},
},
{
Name: "pattern removed, not flagged",
Old: &apiextensionsv1.JSONSchemaProps{
Pattern: "^[a-z]+$",
},
New: &apiextensionsv1.JSONSchemaProps{},
Flagged: false,
ComparableValidation: &Pattern{},
},
{
Name: "different field changed, not flagged",
Old: &apiextensionsv1.JSONSchemaProps{
ID: "foo",
},
New: &apiextensionsv1.JSONSchemaProps{
ID: "bar",
},
Flagged: false,
ComparableValidation: &Pattern{},
},
}

internaltesting.RunTestcases(t, testcases...)
}
26 changes: 26 additions & 0 deletions test/patternadded/a.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: patternexamples.example.com
spec:
group: example.com
names:
kind: PatternExample
listKind: PatternExampleList
plural: patternexamples
singular: patternexample
scope: Namespaced
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
code:
type: string
27 changes: 27 additions & 0 deletions test/patternadded/b.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: patternexamples.example.com
spec:
group: example.com
names:
kind: PatternExample
listKind: PatternExampleList
plural: patternexamples
singular: patternexample
scope: Namespaced
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
code:
type: string
pattern: ^[a-z]+$
20 changes: 20 additions & 0 deletions test/patternadded/expected.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"sameVersionValidation": [
{
"version": "v1",
"propertyComparisons": [
{
"property": "^.spec.code",
"comparisonResults": [
{
"name": "pattern",
"errors": [
"pattern added : \"\" -\u003e \"^[a-z]+$\""
]
}
]
}
]
}
]
}
27 changes: 27 additions & 0 deletions test/patternchanged/a.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: patternexamples.example.com
spec:
group: example.com
names:
kind: PatternExample
listKind: PatternExampleList
plural: patternexamples
singular: patternexample
scope: Namespaced
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
code:
type: string
pattern: ^[a-z]+$
27 changes: 27 additions & 0 deletions test/patternchanged/b.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: patternexamples.example.com
spec:
group: example.com
names:
kind: PatternExample
listKind: PatternExampleList
plural: patternexamples
singular: patternexample
scope: Namespaced
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
code:
type: string
pattern: ^[A-Z]+$
20 changes: 20 additions & 0 deletions test/patternchanged/expected.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"sameVersionValidation": [
{
"version": "v1",
"propertyComparisons": [
{
"property": "^.spec.code",
"comparisonResults": [
{
"name": "pattern",
"errors": [
"pattern changed : \"^[a-z]+$\" -\u003e \"^[A-Z]+$\""
]
}
]
}
]
}
]
}