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 462c23d0d6e..332bb610c5e 100644 --- a/staging/src/k8s.io/apimachinery/pkg/api/validate/strfmt.go +++ b/staging/src/k8s.io/apimachinery/pkg/api/validate/strfmt.go @@ -24,6 +24,10 @@ import ( "k8s.io/apimachinery/pkg/util/validation/field" ) +const ( + uuidErrorMessage = "must be a lowercase UUID in 8-4-4-4-12 format" +) + // ShortName verifies that the specified value is a valid "short name" // (sometimes known as a "DNS label"). // - must not be empty @@ -65,3 +69,32 @@ func LongName[T ~string](_ context.Context, op operation.Operation, fldPath *fie } return allErrs } + +// UUID verifies that the specified value is a valid UUID (RFC 4122). +// - must be 36 characters long +// - must be in the normalized form `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` +// - must use only lowercase hexadecimal characters +func UUID[T ~string](_ context.Context, op operation.Operation, fldPath *field.Path, value, _ *T) field.ErrorList { + if value == nil { + return nil + } + val := (string)(*value) + if len(val) != 36 { + return field.ErrorList{field.Invalid(fldPath, val, uuidErrorMessage).WithOrigin("format=k8s-uuid")} + } + for idx := 0; idx < len(val); idx++ { + character := val[idx] + switch idx { + case 8, 13, 18, 23: + if character != '-' { + return field.ErrorList{field.Invalid(fldPath, val, uuidErrorMessage).WithOrigin("format=k8s-uuid")} + } + default: + // should be lower case hexadecimal. + if (character < '0' || character > '9') && (character < 'a' || character > 'f') { + return field.ErrorList{field.Invalid(fldPath, val, uuidErrorMessage).WithOrigin("format=k8s-uuid")} + } + } + } + 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 new file mode 100644 index 00000000000..fa18a863e16 --- /dev/null +++ b/staging/src/k8s.io/apimachinery/pkg/api/validate/strfmt_test.go @@ -0,0 +1,217 @@ +/* +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 validate + +import ( + "context" + "testing" + + "k8s.io/apimachinery/pkg/api/operation" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +func TestShortName(t *testing.T) { + ctx := context.Background() + fldPath := field.NewPath("test") + + testCases := []struct { + name string + input string + wantErrs field.ErrorList + }{{ + name: "valid", + input: "abc-123", + wantErrs: nil, + }, { + name: "invalid: empty", + input: "", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "", "a lowercase RFC 1123 label must consist of lower case alphanumeric characters or '-', and must start and end with an alphanumeric character").WithOrigin("format=k8s-short-name"), + }, + }, { + name: "invalid: too long", + input: "01234567890123456789012345678901234567890123456789012345678901234", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "01234567890123456789012345678901234567890123456789012345678901234", "must be no more than 63 bytes").WithOrigin("format=k8s-short-name"), + }, + }, { + name: "invalid: starts with dash", + input: "-abc-123", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "-abc-123", "a lowercase RFC 1123 label must consist of lower case alphanumeric characters or '-', and must start and end with an alphanumeric character").WithOrigin("format=k8s-short-name"), + }, + }, { + name: "invalid: ends with dash", + input: "abc-123-", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "abc-123-", "a lowercase RFC 1123 label must consist of lower case alphanumeric characters or '-', and must start and end with an alphanumeric character").WithOrigin("format=k8s-short-name"), + }, + }, { + name: "invalid: upper-case", + input: "ABC-123", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "ABC-123", "a lowercase RFC 1123 label must consist of lower case alphanumeric characters or '-', and must start and end with an alphanumeric character").WithOrigin("format=k8s-short-name"), + }, + }, { + name: "invalid: other chars", + input: "abc_123", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "abc_123", "a lowercase RFC 1123 label must consist of lower case alphanumeric characters or '-', and must start and end with an alphanumeric character").WithOrigin("format=k8s-short-name"), + }, + }} + + matcher := field.ErrorMatcher{}.ByType().ByField().ByOrigin().ByDetailSubstring() + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + value := tc.input + gotErrs := ShortName(ctx, operation.Operation{}, fldPath, &value, nil) + matcher.Test(t, tc.wantErrs, gotErrs) + }) + } +} + +func TestLongName(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, "", "a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character").WithOrigin("format=k8s-long-name"), + }, + }, { + name: "invalid: too long", + input: "0123456789012345678901234567890123456789012345678901234567890123.0123456789012345678901234567890123456789012345678901234567890123.0123456789012345678901234567890123456789012345678901234567890123.01234567890123456789012345678901234567890123456789012345678901234", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "0123456789012345678901234567890123456789012345678901234567890123.0123456789012345678901234567890123456789012345678901234567890123.0123456789012345678901234567890123456789012345678901234567890123.01234567890123456789012345678901234567890123456789012345678901234", "must be no more than 253 bytes").WithOrigin("format=k8s-long-name"), + }, + }, { + name: "invalid: starts with dash", + input: "-a.b.c", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "-a.b.c", "a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character").WithOrigin("format=k8s-long-name"), + }, + }, { + name: "invalid: ends with dash", + input: "a.b.c-", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "a.b.c-", "a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character").WithOrigin("format=k8s-long-name"), + }, + }, { + name: "invalid: upper-case", + input: "A.b.c", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "A.b.c", "a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character").WithOrigin("format=k8s-long-name"), + }, + }, { + name: "invalid: other chars", + input: "a_b.c", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "a_b.c", "a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character").WithOrigin("format=k8s-long-name"), + }, + }} + + matcher := field.ErrorMatcher{}.ByType().ByField().ByOrigin().ByDetailSubstring() + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + value := tc.input + gotErrs := LongName(ctx, operation.Operation{}, fldPath, &value, nil) + matcher.Test(t, tc.wantErrs, gotErrs) + }) + } +} + +func TestK8sUUID(t *testing.T) { + ctx := context.Background() + fldPath := field.NewPath("test") + + testCases := []struct { + name string + input string + wantErrs field.ErrorList + }{{ + name: "valid uuid with hyphens", + input: "123e4567-e89b-12d3-a456-426614174000", + wantErrs: nil, + }, { + name: "invalid uuid with hyphens uppercase", + input: "123E4567-E89B-12D3-A456-426614174000", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "123E4567-E89B-12D3-A456-426614174000", "must be a lowercase UUID in 8-4-4-4-12 format").WithOrigin("format=k8s-uuid"), + }, + }, { + name: "invalid uuid with urn prefix", + input: "urn:uuid:123e4567-e89b-12d3-a456-426614174000", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "urn:uuid:123e4567-e89b-12d3-a456-426614174000", "must be a lowercase UUID in 8-4-4-4-12 format").WithOrigin("format=k8s-uuid"), + }, + }, { + name: "invalid uuid without hyphens", + input: "123e4567e89b12d3a456426614174000", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "123e4567e89b12d3a456426614174000", "must be a lowercase UUID in 8-4-4-4-12 format").WithOrigin("format=k8s-uuid"), + }, + }, { + name: "invalid: wrong length", + input: "123e4567-e89b-12d3-a456-42661417400", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "123e4567-e89b-12d3-a456-42661417400", "must be a lowercase UUID in 8-4-4-4-12 format").WithOrigin("format=k8s-uuid"), + }, + }, { + name: "invalid: wrong characters", + input: "123e4567-e89b-12d3-a456-42661417400g", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "123e4567-e89b-12d3-a456-42661417400g", "must be a lowercase UUID in 8-4-4-4-12 format").WithOrigin("format=k8s-uuid"), + }, + }, { + name: "invalid: misplaced hyphens", + input: "123e4567-e89b-12d3-a4564-26614174000", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "123e4567-e89b-12d3-a4564-26614174000", "must be a lowercase UUID in 8-4-4-4-12 format").WithOrigin("format=k8s-uuid"), + }, + }, { + name: "empty string", + input: "", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "", "must be a lowercase UUID in 8-4-4-4-12 format").WithOrigin("format=k8s-uuid"), + }, + }, { + name: "not a uuid", + input: "not-a-uuid", + wantErrs: field.ErrorList{ + field.Invalid(fldPath, "not-a-uuid", "must be a lowercase UUID in 8-4-4-4-12 format").WithOrigin("format=k8s-uuid"), + }, + }} + + matcher := field.ErrorMatcher{}.ByType().ByField().ByOrigin().ByDetailExact() + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + value := tc.input + gotErrs := UUID(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-uuid/doc.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-uuid/doc.go new file mode 100644 index 00000000000..4adfcfc617b --- /dev/null +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-uuid/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 k8suuid + +import "k8s.io/code-generator/cmd/validation-gen/testscheme" + +var localSchemeBuilder = testscheme.New() + +// MyType is a struct that contains a field with the uuid format. +// +k8s:validation:Required +type MyType struct { + TypeMeta int + // +k8s:Optional + // +k8s:format=k8s-uuid + UUIDField string `json:"uuidField"` + // +k8s:Optional + // +k8s:format=k8s-uuid + UUIDPtrField *string `json:"uuidPtrField"` + // Note: no validation here + UUIDTypedefField UUIDStringType `json:"uuidTypedefField"` +} + +// +k8s:format=k8s-uuid +type UUIDStringType string diff --git a/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-uuid/doc_test.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-uuid/doc_test.go new file mode 100644 index 00000000000..095439575c7 --- /dev/null +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-uuid/doc_test.go @@ -0,0 +1,60 @@ +/* +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 k8suuid + +import ( + "testing" + + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/ptr" +) + +func TestK8sUUID(t *testing.T) { + st := localSchemeBuilder.Test(t) + + st.Value(&MyType{ + UUIDField: "123e4567-e89b-12d3-a456-426614174000", + UUIDPtrField: ptr.To("123e4567-e89b-12d3-a456-426614174000"), + UUIDTypedefField: "123e4567-e89b-12d3-a456-426614174000", + }).ExpectValid() + + invalidStruct := &MyType{ + UUIDField: "123E4567-E89B-12D3-A456-426614174000", + UUIDPtrField: ptr.To("123E4567-E89B-12D3-A456-426614174000"), + UUIDTypedefField: "123E4567-E89B-12D3-A456-426614174000", + } + st.Value(invalidStruct).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("uuidField"), nil, "").WithOrigin("format=k8s-uuid"), + field.Invalid(field.NewPath("uuidPtrField"), nil, "").WithOrigin("format=k8s-uuid"), + field.Invalid(field.NewPath("uuidTypedefField"), nil, "").WithOrigin("format=k8s-uuid"), + }) + // Test validation ratcheting + st.Value(invalidStruct).OldValue(invalidStruct).ExpectValid() + + invalidStruct = &MyType{ + UUIDField: "not-a-uuid", + UUIDPtrField: ptr.To("not-a-uuid"), + UUIDTypedefField: "not-a-uuid", + } + st.Value(invalidStruct).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("uuidField"), nil, "").WithOrigin("format=k8s-uuid"), + field.Invalid(field.NewPath("uuidPtrField"), nil, "").WithOrigin("format=k8s-uuid"), + field.Invalid(field.NewPath("uuidTypedefField"), nil, "").WithOrigin("format=k8s-uuid"), + }) + // 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-uuid/zz_generated.validations.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-uuid/zz_generated.validations.go new file mode 100644 index 00000000000..b1de2e4cd0e --- /dev/null +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/format/k8s-uuid/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 k8suuid + +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.UUIDField + 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.UUID(ctx, op, fldPath, obj, oldObj)...) + return + }(fldPath.Child("uuidField"), &obj.UUIDField, safe.Field(oldObj, func(oldObj *MyType) *string { return &oldObj.UUIDField }))...) + + // field MyType.UUIDPtrField + 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.UUID(ctx, op, fldPath, obj, oldObj)...) + return + }(fldPath.Child("uuidPtrField"), obj.UUIDPtrField, safe.Field(oldObj, func(oldObj *MyType) *string { return oldObj.UUIDPtrField }))...) + + // field MyType.UUIDTypedefField + errs = append(errs, + func(fldPath *field.Path, obj, oldObj *UUIDStringType) (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_UUIDStringType(ctx, op, fldPath, obj, oldObj)...) + return + }(fldPath.Child("uuidTypedefField"), &obj.UUIDTypedefField, safe.Field(oldObj, func(oldObj *MyType) *UUIDStringType { return &oldObj.UUIDTypedefField }))...) + + return errs +} + +// Validate_UUIDStringType validates an instance of UUIDStringType according +// to declarative validation rules in the API schema. +func Validate_UUIDStringType(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *UUIDStringType) (errs field.ErrorList) { + errs = append(errs, validate.UUID(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 6787245ed44..4150ee6f05f 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 @@ -51,6 +51,7 @@ var ( // Keep this list alphabetized. 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) { @@ -81,6 +82,8 @@ func getFormatValidationFunction(format string) (FunctionGen, error) { return Function(formatTagName, DefaultFlags, longNameValidator), nil case "k8s-short-name": return Function(formatTagName, DefaultFlags, shortNameValidator), nil + case "k8s-uuid": + return Function(formatTagName, DefaultFlags, uuidValidator), nil } // TODO: Flesh out the list of validation functions @@ -98,6 +101,9 @@ func (ftv formatTagValidator) Docs() TagDoc { }, { Description: "k8s-short-name", Docs: "This field holds a Kubernetes \"short name\", aka a \"DNS label\" value.", + }, { + Description: "k8s-uuid", + Docs: "This field holds a Kubernetes UUID, which conforms to RFC 4122.", }}, PayloadsType: codetags.ValueTypeString, PayloadsRequired: true,