Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
24 changes: 24 additions & 0 deletions docs/validations.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,27 @@ 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.

By default, removing a pattern is also considered incompatible.

#### Configuration

The `pattern` validation can be configured to allow removing an existing pattern when you know the change is safe:

- `removalPolicy` - controls whether removing a pattern constraint is considered compatible. Allowed values are `Allow` and `Disallow`. When set to `Allow`, removing a pattern is not flagged. The default is `Disallow` to remain maximally conservative.

Example configuration that allows removing patterns:

```yaml
validations:
- name: pattern
enforcement: Error
configuration:
removalPolicy: Allow
```
5 changes: 5 additions & 0 deletions examples/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,8 @@ validations:
enforcement: Error
configuration:
additionPolicy: Allow
# example of configuring pattern validation to allow removal of pattern constraints
- name: pattern
enforcement: Error
configuration:
removalPolicy: Allow
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
143 changes: 143 additions & 0 deletions pkg/validations/property/pattern.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// 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(cfg map[string]interface{}) (validations.Validation, error) {
patternCfg := &PatternConfig{}

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

err = ValidatePatternConfig(patternCfg)
if err != nil {
return nil, fmt.Errorf("validating pattern config: %w", err)
}

return &Pattern{PatternConfig: *patternCfg}, nil
}

// ValidatePatternConfig ensures provided PatternConfig is valid and defaults missing values.
func ValidatePatternConfig(in *PatternConfig) error {
if in == nil {
return nil
}

switch in.RemovalPolicy {
case PatternRemovalPolicyAllow, PatternRemovalPolicyDisallow:
// valid entries
case PatternRemovalPolicy(""):
in.RemovalPolicy = PatternRemovalPolicyDisallow
default:
return fmt.Errorf("%w : %q", errUnknownPatternRemovalPolicy, in.RemovalPolicy)
}

return nil
}

var errUnknownPatternRemovalPolicy = errors.New("unknown removal policy")

// PatternRemovalPolicy represents how removing a pattern constraint should be evaluated.
type PatternRemovalPolicy string

const (
// PatternRemovalPolicyAllow treats removing a pattern constraint as compatible.
PatternRemovalPolicyAllow PatternRemovalPolicy = "Allow"
// PatternRemovalPolicyDisallow treats removing a pattern constraint as incompatible.
PatternRemovalPolicyDisallow PatternRemovalPolicy = "Disallow"
)

// PatternConfig contains additional configuration for the Pattern validation.
type PatternConfig struct {
// RemovalPolicy dictates whether removing a pattern constraint is compatible.
// Allowed values are Allow and Disallow. Defaults to Disallow.
RemovalPolicy PatternRemovalPolicy `json:"removalPolicy,omitempty"`
}

// Pattern is a Validation that can be used to identify
// incompatible changes to the pattern constraints of CRD properties.
type Pattern struct {
PatternConfig
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 == "" && p.RemovalPolicy != PatternRemovalPolicyAllow:
err = fmt.Errorf("%w : %q -> %q", ErrPatternRemoved, a.Pattern, b.Pattern)
case a.Pattern != "" && b.Pattern != "" && 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")

// ErrPatternRemoved represents an error state when a property Pattern was removed.
var ErrPatternRemoved = errors.New("pattern removed")
91 changes: 91 additions & 0 deletions pkg/validations/property/pattern_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// 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, flagged by default",
Old: &apiextensionsv1.JSONSchemaProps{
Pattern: "^[a-z]+$",
},
New: &apiextensionsv1.JSONSchemaProps{},
Flagged: true,
ComparableValidation: &Pattern{},
},
{
Name: "pattern removed, allowed via config",
Old: &apiextensionsv1.JSONSchemaProps{
Pattern: "^[a-z]+$",
},
New: &apiextensionsv1.JSONSchemaProps{},
Flagged: false,
ComparableValidation: &Pattern{
PatternConfig: PatternConfig{RemovalPolicy: PatternRemovalPolicyAllow},
},
},
{
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]+$
Loading