apiserver/pkg/registry/rest/validate.go

341 lines
14 KiB
Go

/*
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 rest
import (
"context"
"fmt"
"slices"
"strings"
"k8s.io/apimachinery/pkg/api/operation"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/validation/field"
genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
validationmetrics "k8s.io/apiserver/pkg/validation"
"k8s.io/klog/v2"
)
// ValidationConfig defines how a declarative validation request may be configured.
type ValidationConfig func(*validationConfigOption)
// WithOptions sets the validation options.
// Options should contain any validation options that the declarative validation
// tags expect. These often correspond to feature gates.
func WithOptions(options []string) ValidationConfig {
return func(config *validationConfigOption) {
config.options = options
}
}
// WithTakeover sets the takeover flag for validation.
func WithTakeover(takeover bool) ValidationConfig {
return func(config *validationConfigOption) {
config.takeover = takeover
}
}
// WithSubresourceMapper sets the subresource mapper for validation.
// This should be used when registering validation for polymorphic subresources like /scale.
//
// For example, the deployments/scale subresource mapper might map from:
//
// group: apps, version: v1, subresource=scale
//
// to a target of:
//
// group: autoscaling, version: v1, kind=Scale
//
// When set, the group version in the requestInfo of the ctx provided to a declarative validation
// request will be passed to the subresource mapper to find the group version kind of the subresource.
// Declarative validation will then convert the object to the subresource group version kind and validate it.
//
// Note that the target of the mapping contains no subresource part since the mapper is expected to
// map to the group version kind of the subresource.
func WithSubresourceMapper(subresourceMapper GroupVersionKindProvider) ValidationConfig {
return func(config *validationConfigOption) {
config.subresourceGVKMapper = subresourceMapper
}
}
type validationConfigOption struct {
opType operation.Type
options []string
takeover bool
subresourceGVKMapper GroupVersionKindProvider
}
// ValidateDeclaratively validates obj against declarative validation tags
// defined in its Go type. It uses the API version extracted from ctx and the
// provided scheme for validation.
//
// The ctx MUST contain requestInfo, which determines the target API for
// validation. The obj is converted to the API version using the provided scheme
// before validation occurs. The scheme MUST have the declarative validation
// registered for the requested resource/subresource.
//
// Returns a field.ErrorList containing any validation errors. An internal error
// is included if requestInfo is missing from the context or if version
// conversion fails.
func ValidateDeclaratively(ctx context.Context, scheme *runtime.Scheme, obj runtime.Object, configOpts ...ValidationConfig) field.ErrorList {
cfg := &validationConfigOption{opType: operation.Create}
for _, o := range configOpts {
o(cfg)
}
return panicSafeValidateFunc(validateDeclaratively, cfg.takeover)(ctx, scheme, obj, nil, cfg)
}
// ValidateUpdateDeclaratively validates obj and oldObj against declarative
// validation tags defined in its Go type. It uses the API version extracted from
// ctx and the provided scheme for validation.
//
// The ctx MUST contain requestInfo, which determines the target API for
// validation. The obj is converted to the API version using the provided scheme
// before validation occurs. The scheme MUST have the declarative validation
// registered for the requested resource/subresource.
//
// Returns a field.ErrorList containing any validation errors. An internal error
// is included if requestInfo is missing from the context or if version
// conversion fails.
func ValidateUpdateDeclaratively(ctx context.Context, scheme *runtime.Scheme, obj, oldObj runtime.Object, configOpts ...ValidationConfig) field.ErrorList {
cfg := &validationConfigOption{opType: operation.Update}
for _, o := range configOpts {
o(cfg)
}
return panicSafeValidateFunc(validateDeclaratively, cfg.takeover)(ctx, scheme, obj, oldObj, cfg)
}
func validateDeclaratively(ctx context.Context, scheme *runtime.Scheme, obj, oldObj runtime.Object, o *validationConfigOption) field.ErrorList {
// Find versionedGroupVersion, which identifies the API version to use for declarative validation.
versionedGroupVersion, subresources, err := requestInfo(ctx, o.subresourceGVKMapper)
if err != nil {
return field.ErrorList{field.InternalError(nil, err)}
}
versionedObj, err := scheme.ConvertToVersion(obj, versionedGroupVersion)
if err != nil {
return field.ErrorList{field.InternalError(nil, fmt.Errorf("unexpected error converting to versioned type: %w", err))}
}
var versionedOldObj runtime.Object
switch o.opType {
case operation.Create:
return scheme.Validate(ctx, o.options, versionedObj, subresources...)
case operation.Update:
versionedOldObj, err = scheme.ConvertToVersion(oldObj, versionedGroupVersion)
if err != nil {
return field.ErrorList{field.InternalError(nil, fmt.Errorf("unexpected error converting to versioned type: %w", err))}
}
return scheme.ValidateUpdate(ctx, o.options, versionedObj, versionedOldObj, subresources...)
default:
return field.ErrorList{field.InternalError(nil, fmt.Errorf("unknown operation type: %v", o.opType))}
}
}
func requestInfo(ctx context.Context, subresourceMapper GroupVersionKindProvider) (schema.GroupVersion, []string, error) {
requestInfo, found := genericapirequest.RequestInfoFrom(ctx)
if !found {
return schema.GroupVersion{}, nil, fmt.Errorf("could not find requestInfo in context")
}
groupVersion := schema.GroupVersion{Group: requestInfo.APIGroup, Version: requestInfo.APIVersion}
if subresourceMapper != nil {
groupVersion = subresourceMapper.GroupVersionKind(groupVersion).GroupVersion()
}
subresources, err := parseSubresourcePath(requestInfo.Subresource)
if err != nil {
return schema.GroupVersion{}, nil, fmt.Errorf("unexpected error parsing subresource path: %w", err)
}
return groupVersion, subresources, nil
}
func parseSubresourcePath(subresourcePath string) ([]string, error) {
if len(subresourcePath) == 0 {
return nil, nil
}
parts := strings.Split(subresourcePath, "/")
for _, part := range parts {
if len(part) == 0 {
return nil, fmt.Errorf("invalid subresource path: %s", subresourcePath)
}
}
return parts, nil
}
// CompareDeclarativeErrorsAndEmitMismatches checks for mismatches between imperative and declarative validation
// and logs + emits metrics when inconsistencies are found
func CompareDeclarativeErrorsAndEmitMismatches(ctx context.Context, imperativeErrs, declarativeErrs field.ErrorList, takeover bool) {
logger := klog.FromContext(ctx)
mismatchDetails := gatherDeclarativeValidationMismatches(imperativeErrs, declarativeErrs, takeover)
for _, detail := range mismatchDetails {
// Log information about the mismatch using contextual logger
logger.Error(nil, detail)
// Increment the metric for the mismatch
validationmetrics.Metrics.IncDeclarativeValidationMismatchMetric()
}
}
// gatherDeclarativeValidationMismatches compares imperative and declarative validation errors
// and returns detailed information about any mismatches found. Errors are compared via type, field, and origin
func gatherDeclarativeValidationMismatches(imperativeErrs, declarativeErrs field.ErrorList, takeover bool) []string {
var mismatchDetails []string
// short circuit here to minimize allocs for usual case of 0 validation errors
if len(imperativeErrs) == 0 && len(declarativeErrs) == 0 {
return mismatchDetails
}
// recommendation based on takeover status
recommendation := "This difference should not affect system operation since hand written validation is authoritative."
if takeover {
recommendation = "Consider disabling the DeclarativeValidationTakeover feature gate to keep data persisted in etcd consistent with prior versions of Kubernetes."
}
fuzzyMatcher := field.ErrorMatcher{}.ByType().ByField().ByOrigin().RequireOriginWhenInvalid()
exactMatcher := field.ErrorMatcher{}.Exactly()
// Dedupe imperative errors of exact error matches as they are
// not intended and come from (buggy) duplicate validation calls
// This is necessary as without deduping we could get unmatched
// imperative errors for cases that are correct (matching)
dedupedImperativeErrs := field.ErrorList{}
for _, err := range imperativeErrs {
found := false
for _, existingErr := range dedupedImperativeErrs {
if exactMatcher.Matches(existingErr, err) {
found = true
break
}
}
if !found {
dedupedImperativeErrs = append(dedupedImperativeErrs, err)
}
}
imperativeErrs = dedupedImperativeErrs
// Create a copy of declarative errors to track remaining ones
remaining := make(field.ErrorList, len(declarativeErrs))
copy(remaining, declarativeErrs)
// Match each "covered" imperative error to declarative errors.
// We use a fuzzy matching approach to find corresponding declarative errors
// for each imperative error marked as CoveredByDeclarative.
// As matches are found, they're removed from the 'remaining' list.
// They are removed from `remaining` with a "1:many" mapping: for a given
// imperative error we mark as matched all matching declarative errors
// This allows us to:
// 1. Detect imperative errors that should have matching declarative errors but don't
// 2. Identify extra declarative errors with no imperative counterpart
// Both cases indicate issues with the declarative validation implementation.
for _, iErr := range imperativeErrs {
if !iErr.CoveredByDeclarative {
continue
}
tmp := make(field.ErrorList, 0, len(remaining))
matchCount := 0
for _, dErr := range remaining {
if fuzzyMatcher.Matches(iErr, dErr) {
matchCount++
} else {
tmp = append(tmp, dErr)
}
}
if matchCount == 0 {
mismatchDetails = append(mismatchDetails,
fmt.Sprintf(
"Unexpected difference between hand written validation and declarative validation error results, unmatched error(s) found %s. "+
"This indicates an issue with declarative validation. %s",
fuzzyMatcher.Render(iErr),
recommendation,
),
)
}
remaining = tmp
}
// Any remaining unmatched declarative errors are considered "extra"
for _, dErr := range remaining {
mismatchDetails = append(mismatchDetails,
fmt.Sprintf(
"Unexpected difference between hand written validation and declarative validation error results, extra error(s) found %s. "+
"This indicates an issue with declarative validation. %s",
fuzzyMatcher.Render(dErr),
recommendation,
),
)
}
return mismatchDetails
}
// createDeclarativeValidationPanicHandler returns a function with panic recovery logic
// that will increment the panic metric and either log or append errors based on the takeover parameter.
func createDeclarativeValidationPanicHandler(ctx context.Context, errs *field.ErrorList, takeover bool) func() {
logger := klog.FromContext(ctx)
return func() {
if r := recover(); r != nil {
// Increment the panic metric counter
validationmetrics.Metrics.IncDeclarativeValidationPanicMetric()
const errorFmt = "panic during declarative validation: %v"
if takeover {
// If takeover is enabled, output as a validation error as authoritative validator panicked and validation should error
*errs = append(*errs, field.InternalError(nil, fmt.Errorf(errorFmt, r)))
} else {
// if takeover not enabled, log the panic as an error message
logger.Error(nil, fmt.Sprintf(errorFmt, r))
}
}
}
}
// panicSafeValidateFunc wraps an validation function with panic recovery logic.
// The returned function will execute the wrapped function and handle any panics by
// incrementing the panic metric, and logging an error message
// if takeover=false, and adding a validation error if takeover=true.
func panicSafeValidateFunc(
validateUpdateFunc func(ctx context.Context, scheme *runtime.Scheme, obj, oldObj runtime.Object, o *validationConfigOption) field.ErrorList,
takeover bool,
) func(ctx context.Context, scheme *runtime.Scheme, obj, oldObj runtime.Object, o *validationConfigOption) field.ErrorList {
return func(ctx context.Context, scheme *runtime.Scheme, obj, oldObj runtime.Object, o *validationConfigOption) (errs field.ErrorList) {
defer createDeclarativeValidationPanicHandler(ctx, &errs, takeover)()
return validateUpdateFunc(ctx, scheme, obj, oldObj, o)
}
}
// RecordDuplicateValidationErrors increments a metric and log the error when duplicate validation errors are found.
func RecordDuplicateValidationErrors(ctx context.Context, qualifiedKind schema.GroupKind, errs field.ErrorList) {
logger := klog.FromContext(ctx)
seenErrs := make([]string, 0, len(errs))
for _, err := range errs {
errStr := fmt.Sprintf("%v", err)
if slices.Contains(seenErrs, errStr) {
logger.Info("Found duplicate validation error", "kind", qualifiedKind.String(), "error", errStr)
validationmetrics.Metrics.IncDuplicateValidationErrorMetric()
} else {
seenErrs = append(seenErrs, errStr)
}
}
}