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 504afecc033..feaf9c988e6 100644 --- a/staging/src/k8s.io/apimachinery/pkg/api/validate/strfmt.go +++ b/staging/src/k8s.io/apimachinery/pkg/api/validate/strfmt.go @@ -27,7 +27,10 @@ import ( ) const ( - uuidErrorMessage = "must be a lowercase UUID in 8-4-4-4-12 format" + uuidErrorMessage = "must be a lowercase UUID in 8-4-4-4-12 format" + defaultResourceRequestsPrefix = "requests." + // Default namespace prefix. + resourceDefaultNamespacePrefix = "kubernetes.io/" ) // ShortName verifies that the specified value is a valid "short name" @@ -180,3 +183,38 @@ func ResourcePoolName[T ~string](ctx context.Context, op operation.Operation, fl } return allErrs.WithOrigin("format=k8s-resource-pool-name") } + +// ExtendedResourceName verifies that the specified value is a valid extended resource name. +// An extended resource name is a domain-prefixed name that does not use the "kubernetes.io" +// or "requests." prefixes. It must be a valid label key when prefixed with "requests.". +// +// - must have slash domain and name. +// - must not have the "kubernetes.io" domain +// - must not have the "requests." prefix +// - name must be 63 characters or less +// - must be a valid label key when prefixed with "requests." +// -- must contain only alphanumeric characters, dashes, underscores, or dots +// -- must end with an alphanumeric character +func ExtendedResourceName[T ~string](_ context.Context, op operation.Operation, fldPath *field.Path, value, _ *T) field.ErrorList { + if value == nil { + return nil + } + val := (string)(*value) + allErrs := field.ErrorList{} + if !strings.Contains(val, "/") { + allErrs = append(allErrs, field.Invalid(fldPath, val, "a qualified name must be a domain-prefixed path, such as 'example.com/my-prop'")) + } else if strings.Contains(val, resourceDefaultNamespacePrefix) { + allErrs = append(allErrs, field.Invalid(fldPath, val, fmt.Sprintf("must not have %q domain", resourceDefaultNamespacePrefix))) + } + + if strings.HasPrefix(val, defaultResourceRequestsPrefix) { + allErrs = append(allErrs, field.Invalid(fldPath, val, fmt.Sprintf("must not have %q prefix", defaultResourceRequestsPrefix))) + } + + // Ensure it satisfies the rules in IsLabelKey() after converted into quota resource name + nameForQuota := fmt.Sprintf("%s%s", defaultResourceRequestsPrefix, val) + for _, msg := range content.IsLabelKey(nameForQuota) { + allErrs = append(allErrs, field.Invalid(fldPath, val, msg)) + } + return allErrs.WithOrigin("format=k8s-extended-resource-name") +} 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 fd89bf08b5e..c54cd5bdd42 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 @@ -572,3 +572,58 @@ func TestResourcePoolName(t *testing.T) { }) } } + +func TestExtendedResourceName(t *testing.T) { + ctx := context.Background() + fldPath := field.NewPath("test") + + testCases := []struct { + name string + input string + wantErrs field.ErrorList + }{ + { + name: "valid", + input: "example-kub.io/foo", + wantErrs: nil, + }, + { + name: "invalid: no domain", + input: "foo", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "foo", "a qualified name must be a domain-prefixed path, such as 'example.com/my-prop'").WithOrigin("format=k8s-extended-resource-name"), + }, + }, + { + name: "invalid: kubernetes.io domain", + input: "kubernetes.io/foo", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "kubernetes.io/foo", "must not have 'kubernetes.io' domain").WithOrigin("format=k8s-extended-resource-name"), + }, + }, + { + name: "invalid: requests prefix", + input: "requests.example.com/foo", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "requests.example.com/foo", "must not have 'requests.' prefix").WithOrigin("format=k8s-extended-resource-name"), + }, + }, + { + name: "invalid: name too long", + input: "example.com/" + strings.Repeat("a", 64), + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "example.com/"+strings.Repeat("a", 64), "name part must be no more than 63 bytes").WithOrigin("format=k8s-extended-resource-name"), + }, + }, + } + + exactMatcher := field.ErrorMatcher{}.ByType().ByField().ByOrigin().ByDetailSubstring() + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + value := &tc.input + gotErrs := ExtendedResourceName(ctx, operation.Operation{}, fldPath, value, nil) + exactMatcher.Test(t, tc.wantErrs, gotErrs) + }) + } +} diff --git a/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-extended-resource-name/doc.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-extended-resource-name/doc.go new file mode 100644 index 00000000000..4bada027b88 --- /dev/null +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-extended-resource-name/doc.go @@ -0,0 +1,42 @@ +/* +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 k8sextendedresourcename + +import "k8s.io/code-generator/cmd/validation-gen/testscheme" + +var localSchemeBuilder = testscheme.New() + +// MyType is a struct that contains a field with the extended-resource-name format. +// +k8s:validation:Required +type MyType struct { + TypeMeta int + // +k8s:Optional + // +k8s:format=k8s-extended-resource-name + NameField string `json:"nameField"` + // +k8s:Optional + // +k8s:format=k8s-extended-resource-name + NamePtrField *string `json:"namePtrField"` + // Note: no validation here + NameTypedefField NameStringType `json:"nameTypedefField"` +} + +// +k8s:format=k8s-extended-resource-name +type NameStringType string diff --git a/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-extended-resource-name/doc_test.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-extended-resource-name/doc_test.go new file mode 100644 index 00000000000..5fc2b07b3d5 --- /dev/null +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-extended-resource-name/doc_test.go @@ -0,0 +1,68 @@ +/* +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 k8sextendedresourcename + +import ( + "testing" + + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/ptr" +) + +func TestK8sExtendedResourceName(t *testing.T) { + st := localSchemeBuilder.Test(t) + + st.Value(&MyType{ + NameField: "example.com/my-resource", + NamePtrField: ptr.To("my-domain.org/foo"), + NameTypedefField: "example.com/another-resource", + }).ExpectValid() + + st.Value(&MyType{ + NameField: "example.com/my_resource", + NamePtrField: ptr.To("example.com/My-Resource"), + NameTypedefField: "example.com/my.resource", + }).ExpectValid() + + invalidStruct := &MyType{ + NameField: "kubernetes.io/my-resource", + NamePtrField: ptr.To("requests.example.com/my-resource"), + NameTypedefField: "my-resource", + } + st.Value(invalidStruct).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("nameField"), invalidStruct.NameField, "a qualified name must not be a reserved name").WithOrigin("format=k8s-extended-resource-name"), + field.Invalid(field.NewPath("namePtrField"), *invalidStruct.NamePtrField, "a qualified name must not have a reserved prefix").WithOrigin("format=k8s-extended-resource-name"), + field.Invalid(field.NewPath("nameTypedefField"), invalidStruct.NameTypedefField, "a qualified name must be a valid domain prefix and a name separated by a slash").WithOrigin("format=k8s-extended-resource-name"), + }) + // Test validation ratcheting + st.Value(invalidStruct).OldValue(invalidStruct).ExpectValid() + + const commonDetail = "a valid extended resource name must consist of a domain name prefix and a path segment separated by a slash, where the path segment consists of alphanumeric characters, '-', and must start and end with an alphanumeric character, and the domain name prefix is a valid DNS subdomain name, with the exception that 'requests' is a valid domain name prefix" + invalidStruct = &MyType{ + NameField: "example.com/my-resource-", + NamePtrField: ptr.To("example.com/-my-resource"), + NameTypedefField: "example.com/", + } + st.Value(invalidStruct).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("nameField"), invalidStruct.NameField, commonDetail).WithOrigin("format=k8s-extended-resource-name"), + field.Invalid(field.NewPath("namePtrField"), *invalidStruct.NamePtrField, commonDetail).WithOrigin("format=k8s-extended-resource-name"), + field.Invalid(field.NewPath("nameTypedefField"), invalidStruct.NameTypedefField, commonDetail).WithOrigin("format=k8s-extended-resource-name"), + }) + + // 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-extended-resource-name/zz_generated.validations.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-extended-resource-name/zz_generated.validations.go new file mode 100644 index 00000000000..5dcf63191a3 --- /dev/null +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-extended-resource-name/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 k8sextendedresourcename + +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 MyType + scheme.AddValidationFunc((*MyType)(nil), func(ctx context.Context, op operation.Operation, obj, oldObj interface{}) field.ErrorList { + switch op.Request.SubresourcePath() { + case "/": + return Validate_MyType(ctx, op, nil /* fldPath */, obj.(*MyType), safe.Cast[*MyType](oldObj)) + } + return field.ErrorList{field.InternalError(nil, fmt.Errorf("no validation found for %T, subresource: %v", obj, op.Request.SubresourcePath()))} + }) + return nil +} + +// Validate_MyType validates an instance of MyType according +// to declarative validation rules in the API schema. +func Validate_MyType(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *MyType) (errs field.ErrorList) { + // field MyType.TypeMeta has no validation + + // field MyType.NameField + 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.ExtendedResourceName(ctx, op, fldPath, obj, oldObj)...) + return + }(fldPath.Child("nameField"), &obj.NameField, safe.Field(oldObj, func(oldObj *MyType) *string { return &oldObj.NameField }))...) + + // field MyType.NamePtrField + 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.ExtendedResourceName(ctx, op, fldPath, obj, oldObj)...) + return + }(fldPath.Child("namePtrField"), obj.NamePtrField, safe.Field(oldObj, func(oldObj *MyType) *string { return oldObj.NamePtrField }))...) + + // field MyType.NameTypedefField + errs = append(errs, + func(fldPath *field.Path, obj, oldObj *NameStringType) (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_NameStringType(ctx, op, fldPath, obj, oldObj)...) + return + }(fldPath.Child("nameTypedefField"), &obj.NameTypedefField, safe.Field(oldObj, func(oldObj *MyType) *NameStringType { return &oldObj.NameTypedefField }))...) + + return errs +} + +// Validate_NameStringType validates an instance of NameStringType according +// to declarative validation rules in the API schema. +func Validate_NameStringType(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *NameStringType) (errs field.ErrorList) { + errs = append(errs, validate.ExtendedResourceName(ctx, op, fldPath, obj, oldObj)...) + + 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 b147ce7635f..e920b936da7 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 @@ -5,7 +5,7 @@ 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 + http://www.apache.org/licenses/LICENSE-20.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -52,6 +52,7 @@ var ( // TODO: uncomment the following when we've done the homework // to be sure it works the current state of IP manual-ratcheting // ipSloppyValidator = types.Name{Package: libValidationPkg, Name: "IPSloppy"} + extendedResourceNameValidator = types.Name{Package: libValidationPkg, Name: "ExtendedResourceName"} labelKeyValidator = types.Name{Package: libValidationPkg, Name: "LabelKey"} labelValueValidator = types.Name{Package: libValidationPkg, Name: "LabelValue"} longNameCaselessValidator = types.Name{Package: libValidationPkg, Name: "LongNameCaseless"} @@ -85,6 +86,8 @@ func getFormatValidationFunction(format string) (FunctionGen, error) { switch format { // Keep this sequence alphabetized. + case "k8s-extended-resource-name": + return Function(formatTagName, DefaultFlags, extendedResourceNameValidator), nil // TODO: uncomment the following when we've done the homework // to be sure it works the current state of IP manual-ratcheting /* @@ -118,6 +121,9 @@ func (ftv formatTagValidator) Docs() TagDoc { Scopes: ftv.ValidScopes().UnsortedList(), Description: "Indicates that a string field has a particular format.", Payloads: []TagPayloadDoc{{ // Keep this list alphabetized. + Description: "k8s-extended-resource-name", + Docs: "This field holds a Kubernetes extended resource name, which is a domain-prefixed name that does not use the 'kubernetes.io' or 'requests.' prefixes.", + }, { Description: "k8s-ip", Docs: "This field holds an IPv4 or IPv6 address value. IPv4 octets may have leading zeros.", }, {