Support emulation versioning of formats

We plan to add more formats in upcoming releases.
This tracks which formats are introduced at a version.

Unrecognized formats remain ignored. That is,
if a format is not supported at the emulated version,
the format is not enforced. This differs from typical
field handling, where unsupported field values are
forbidden. This is pre-existing behavior and is
in compliance with JSON Schema's format handling.

Ratcheting of custom resources helps with the introduction
of new formats. When a cluster is upgraded to a version
of Kubernetes that supports a format already set (but not enforced)
in a custom resource definition, the format will start
being enforced against custom resources. Ratcheting will
tolerate unchanged values of custom resources, even
if the value is not valid according to the format.
This commit is contained in:
Joe Betz 2025-03-12 10:24:19 -04:00
parent e0ab1a16ad
commit e0011c7236
6 changed files with 227 additions and 41 deletions

View File

@ -21,6 +21,7 @@ require (
go.etcd.io/etcd/client/v3 v3.5.16
go.opentelemetry.io/otel v1.33.0
go.opentelemetry.io/otel/trace v1.33.0
golang.org/x/sync v0.11.0
google.golang.org/grpc v1.68.1
google.golang.org/protobuf v1.36.5
gopkg.in/evanphx/json-patch.v4 v4.12.0
@ -110,7 +111,6 @@ require (
golang.org/x/mod v0.21.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/oauth2 v0.27.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/term v0.29.0 // indirect
golang.org/x/text v0.22.0 // indirect

View File

@ -17,49 +17,120 @@ limitations under the License.
package validation
import (
"fmt"
"strings"
"sync"
"golang.org/x/sync/singleflight"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apimachinery/pkg/util/version"
"k8s.io/kube-openapi/pkg/validation/spec"
)
var supportedFormats = sets.NewString(
"bsonobjectid", // bson object ID
"uri", // an URI as parsed by Golang net/url.ParseRequestURI
"email", // an email address as parsed by Golang net/mail.ParseAddress
"hostname", // a valid representation for an Internet host name, as defined by RFC 1034, section 3.1 [RFC1034].
"ipv4", // an IPv4 IP as parsed by Golang net.ParseIP
"ipv6", // an IPv6 IP as parsed by Golang net.ParseIP
"cidr", // a CIDR as parsed by Golang net.ParseCIDR
"mac", // a MAC address as parsed by Golang net.ParseMAC
"uuid", // an UUID that allows uppercase defined by the regex (?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$
"uuid3", // an UUID3 that allows uppercase defined by the regex (?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?3[0-9a-f]{3}-?[0-9a-f]{4}-?[0-9a-f]{12}$
"uuid4", // an UUID4 that allows uppercase defined by the regex (?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?4[0-9a-f]{3}-?[89ab][0-9a-f]{3}-?[0-9a-f]{12}$
"uuid5", // an UUID6 that allows uppercase defined by the regex (?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?5[0-9a-f]{3}-?[89ab][0-9a-f]{3}-?[0-9a-f]{12}$
"isbn", // an ISBN10 or ISBN13 number string like "0321751043" or "978-0321751041"
"isbn10", // an ISBN10 number string like "0321751043"
"isbn13", // an ISBN13 number string like "978-0321751041"
"creditcard", // a credit card number defined by the regex ^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|6(?:011|5[0-9][0-9])[0-9]{12}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\\d{3})\\d{11})$ with any non digit characters mixed in
"ssn", // a U.S. social security number following the regex ^\\d{3}[- ]?\\d{2}[- ]?\\d{4}$
"hexcolor", // an hexadecimal color code like "#FFFFFF", following the regex ^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$
"rgbcolor", // an RGB color code like rgb like "rgb(255,255,2559"
"byte", // base64 encoded binary data
"password", // any kind of string
"date", // a date string like "2006-01-02" as defined by full-date in RFC3339
"duration", // a duration string like "22 ns" as parsed by Golang time.ParseDuration or compatible with Scala duration format
"datetime", // a date time string like "2014-12-15T19:30:20.000Z" as defined by date-time in RFC3339
)
// supportedVersionedFormats tracks the formats supported by CRD schemas, and the version at which support was introduced.
// Formats in CRD schemas are ignored when used in versions where they are not supported.
var supportedVersionedFormats = []versionedFormats{
{
introducedVersion: version.MajorMinor(1, 0),
formats: sets.New(
"bsonobjectid", // bson object ID
"uri", // an URI as parsed by Golang net/url.ParseRequestURI
"email", // an email address as parsed by Golang net/mail.ParseAddress
"hostname", // a valid representation for an Internet host name, as defined by RFC 1034, section 3.1 [RFC1034].
"ipv4", // an IPv4 IP as parsed by Golang net.ParseIP
"ipv6", // an IPv6 IP as parsed by Golang net.ParseIP
"cidr", // a CIDR as parsed by Golang net.ParseCIDR
"mac", // a MAC address as parsed by Golang net.ParseMAC
"uuid", // an UUID that allows uppercase defined by the regex (?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$
"uuid3", // an UUID3 that allows uppercase defined by the regex (?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?3[0-9a-f]{3}-?[0-9a-f]{4}-?[0-9a-f]{12}$
"uuid4", // an UUID4 that allows uppercase defined by the regex (?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?4[0-9a-f]{3}-?[89ab][0-9a-f]{3}-?[0-9a-f]{12}$
"uuid5", // an UUID6 that allows uppercase defined by the regex (?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?5[0-9a-f]{3}-?[89ab][0-9a-f]{3}-?[0-9a-f]{12}$
"isbn", // an ISBN10 or ISBN13 number string like "0321751043" or "978-0321751041"
"isbn10", // an ISBN10 number string like "0321751043"
"isbn13", // an ISBN13 number string like "978-0321751041"
"creditcard", // a credit card number defined by the regex ^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|6(?:011|5[0-9][0-9])[0-9]{12}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\\d{3})\\d{11})$ with any non digit characters mixed in
"ssn", // a U.S. social security number following the regex ^\\d{3}[- ]?\\d{2}[- ]?\\d{4}$
"hexcolor", // an hexadecimal color code like "#FFFFFF", following the regex ^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$
"rgbcolor", // an RGB color code like rgb like "rgb(255,255,2559"
"byte", // base64 encoded binary data
"password", // any kind of string
"date", // a date string like "2006-01-02" as defined by full-date in RFC3339
"duration", // a duration string like "22 ns" as parsed by Golang time.ParseDuration or compatible with Scala duration format
"datetime", // a date time string like "2014-12-15T19:30:20.000Z" as defined by date-time in RFC3339
),
},
}
// StripUnsupportedFormatsPostProcess sets unsupported formats to empty string.
// Only supports formats supported by all known version of Kubernetes.
// Deprecated: Use StripUnsupportedFormatsPostProcessorForVersion instead.
func StripUnsupportedFormatsPostProcess(s *spec.Schema) error {
if len(s.Format) == 0 {
return legacyPostProcessor(s)
}
// StripUnsupportedFormatsPostProcessorForVersion determines the supported formats at the given compatibility version and
// sets unsupported formats to empty string.
func StripUnsupportedFormatsPostProcessorForVersion(compatibilityVersion *version.Version) func(s *spec.Schema) error {
return func(s *spec.Schema) error {
if len(s.Format) == 0 {
return nil
}
normalized := strings.ReplaceAll(s.Format, "-", "") // go-openapi default format name normalization
if !supportedFormatsAtVersion(compatibilityVersion).supported.Has(normalized) {
s.Format = ""
}
return nil
}
normalized := strings.Replace(s.Format, "-", "", -1) // go-openapi default format name normalization
if !supportedFormats.Has(normalized) {
s.Format = ""
}
return nil
}
type versionedFormats struct {
introducedVersion *version.Version
formats sets.Set[string]
}
type supportedFormats struct {
compatibilityVersion *version.Version
// supported is a set of formats validated at compatibilityVersion of Kubernetes.
supported sets.Set[string]
}
var cacheFormatSets = true
func supportedFormatsAtVersion(ver *version.Version) *supportedFormats {
key := fmt.Sprintf("%d.%d", ver.Major(), ver.Minor())
var entry interface{}
if entry, ok := baseEnvs.Load(key); ok {
return entry.(*supportedFormats)
}
entry, _, _ = baseEnvsSingleflight.Do(key, func() (interface{}, error) {
entry := newFormatsAtVersion(ver, supportedVersionedFormats)
if cacheFormatSets {
baseEnvs.Store(key, entry)
}
return entry, nil
})
return entry.(*supportedFormats)
}
func newFormatsAtVersion(ver *version.Version, versionedFormats []versionedFormats) *supportedFormats {
result := &supportedFormats{
compatibilityVersion: ver,
supported: sets.New[string](),
}
for _, vf := range versionedFormats {
if ver.AtLeast(vf.introducedVersion) {
result.supported = result.supported.Union(vf.formats)
}
}
return result
}
var (
baseEnvs = sync.Map{}
baseEnvsSingleflight = &singleflight.Group{}
)
var legacyPostProcessor = StripUnsupportedFormatsPostProcessorForVersion(version.MajorMinor(1, 0))

View File

@ -19,13 +19,122 @@ package validation
import (
"testing"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apimachinery/pkg/util/version"
"k8s.io/kube-openapi/pkg/validation/spec"
"k8s.io/kube-openapi/pkg/validation/strfmt"
)
func TestRegistryFormats(t *testing.T) {
for f := range supportedFormats {
if !strfmt.Default.ContainsName(f) {
t.Errorf("expected format %q in strfmt default registry", f)
for _, sf := range supportedVersionedFormats {
for f := range sf.formats {
if !strfmt.Default.ContainsName(f) {
t.Errorf("expected format %q in strfmt default registry", f)
}
}
}
}
func TestSupportedFormats(t *testing.T) {
vf := []versionedFormats{
{
introducedVersion: version.MajorMinor(1, 0),
formats: sets.New(
"A",
),
},
{
introducedVersion: version.MajorMinor(1, 1),
formats: sets.New(
"B",
"C",
),
},
// Version 1.2 has no new supported formats
{
introducedVersion: version.MajorMinor(1, 3),
formats: sets.New(
"D",
),
},
{
introducedVersion: version.MajorMinor(1, 3), // same version as previous entry
formats: sets.New(
"E",
),
},
{
introducedVersion: version.MajorMinor(1, 4),
formats: sets.New[string](),
},
}
testCases := []struct {
name string
version *version.Version
expectedFormats sets.Set[string]
}{
{
name: "version 1.0",
version: version.MajorMinor(1, 0),
expectedFormats: sets.New("A"),
},
{
name: "version 1.1",
version: version.MajorMinor(1, 1),
expectedFormats: sets.New("A", "B", "C"),
},
{
name: "version 1.2",
version: version.MajorMinor(1, 2),
expectedFormats: sets.New("A", "B", "C"),
},
{
name: "version 1.3",
version: version.MajorMinor(1, 3),
expectedFormats: sets.New("A", "B", "C", "D", "E"),
},
{
name: "version 1.4",
version: version.MajorMinor(1, 4),
expectedFormats: sets.New("A", "B", "C", "D", "E"),
},
}
allFormats := newFormatsAtVersion(version.MajorMinor(0, 0), vf)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got := newFormatsAtVersion(tc.version, vf)
t.Run("newFormatsAtVersion", func(t *testing.T) {
if !got.supported.Equal(tc.expectedFormats) {
t.Errorf("expected %v, got %v", tc.expectedFormats, got.supported)
}
if len(got.supported.Difference(allFormats.supported)) == 0 {
t.Errorf("expected allFormats to be a superset of all formats, but was missing %v", allFormats.supported)
}
})
t.Run("StripUnsupportedFormatsPostProcessorForVersion", func(t *testing.T) {
processor := StripUnsupportedFormatsPostProcessorForVersion(tc.version)
for f := range allFormats.supported {
schema := &spec.Schema{SchemaProps: spec.SchemaProps{Format: f}}
err := processor(schema)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
gotFormat := schema.Format
if tc.expectedFormats.Has(f) {
if gotFormat != f {
t.Errorf("expected format %q, got %q", f, gotFormat)
}
} else {
if gotFormat != "" {
t.Errorf("expected format to be stripped out, got %q", gotFormat)
}
}
}
})
})
}
}

View File

@ -25,6 +25,7 @@ import (
"k8s.io/apiextensions-apiserver/pkg/features"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/apiserver/pkg/cel/common"
"k8s.io/apiserver/pkg/cel/environment"
utilfeature "k8s.io/apiserver/pkg/util/feature"
openapierrors "k8s.io/kube-openapi/pkg/validation/errors"
"k8s.io/kube-openapi/pkg/validation/spec"
@ -102,7 +103,8 @@ func NewSchemaValidator(customResourceValidation *apiextensions.JSONSchemaProps)
openapiSchema := &spec.Schema{}
if customResourceValidation != nil {
// TODO: replace with NewStructural(...).ToGoOpenAPI
if err := ConvertJSONSchemaPropsWithPostProcess(customResourceValidation, openapiSchema, StripUnsupportedFormatsPostProcess); err != nil {
formatPostProcessor := StripUnsupportedFormatsPostProcessorForVersion(environment.DefaultCompatibilityVersion())
if err := ConvertJSONSchemaPropsWithPostProcess(customResourceValidation, openapiSchema, formatPostProcessor); err != nil {
return nil, nil, err
}
}

View File

@ -49,6 +49,7 @@ import (
"k8s.io/apimachinery/pkg/util/version"
"k8s.io/apimachinery/pkg/util/wait"
utilyaml "k8s.io/apimachinery/pkg/util/yaml"
"k8s.io/apiserver/pkg/cel/environment"
utilfeature "k8s.io/apiserver/pkg/util/feature"
"k8s.io/client-go/dynamic"
featuregatetesting "k8s.io/component-base/featuregate/testing"
@ -1769,7 +1770,8 @@ func newValidator(customResourceValidation *apiextensionsinternal.JSONSchemaProp
openapiSchema := &spec.Schema{}
if customResourceValidation != nil {
// TODO: replace with NewStructural(...).ToGoOpenAPI
if err := apiservervalidation.ConvertJSONSchemaPropsWithPostProcess(customResourceValidation, openapiSchema, apiservervalidation.StripUnsupportedFormatsPostProcess); err != nil {
formatPostProcessor := apiservervalidation.StripUnsupportedFormatsPostProcessorForVersion(environment.DefaultCompatibilityVersion())
if err := apiservervalidation.ConvertJSONSchemaPropsWithPostProcess(customResourceValidation, openapiSchema, formatPostProcessor); err != nil {
return nil, err
}
}

View File

@ -29,6 +29,7 @@ import (
"github.com/onsi/ginkgo/v2"
"sigs.k8s.io/yaml"
"k8s.io/apiserver/pkg/cel/environment"
openapiutil "k8s.io/kube-openapi/pkg/util"
"k8s.io/utils/pointer"
@ -698,7 +699,8 @@ func convertJSONSchemaProps(in []byte, out *spec.Schema) error {
return err
}
kubeOut := spec.Schema{}
if err := validation.ConvertJSONSchemaPropsWithPostProcess(&internal, &kubeOut, validation.StripUnsupportedFormatsPostProcess); err != nil {
formatPostProcessor := validation.StripUnsupportedFormatsPostProcessorForVersion(environment.DefaultCompatibilityVersion())
if err := validation.ConvertJSONSchemaPropsWithPostProcess(&internal, &kubeOut, formatPostProcessor); err != nil {
return err
}
bs, err := json.Marshal(kubeOut)