Skip to content

Commit e0011c7

Browse files
committed
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.
1 parent e0ab1a1 commit e0011c7

File tree

6 files changed

+224
-38
lines changed

6 files changed

+224
-38
lines changed

staging/src/k8s.io/apiextensions-apiserver/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ require (
2121
go.etcd.io/etcd/client/v3 v3.5.16
2222
go.opentelemetry.io/otel v1.33.0
2323
go.opentelemetry.io/otel/trace v1.33.0
24+
golang.org/x/sync v0.11.0
2425
google.golang.org/grpc v1.68.1
2526
google.golang.org/protobuf v1.36.5
2627
gopkg.in/evanphx/json-patch.v4 v4.12.0
@@ -110,7 +111,6 @@ require (
110111
golang.org/x/mod v0.21.0 // indirect
111112
golang.org/x/net v0.33.0 // indirect
112113
golang.org/x/oauth2 v0.27.0 // indirect
113-
golang.org/x/sync v0.11.0 // indirect
114114
golang.org/x/sys v0.30.0 // indirect
115115
golang.org/x/term v0.29.0 // indirect
116116
golang.org/x/text v0.22.0 // indirect

staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/validation/formats.go

Lines changed: 102 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -17,49 +17,120 @@ limitations under the License.
1717
package validation
1818

1919
import (
20+
"fmt"
2021
"strings"
22+
"sync"
23+
24+
"golang.org/x/sync/singleflight"
2125

2226
"k8s.io/apimachinery/pkg/util/sets"
27+
"k8s.io/apimachinery/pkg/util/version"
2328
"k8s.io/kube-openapi/pkg/validation/spec"
2429
)
2530

26-
var supportedFormats = sets.NewString(
27-
"bsonobjectid", // bson object ID
28-
"uri", // an URI as parsed by Golang net/url.ParseRequestURI
29-
"email", // an email address as parsed by Golang net/mail.ParseAddress
30-
"hostname", // a valid representation for an Internet host name, as defined by RFC 1034, section 3.1 [RFC1034].
31-
"ipv4", // an IPv4 IP as parsed by Golang net.ParseIP
32-
"ipv6", // an IPv6 IP as parsed by Golang net.ParseIP
33-
"cidr", // a CIDR as parsed by Golang net.ParseCIDR
34-
"mac", // a MAC address as parsed by Golang net.ParseMAC
35-
"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}$
36-
"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}$
37-
"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}$
38-
"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}$
39-
"isbn", // an ISBN10 or ISBN13 number string like "0321751043" or "978-0321751041"
40-
"isbn10", // an ISBN10 number string like "0321751043"
41-
"isbn13", // an ISBN13 number string like "978-0321751041"
42-
"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
43-
"ssn", // a U.S. social security number following the regex ^\\d{3}[- ]?\\d{2}[- ]?\\d{4}$
44-
"hexcolor", // an hexadecimal color code like "#FFFFFF", following the regex ^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$
45-
"rgbcolor", // an RGB color code like rgb like "rgb(255,255,2559"
46-
"byte", // base64 encoded binary data
47-
"password", // any kind of string
48-
"date", // a date string like "2006-01-02" as defined by full-date in RFC3339
49-
"duration", // a duration string like "22 ns" as parsed by Golang time.ParseDuration or compatible with Scala duration format
50-
"datetime", // a date time string like "2014-12-15T19:30:20.000Z" as defined by date-time in RFC3339
51-
)
31+
// supportedVersionedFormats tracks the formats supported by CRD schemas, and the version at which support was introduced.
32+
// Formats in CRD schemas are ignored when used in versions where they are not supported.
33+
var supportedVersionedFormats = []versionedFormats{
34+
{
35+
introducedVersion: version.MajorMinor(1, 0),
36+
formats: sets.New(
37+
"bsonobjectid", // bson object ID
38+
"uri", // an URI as parsed by Golang net/url.ParseRequestURI
39+
"email", // an email address as parsed by Golang net/mail.ParseAddress
40+
"hostname", // a valid representation for an Internet host name, as defined by RFC 1034, section 3.1 [RFC1034].
41+
"ipv4", // an IPv4 IP as parsed by Golang net.ParseIP
42+
"ipv6", // an IPv6 IP as parsed by Golang net.ParseIP
43+
"cidr", // a CIDR as parsed by Golang net.ParseCIDR
44+
"mac", // a MAC address as parsed by Golang net.ParseMAC
45+
"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}$
46+
"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}$
47+
"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}$
48+
"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}$
49+
"isbn", // an ISBN10 or ISBN13 number string like "0321751043" or "978-0321751041"
50+
"isbn10", // an ISBN10 number string like "0321751043"
51+
"isbn13", // an ISBN13 number string like "978-0321751041"
52+
"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
53+
"ssn", // a U.S. social security number following the regex ^\\d{3}[- ]?\\d{2}[- ]?\\d{4}$
54+
"hexcolor", // an hexadecimal color code like "#FFFFFF", following the regex ^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$
55+
"rgbcolor", // an RGB color code like rgb like "rgb(255,255,2559"
56+
"byte", // base64 encoded binary data
57+
"password", // any kind of string
58+
"date", // a date string like "2006-01-02" as defined by full-date in RFC3339
59+
"duration", // a duration string like "22 ns" as parsed by Golang time.ParseDuration or compatible with Scala duration format
60+
"datetime", // a date time string like "2014-12-15T19:30:20.000Z" as defined by date-time in RFC3339
61+
),
62+
},
63+
}
5264

5365
// StripUnsupportedFormatsPostProcess sets unsupported formats to empty string.
66+
// Only supports formats supported by all known version of Kubernetes.
67+
// Deprecated: Use StripUnsupportedFormatsPostProcessorForVersion instead.
5468
func StripUnsupportedFormatsPostProcess(s *spec.Schema) error {
55-
if len(s.Format) == 0 {
69+
return legacyPostProcessor(s)
70+
}
71+
72+
// StripUnsupportedFormatsPostProcessorForVersion determines the supported formats at the given compatibility version and
73+
// sets unsupported formats to empty string.
74+
func StripUnsupportedFormatsPostProcessorForVersion(compatibilityVersion *version.Version) func(s *spec.Schema) error {
75+
return func(s *spec.Schema) error {
76+
if len(s.Format) == 0 {
77+
return nil
78+
}
79+
80+
normalized := strings.ReplaceAll(s.Format, "-", "") // go-openapi default format name normalization
81+
if !supportedFormatsAtVersion(compatibilityVersion).supported.Has(normalized) {
82+
s.Format = ""
83+
}
84+
5685
return nil
5786
}
87+
}
5888

59-
normalized := strings.Replace(s.Format, "-", "", -1) // go-openapi default format name normalization
60-
if !supportedFormats.Has(normalized) {
61-
s.Format = ""
89+
type versionedFormats struct {
90+
introducedVersion *version.Version
91+
formats sets.Set[string]
92+
}
93+
type supportedFormats struct {
94+
compatibilityVersion *version.Version
95+
// supported is a set of formats validated at compatibilityVersion of Kubernetes.
96+
supported sets.Set[string]
97+
}
98+
99+
var cacheFormatSets = true
100+
101+
func supportedFormatsAtVersion(ver *version.Version) *supportedFormats {
102+
key := fmt.Sprintf("%d.%d", ver.Major(), ver.Minor())
103+
var entry interface{}
104+
if entry, ok := baseEnvs.Load(key); ok {
105+
return entry.(*supportedFormats)
106+
}
107+
entry, _, _ = baseEnvsSingleflight.Do(key, func() (interface{}, error) {
108+
entry := newFormatsAtVersion(ver, supportedVersionedFormats)
109+
if cacheFormatSets {
110+
baseEnvs.Store(key, entry)
111+
}
112+
return entry, nil
113+
})
114+
return entry.(*supportedFormats)
115+
}
116+
117+
func newFormatsAtVersion(ver *version.Version, versionedFormats []versionedFormats) *supportedFormats {
118+
result := &supportedFormats{
119+
compatibilityVersion: ver,
120+
supported: sets.New[string](),
62121
}
122+
for _, vf := range versionedFormats {
123+
if ver.AtLeast(vf.introducedVersion) {
124+
result.supported = result.supported.Union(vf.formats)
63125

64-
return nil
126+
}
127+
}
128+
return result
65129
}
130+
131+
var (
132+
baseEnvs = sync.Map{}
133+
baseEnvsSingleflight = &singleflight.Group{}
134+
)
135+
136+
var legacyPostProcessor = StripUnsupportedFormatsPostProcessorForVersion(version.MajorMinor(1, 0))

staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/validation/formats_test.go

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,122 @@ package validation
1919
import (
2020
"testing"
2121

22+
"k8s.io/apimachinery/pkg/util/sets"
23+
"k8s.io/apimachinery/pkg/util/version"
24+
"k8s.io/kube-openapi/pkg/validation/spec"
2225
"k8s.io/kube-openapi/pkg/validation/strfmt"
2326
)
2427

2528
func TestRegistryFormats(t *testing.T) {
26-
for f := range supportedFormats {
27-
if !strfmt.Default.ContainsName(f) {
28-
t.Errorf("expected format %q in strfmt default registry", f)
29+
for _, sf := range supportedVersionedFormats {
30+
for f := range sf.formats {
31+
if !strfmt.Default.ContainsName(f) {
32+
t.Errorf("expected format %q in strfmt default registry", f)
33+
}
2934
}
3035
}
3136
}
37+
38+
func TestSupportedFormats(t *testing.T) {
39+
vf := []versionedFormats{
40+
{
41+
introducedVersion: version.MajorMinor(1, 0),
42+
formats: sets.New(
43+
"A",
44+
),
45+
},
46+
{
47+
introducedVersion: version.MajorMinor(1, 1),
48+
formats: sets.New(
49+
"B",
50+
"C",
51+
),
52+
},
53+
// Version 1.2 has no new supported formats
54+
{
55+
introducedVersion: version.MajorMinor(1, 3),
56+
formats: sets.New(
57+
"D",
58+
),
59+
},
60+
{
61+
introducedVersion: version.MajorMinor(1, 3), // same version as previous entry
62+
formats: sets.New(
63+
"E",
64+
),
65+
},
66+
{
67+
introducedVersion: version.MajorMinor(1, 4),
68+
formats: sets.New[string](),
69+
},
70+
}
71+
72+
testCases := []struct {
73+
name string
74+
version *version.Version
75+
expectedFormats sets.Set[string]
76+
}{
77+
{
78+
name: "version 1.0",
79+
version: version.MajorMinor(1, 0),
80+
expectedFormats: sets.New("A"),
81+
},
82+
{
83+
name: "version 1.1",
84+
version: version.MajorMinor(1, 1),
85+
expectedFormats: sets.New("A", "B", "C"),
86+
},
87+
{
88+
name: "version 1.2",
89+
version: version.MajorMinor(1, 2),
90+
expectedFormats: sets.New("A", "B", "C"),
91+
},
92+
{
93+
name: "version 1.3",
94+
version: version.MajorMinor(1, 3),
95+
expectedFormats: sets.New("A", "B", "C", "D", "E"),
96+
},
97+
{
98+
name: "version 1.4",
99+
version: version.MajorMinor(1, 4),
100+
expectedFormats: sets.New("A", "B", "C", "D", "E"),
101+
},
102+
}
103+
allFormats := newFormatsAtVersion(version.MajorMinor(0, 0), vf)
104+
for _, tc := range testCases {
105+
t.Run(tc.name, func(t *testing.T) {
106+
got := newFormatsAtVersion(tc.version, vf)
107+
108+
t.Run("newFormatsAtVersion", func(t *testing.T) {
109+
if !got.supported.Equal(tc.expectedFormats) {
110+
t.Errorf("expected %v, got %v", tc.expectedFormats, got.supported)
111+
}
112+
113+
if len(got.supported.Difference(allFormats.supported)) == 0 {
114+
t.Errorf("expected allFormats to be a superset of all formats, but was missing %v", allFormats.supported)
115+
}
116+
})
117+
118+
t.Run("StripUnsupportedFormatsPostProcessorForVersion", func(t *testing.T) {
119+
processor := StripUnsupportedFormatsPostProcessorForVersion(tc.version)
120+
for f := range allFormats.supported {
121+
schema := &spec.Schema{SchemaProps: spec.SchemaProps{Format: f}}
122+
err := processor(schema)
123+
if err != nil {
124+
t.Fatalf("Unexpected error: %v", err)
125+
}
126+
gotFormat := schema.Format
127+
if tc.expectedFormats.Has(f) {
128+
if gotFormat != f {
129+
t.Errorf("expected format %q, got %q", f, gotFormat)
130+
}
131+
} else {
132+
if gotFormat != "" {
133+
t.Errorf("expected format to be stripped out, got %q", gotFormat)
134+
}
135+
}
136+
}
137+
})
138+
})
139+
}
140+
}

staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/validation/validation.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"k8s.io/apiextensions-apiserver/pkg/features"
2626
"k8s.io/apimachinery/pkg/util/validation/field"
2727
"k8s.io/apiserver/pkg/cel/common"
28+
"k8s.io/apiserver/pkg/cel/environment"
2829
utilfeature "k8s.io/apiserver/pkg/util/feature"
2930
openapierrors "k8s.io/kube-openapi/pkg/validation/errors"
3031
"k8s.io/kube-openapi/pkg/validation/spec"
@@ -102,7 +103,8 @@ func NewSchemaValidator(customResourceValidation *apiextensions.JSONSchemaProps)
102103
openapiSchema := &spec.Schema{}
103104
if customResourceValidation != nil {
104105
// TODO: replace with NewStructural(...).ToGoOpenAPI
105-
if err := ConvertJSONSchemaPropsWithPostProcess(customResourceValidation, openapiSchema, StripUnsupportedFormatsPostProcess); err != nil {
106+
formatPostProcessor := StripUnsupportedFormatsPostProcessorForVersion(environment.DefaultCompatibilityVersion())
107+
if err := ConvertJSONSchemaPropsWithPostProcess(customResourceValidation, openapiSchema, formatPostProcessor); err != nil {
106108
return nil, nil, err
107109
}
108110
}

staging/src/k8s.io/apiextensions-apiserver/test/integration/ratcheting_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import (
4949
"k8s.io/apimachinery/pkg/util/version"
5050
"k8s.io/apimachinery/pkg/util/wait"
5151
utilyaml "k8s.io/apimachinery/pkg/util/yaml"
52+
"k8s.io/apiserver/pkg/cel/environment"
5253
utilfeature "k8s.io/apiserver/pkg/util/feature"
5354
"k8s.io/client-go/dynamic"
5455
featuregatetesting "k8s.io/component-base/featuregate/testing"
@@ -1769,7 +1770,8 @@ func newValidator(customResourceValidation *apiextensionsinternal.JSONSchemaProp
17691770
openapiSchema := &spec.Schema{}
17701771
if customResourceValidation != nil {
17711772
// TODO: replace with NewStructural(...).ToGoOpenAPI
1772-
if err := apiservervalidation.ConvertJSONSchemaPropsWithPostProcess(customResourceValidation, openapiSchema, apiservervalidation.StripUnsupportedFormatsPostProcess); err != nil {
1773+
formatPostProcessor := apiservervalidation.StripUnsupportedFormatsPostProcessorForVersion(environment.DefaultCompatibilityVersion())
1774+
if err := apiservervalidation.ConvertJSONSchemaPropsWithPostProcess(customResourceValidation, openapiSchema, formatPostProcessor); err != nil {
17731775
return nil, err
17741776
}
17751777
}

test/e2e/apimachinery/crd_publish_openapi.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
"github.com/onsi/ginkgo/v2"
3030
"sigs.k8s.io/yaml"
3131

32+
"k8s.io/apiserver/pkg/cel/environment"
3233
openapiutil "k8s.io/kube-openapi/pkg/util"
3334
"k8s.io/utils/pointer"
3435

@@ -698,7 +699,8 @@ func convertJSONSchemaProps(in []byte, out *spec.Schema) error {
698699
return err
699700
}
700701
kubeOut := spec.Schema{}
701-
if err := validation.ConvertJSONSchemaPropsWithPostProcess(&internal, &kubeOut, validation.StripUnsupportedFormatsPostProcess); err != nil {
702+
formatPostProcessor := validation.StripUnsupportedFormatsPostProcessorForVersion(environment.DefaultCompatibilityVersion())
703+
if err := validation.ConvertJSONSchemaPropsWithPostProcess(&internal, &kubeOut, formatPostProcessor); err != nil {
702704
return err
703705
}
704706
bs, err := json.Marshal(kubeOut)

0 commit comments

Comments
 (0)