Merge pull request #133948 from lalitc375/cherry-pick-pr-170

Add support for UUID format.
This commit is contained in:
Kubernetes Prow Robot
2025-09-10 15:56:11 -07:00
committed by GitHub
6 changed files with 459 additions and 0 deletions

View File

@@ -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
}

View File

@@ -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)
})
}
}

View File

@@ -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

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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,