From 67632f8229de9a36cba1d7d88f36aa597cf1ff91 Mon Sep 17 00:00:00 2001 From: Lalit Chauhan Date: Tue, 16 Sep 2025 17:44:16 +0000 Subject: [PATCH] Add support for k8s-long-name-caseless format. --- .../pkg/api/validate/content/dns.go | 3 + .../apimachinery/pkg/api/validate/strfmt.go | 3 + .../pkg/api/validate/strfmt_test.go | 62 ++++++++++- .../tags/format/k8s-long-name-caseless/doc.go | 41 +++++++ .../format/k8s-long-name-caseless/doc_test.go | 72 +++++++++++++ .../zz_generated.validations.go | 101 ++++++++++++++++++ .../cmd/validation-gen/validators/format.go | 18 ++-- 7 files changed, 292 insertions(+), 8 deletions(-) create mode 100644 staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-long-name-caseless/doc.go create mode 100644 staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-long-name-caseless/doc_test.go create mode 100644 staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-long-name-caseless/zz_generated.validations.go diff --git a/staging/src/k8s.io/apimachinery/pkg/api/validate/content/dns.go b/staging/src/k8s.io/apimachinery/pkg/api/validate/content/dns.go index 218e2b4a56c..ddba8cbb310 100644 --- a/staging/src/k8s.io/apimachinery/pkg/api/validate/content/dns.go +++ b/staging/src/k8s.io/apimachinery/pkg/api/validate/content/dns.go @@ -67,6 +67,9 @@ func IsDNS1123Subdomain(value string) []string { // IsDNS1123SubdomainCaseless tests for a string that conforms to the definition of a // subdomain in DNS (RFC 1123). +// Deprecated: Use IsDNS1123Subdomain for strict, lowercase validation. +// Case-insensitive names are not recommended as they can lead to ambiguity +// (e.g., 'Foo', 'FOO', and 'foo' would be allowed names for foo). func IsDNS1123SubdomainCaseless(value string) []string { return isDNS1123Subdomain(value, true) } diff --git a/staging/src/k8s.io/apimachinery/pkg/api/validate/strfmt.go b/staging/src/k8s.io/apimachinery/pkg/api/validate/strfmt.go index 7f19ea10482..6ca7247de00 100644 --- a/staging/src/k8s.io/apimachinery/pkg/api/validate/strfmt.go +++ b/staging/src/k8s.io/apimachinery/pkg/api/validate/strfmt.go @@ -97,6 +97,9 @@ func LabelKey[T ~string](_ context.Context, op operation.Operation, fldPath *fie // - must be less than 254 characters long // - each element must start and end with alphanumeric characters // - each element must contain only alphanumeric characters or dashes +// +// Deprecated: Case-insensitive names are not recommended as they can lead to ambiguity +// (e.g., 'Foo', 'FOO', and 'foo' would be allowed names for foo). Use LongName for strict, lowercase validation. func LongNameCaseless[T ~string](_ context.Context, op operation.Operation, fldPath *field.Path, value, _ *T) field.ErrorList { if value == nil { return nil diff --git a/staging/src/k8s.io/apimachinery/pkg/api/validate/strfmt_test.go b/staging/src/k8s.io/apimachinery/pkg/api/validate/strfmt_test.go index 9c93d47db46..6fe7b70e326 100644 --- a/staging/src/k8s.io/apimachinery/pkg/api/validate/strfmt_test.go +++ b/staging/src/k8s.io/apimachinery/pkg/api/validate/strfmt_test.go @@ -105,9 +105,9 @@ func TestLongName(t *testing.T) { }, }, { name: "invalid: too long", - input: "0123456789012345678901234567890123456789012345678901234567890123.0123456789012345678901234567890123456789012345678901234567890123.0123456789012345678901234567890123456789012345678901234567890123.01234567890123456789012345678901234567890123456789012345678901234", + input: strings.Repeat("a", 254), wantErrs: field.ErrorList{ - field.Invalid(fldPath, "0123456789012345678901234567890123456789012345678901234567890123.0123456789012345678901234567890123456789012345678901234567890123.0123456789012345678901234567890123456789012345678901234567890123.01234567890123456789012345678901234567890123456789012345678901234", "must be no more than 253 bytes").WithOrigin("format=k8s-long-name"), + field.Invalid(fldPath, strings.Repeat("a", 254), "must be no more than 253 bytes").WithOrigin("format=k8s-long-name"), }, }, { name: "invalid: starts with dash", @@ -411,3 +411,61 @@ func TestLabelValue(t *testing.T) { }) } } + +func TestLongNameCaseless(t *testing.T) { + ctx := context.Background() + fldPath := field.NewPath("test") + + testCases := []struct { + name string + input string + wantErrs field.ErrorList + }{{ + name: "valid", + input: "A.b.C", + wantErrs: nil, + }, { + name: "invalid: empty", + input: "", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "", "an RFC 1123 subdomain must consist of alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character").WithOrigin("format=k8s-long-name-caseless"), + }, + }, { + name: "invalid: too long", + input: strings.Repeat("a", 254), + wantErrs: field.ErrorList{ + field.Invalid(fldPath, strings.Repeat("a", 254), "must be no more than 253 bytes").WithOrigin("format=k8s-long-name-caseless"), + }, + }, { + name: "invalid: starts with dash", + input: "-A.b.C", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "-A.b.C", "an RFC 1123 subdomain must consist of alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character").WithOrigin("format=k8s-long-name-caseless"), + }, + }, + { + name: "invalid: ends with dash", + input: "A.b.C-", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "A.b.C-", "an RFC 1123 subdomain must consist of alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character").WithOrigin("format=k8s-long-name-caseless"), + }, + }, + { + name: "invalid: other chars", + input: "A_b.C", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "A_b.C", "an RFC 1123 subdomain must consist of alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character").WithOrigin("format=k8s-long-name-caseless"), + }, + }, + } + + matcher := field.ErrorMatcher{}.ByType().ByField().ByOrigin() + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + value := tc.input + gotErrs := LongNameCaseless(ctx, operation.Operation{}, fldPath, &value, nil) + + matcher.Test(t, tc.wantErrs, gotErrs) + }) + } +} diff --git a/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-long-name-caseless/doc.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-long-name-caseless/doc.go new file mode 100644 index 00000000000..35632db1ca7 --- /dev/null +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-long-name-caseless/doc.go @@ -0,0 +1,41 @@ +/* +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. +*/ + +// +k8s:validation-gen=TypeMeta +// +k8s:validation-gen-scheme-registry=k8s.io/code-generator/cmd/validation-gen/testscheme.Scheme + +// This is a test package. +package format + +import "k8s.io/code-generator/cmd/validation-gen/testscheme" + +var localSchemeBuilder = testscheme.New() + +type Struct struct { + TypeMeta int + + // +k8s:format=k8s-long-name-caseless + LongNameField string `json:"longNameField"` + + // +k8s:format=k8s-long-name-caseless + LongNamePtrField *string `json:"longNamePtrField"` + + // Note: no validation here + LongNameTypedefField LongNameStringType `json:"longNameTypedefField"` +} + +// +k8s:format=k8s-long-name-caseless +type LongNameStringType string diff --git a/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-long-name-caseless/doc_test.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-long-name-caseless/doc_test.go new file mode 100644 index 00000000000..eafa6eaa812 --- /dev/null +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-long-name-caseless/doc_test.go @@ -0,0 +1,72 @@ +/* +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 format + +import ( + "testing" + + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/ptr" +) + +func TestCaseless(t *testing.T) { + st := localSchemeBuilder.Test(t) + + st.Value(&Struct{ + LongNameField: "foo.bar", + LongNamePtrField: ptr.To("foo.bar"), + LongNameTypedefField: "foo.bar", + }).ExpectValid() + + st.Value(&Struct{ + LongNameField: "1.2.3.4", + LongNamePtrField: ptr.To("1.2.3.4"), + LongNameTypedefField: "1.2.3.4", + }).ExpectValid() + + st.Value(&Struct{ + LongNameField: "Foo.Bar", + LongNamePtrField: ptr.To("Foo.Bar"), + LongNameTypedefField: "Foo.Bar", + }).ExpectValid() + + invalidStruct := &Struct{ + LongNameField: "", + LongNamePtrField: ptr.To(""), + LongNameTypedefField: "", + } + st.Value(invalidStruct).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("longNameField"), nil, "").WithOrigin("format=k8s-long-name-caseless"), + field.Invalid(field.NewPath("longNamePtrField"), nil, "").WithOrigin("format=k8s-long-name-caseless"), + field.Invalid(field.NewPath("longNameTypedefField"), nil, "").WithOrigin("format=k8s-long-name-caseless"), + }) + // Test validation ratcheting + st.Value(invalidStruct).OldValue(invalidStruct).ExpectValid() + + invalidStruct = &Struct{ + LongNameField: "Not a LongName", + LongNamePtrField: ptr.To("Not a LongName"), + LongNameTypedefField: "Not a LongName", + } + st.Value(invalidStruct).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("longNameField"), nil, "").WithOrigin("format=k8s-long-name-caseless"), + field.Invalid(field.NewPath("longNamePtrField"), nil, "").WithOrigin("format=k8s-long-name-caseless"), + field.Invalid(field.NewPath("longNameTypedefField"), nil, "").WithOrigin("format=k8s-long-name-caseless"), + }) + // Test validation ratcheting + st.Value(invalidStruct).OldValue(invalidStruct).ExpectValid() +} diff --git a/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-long-name-caseless/zz_generated.validations.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-long-name-caseless/zz_generated.validations.go new file mode 100644 index 00000000000..89f607b1f25 --- /dev/null +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-long-name-caseless/zz_generated.validations.go @@ -0,0 +1,101 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright 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. +*/ + +// Code generated by validation-gen. DO NOT EDIT. + +package format + +import ( + context "context" + fmt "fmt" + + operation "k8s.io/apimachinery/pkg/api/operation" + safe "k8s.io/apimachinery/pkg/api/safe" + validate "k8s.io/apimachinery/pkg/api/validate" + field "k8s.io/apimachinery/pkg/util/validation/field" + testscheme "k8s.io/code-generator/cmd/validation-gen/testscheme" +) + +func init() { localSchemeBuilder.Register(RegisterValidations) } + +// RegisterValidations adds validation functions to the given scheme. +// Public to allow building arbitrary schemes. +func RegisterValidations(scheme *testscheme.Scheme) error { + // type Struct + scheme.AddValidationFunc((*Struct)(nil), func(ctx context.Context, op operation.Operation, obj, oldObj interface{}) field.ErrorList { + switch op.Request.SubresourcePath() { + case "/": + return Validate_Struct(ctx, op, nil /* fldPath */, obj.(*Struct), safe.Cast[*Struct](oldObj)) + } + return field.ErrorList{field.InternalError(nil, fmt.Errorf("no validation found for %T, subresource: %v", obj, op.Request.SubresourcePath()))} + }) + return nil +} + +// Validate_LongNameStringType validates an instance of LongNameStringType according +// to declarative validation rules in the API schema. +func Validate_LongNameStringType(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *LongNameStringType) (errs field.ErrorList) { + errs = append(errs, validate.LongNameCaseless(ctx, op, fldPath, obj, oldObj)...) + + return errs +} + +// Validate_Struct validates an instance of Struct according +// to declarative validation rules in the API schema. +func Validate_Struct(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *Struct) (errs field.ErrorList) { + // field Struct.TypeMeta has no validation + + // field Struct.LongNameField + errs = append(errs, + func(fldPath *field.Path, obj, oldObj *string) (errs field.ErrorList) { + // don't revalidate unchanged data + if op.Type == operation.Update && (obj == oldObj || (obj != nil && oldObj != nil && *obj == *oldObj)) { + return nil + } + // call field-attached validations + errs = append(errs, validate.LongNameCaseless(ctx, op, fldPath, obj, oldObj)...) + return + }(fldPath.Child("longNameField"), &obj.LongNameField, safe.Field(oldObj, func(oldObj *Struct) *string { return &oldObj.LongNameField }))...) + + // field Struct.LongNamePtrField + errs = append(errs, + func(fldPath *field.Path, obj, oldObj *string) (errs field.ErrorList) { + // don't revalidate unchanged data + if op.Type == operation.Update && (obj == oldObj || (obj != nil && oldObj != nil && *obj == *oldObj)) { + return nil + } + // call field-attached validations + errs = append(errs, validate.LongNameCaseless(ctx, op, fldPath, obj, oldObj)...) + return + }(fldPath.Child("longNamePtrField"), obj.LongNamePtrField, safe.Field(oldObj, func(oldObj *Struct) *string { return oldObj.LongNamePtrField }))...) + + // field Struct.LongNameTypedefField + errs = append(errs, + func(fldPath *field.Path, obj, oldObj *LongNameStringType) (errs field.ErrorList) { + // don't revalidate unchanged data + if op.Type == operation.Update && (obj == oldObj || (obj != nil && oldObj != nil && *obj == *oldObj)) { + return nil + } + // call the type's validation function + errs = append(errs, Validate_LongNameStringType(ctx, op, fldPath, obj, oldObj)...) + return + }(fldPath.Child("longNameTypedefField"), &obj.LongNameTypedefField, safe.Field(oldObj, func(oldObj *Struct) *LongNameStringType { return &oldObj.LongNameTypedefField }))...) + + return errs +} diff --git a/staging/src/k8s.io/code-generator/cmd/validation-gen/validators/format.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/validators/format.go index 0b1a61de6a7..4ceaf5e04a5 100644 --- a/staging/src/k8s.io/code-generator/cmd/validation-gen/validators/format.go +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/validators/format.go @@ -49,12 +49,13 @@ func (formatTagValidator) ValidScopes() sets.Set[Scope] { var ( // Keep this list alphabetized. - ipSloppyValidator = types.Name{Package: libValidationPkg, Name: "IPSloppy"} - labelKeyValidator = types.Name{Package: libValidationPkg, Name: "LabelKey"} - labelValueValidator = types.Name{Package: libValidationPkg, Name: "LabelValue"} - longNameValidator = types.Name{Package: libValidationPkg, Name: "LongName"} - shortNameValidator = types.Name{Package: libValidationPkg, Name: "ShortName"} - uuidValidator = types.Name{Package: libValidationPkg, Name: "UUID"} + ipSloppyValidator = types.Name{Package: libValidationPkg, Name: "IPSloppy"} + labelKeyValidator = types.Name{Package: libValidationPkg, Name: "LabelKey"} + labelValueValidator = types.Name{Package: libValidationPkg, Name: "LabelValue"} + longNameCaselessValidator = types.Name{Package: libValidationPkg, Name: "LongNameCaseless"} + longNameValidator = types.Name{Package: libValidationPkg, Name: "LongName"} + shortNameValidator = types.Name{Package: libValidationPkg, Name: "ShortName"} + uuidValidator = types.Name{Package: libValidationPkg, Name: "UUID"} ) func (formatTagValidator) GetValidations(context Context, tag codetags.Tag) (Validations, error) { @@ -89,6 +90,8 @@ func getFormatValidationFunction(format string) (FunctionGen, error) { return Function(formatTagName, DefaultFlags, labelValueValidator), nil case "k8s-long-name": return Function(formatTagName, DefaultFlags, longNameValidator), nil + case "k8s-long-name-caseless": + return Function(formatTagName, DefaultFlags, longNameCaselessValidator), nil case "k8s-short-name": return Function(formatTagName, DefaultFlags, shortNameValidator), nil case "k8s-uuid": @@ -116,6 +119,9 @@ func (ftv formatTagValidator) Docs() TagDoc { }, { Description: "k8s-long-name", Docs: "This field holds a Kubernetes \"long name\", aka a \"DNS subdomain\" value.", + }, { + Description: "k8s-long-name-caseless", + Docs: "Deprecated: This field holds a case-insensitive Kubernetes \"long name\", aka a \"DNS subdomain\" value.", }, { Description: "k8s-short-name", Docs: "This field holds a Kubernetes \"short name\", aka a \"DNS label\" value.",