Skip to content

Commit 4666b8c

Browse files
authored
Merge pull request kubernetes#130783 from jpbetz/versioned-formats
Support emulation versioning of custom resource formats
2 parents be127ae + e0011c7 commit 4666b8c

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)