@@ -26,6 +26,8 @@ import (
26
26
"k8s.io/apimachinery/pkg/util/sets"
27
27
"k8s.io/apimachinery/pkg/util/validation/field"
28
28
genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
29
+ validationmetrics "k8s.io/apiserver/pkg/validation"
30
+ "k8s.io/klog/v2"
29
31
)
30
32
31
33
// ValidateDeclaratively validates obj against declarative validation tags
@@ -106,3 +108,212 @@ func parseSubresourcePath(subresourcePath string) ([]string, error) {
106
108
parts := strings .Split (subresourcePath [1 :], "/" )
107
109
return parts , nil
108
110
}
111
+
112
+ // CompareDeclarativeErrorsAndEmitMismatches checks for mismatches between imperative and declarative validation
113
+ // and logs + emits metrics when inconsistencies are found
114
+ func CompareDeclarativeErrorsAndEmitMismatches (ctx context.Context , imperativeErrs , declarativeErrs field.ErrorList , takeover bool ) {
115
+ logger := klog .FromContext (ctx )
116
+ mismatchDetails := gatherDeclarativeValidationMismatches (imperativeErrs , declarativeErrs , takeover )
117
+ for _ , detail := range mismatchDetails {
118
+ // Log information about the mismatch using contextual logger
119
+ logger .Info (detail )
120
+
121
+ // Increment the metric for the mismatch
122
+ validationmetrics .Metrics .IncDeclarativeValidationMismatchMetric ()
123
+ }
124
+ }
125
+
126
+ // gatherDeclarativeValidationMismatches compares imperative and declarative validation errors
127
+ // and returns detailed information about any mismatches found. Errors are compared via type, field, and origin
128
+ func gatherDeclarativeValidationMismatches (imperativeErrs , declarativeErrs field.ErrorList , takeover bool ) []string {
129
+ var mismatchDetails []string
130
+ // short circuit here to minimize allocs for usual case of 0 validation errors
131
+ if len (imperativeErrs ) == 0 && len (declarativeErrs ) == 0 {
132
+ return mismatchDetails
133
+ }
134
+ // recommendation based on takeover status
135
+ recommendation := "This difference should not affect system operation since hand written validation is authoritative."
136
+ if takeover {
137
+ recommendation = "Consider disabling the DeclarativeValidationTakeover feature gate to keep data persisted in etcd consistent with prior versions of Kubernetes."
138
+ }
139
+ fuzzyMatcher := field.ErrorMatcher {}.ByType ().ByField ().ByOrigin ().RequireOriginWhenInvalid ()
140
+ exactMatcher := field.ErrorMatcher {}.Exactly ()
141
+
142
+ // Dedupe imperative errors of exact error matches as they are
143
+ // not intended and come from (buggy) duplicate validation calls
144
+ // This is necessary as without deduping we could get unmatched
145
+ // imperative errors for cases that are correct (matching)
146
+ dedupedImperativeErrs := field.ErrorList {}
147
+ for _ , err := range imperativeErrs {
148
+ found := false
149
+ for _ , existingErr := range dedupedImperativeErrs {
150
+ if exactMatcher .Matches (existingErr , err ) {
151
+ found = true
152
+ break
153
+ }
154
+ }
155
+ if ! found {
156
+ dedupedImperativeErrs = append (dedupedImperativeErrs , err )
157
+ }
158
+ }
159
+ imperativeErrs = dedupedImperativeErrs
160
+
161
+ // Create a copy of declarative errors to track remaining ones
162
+ remaining := make (field.ErrorList , len (declarativeErrs ))
163
+ copy (remaining , declarativeErrs )
164
+
165
+ // Match each "covered" imperative error to declarative errors.
166
+ // We use a fuzzy matching approach to find corresponding declarative errors
167
+ // for each imperative error marked as CoveredByDeclarative.
168
+ // As matches are found, they're removed from the 'remaining' list.
169
+ // They are removed from `remaining` with a "1:many" mapping: for a given
170
+ // imperative error we mark as matched all matching declarative errors
171
+ // This allows us to:
172
+ // 1. Detect imperative errors that should have matching declarative errors but don't
173
+ // 2. Identify extra declarative errors with no imperative counterpart
174
+ // Both cases indicate issues with the declarative validation implementation.
175
+ for _ , iErr := range imperativeErrs {
176
+ if ! iErr .CoveredByDeclarative {
177
+ continue
178
+ }
179
+
180
+ tmp := make (field.ErrorList , 0 , len (remaining ))
181
+ matchCount := 0
182
+
183
+ for _ , dErr := range remaining {
184
+ if fuzzyMatcher .Matches (iErr , dErr ) {
185
+ matchCount ++
186
+ } else {
187
+ tmp = append (tmp , dErr )
188
+ }
189
+ }
190
+
191
+ if matchCount == 0 {
192
+ mismatchDetails = append (mismatchDetails ,
193
+ fmt .Sprintf (
194
+ "Unexpected difference between hand written validation and declarative validation error results, unmatched error(s) found %s. " +
195
+ "This indicates an issue with declarative validation. %s" ,
196
+ fuzzyMatcher .Render (iErr ),
197
+ recommendation ,
198
+ ),
199
+ )
200
+ }
201
+
202
+ remaining = tmp
203
+ }
204
+
205
+ // Any remaining unmatched declarative errors are considered "extra"
206
+ for _ , dErr := range remaining {
207
+ mismatchDetails = append (mismatchDetails ,
208
+ fmt .Sprintf (
209
+ "Unexpected difference between hand written validation and declarative validation error results, extra error(s) found %s. " +
210
+ "This indicates an issue with declarative validation. %s" ,
211
+ fuzzyMatcher .Render (dErr ),
212
+ recommendation ,
213
+ ),
214
+ )
215
+ }
216
+
217
+ return mismatchDetails
218
+ }
219
+
220
+ // createDeclarativeValidationPanicHandler returns a function with panic recovery logic
221
+ // that will increment the panic metric and either log or append errors based on the takeover parameter.
222
+ func createDeclarativeValidationPanicHandler (ctx context.Context , errs * field.ErrorList , takeover bool ) func () {
223
+ logger := klog .FromContext (ctx )
224
+ return func () {
225
+ if r := recover (); r != nil {
226
+ // Increment the panic metric counter
227
+ validationmetrics .Metrics .IncDeclarativeValidationPanicMetric ()
228
+
229
+ const errorFmt = "panic during declarative validation: %v"
230
+ if takeover {
231
+ // If takeover is enabled, output as a validation error as authoritative validator panicked and validation should error
232
+ * errs = append (* errs , field .InternalError (nil , fmt .Errorf (errorFmt , r )))
233
+ } else {
234
+ // if takeover not enabled, log the panic as an info message
235
+ logger .Info (fmt .Sprintf (errorFmt , r ))
236
+ }
237
+ }
238
+ }
239
+ }
240
+
241
+ // withRecover wraps a validation function with panic recovery logic.
242
+ // It takes a validation function with the ValidateDeclaratively signature
243
+ // and returns a function with the same signature.
244
+ // The returned function will execute the wrapped function and handle any panics by
245
+ // incrementing the panic metric, and logging an error message
246
+ // if takeover=false, and adding a validation error if takeover=true.
247
+ func withRecover (
248
+ validateFunc func (ctx context.Context , options sets.Set [string ], scheme * runtime.Scheme , obj runtime.Object ) field.ErrorList ,
249
+ takeover bool ,
250
+ ) func (ctx context.Context , options sets.Set [string ], scheme * runtime.Scheme , obj runtime.Object ) field.ErrorList {
251
+ return func (ctx context.Context , options sets.Set [string ], scheme * runtime.Scheme , obj runtime.Object ) (errs field.ErrorList ) {
252
+ defer createDeclarativeValidationPanicHandler (ctx , & errs , takeover )()
253
+
254
+ return validateFunc (ctx , options , scheme , obj )
255
+ }
256
+ }
257
+
258
+ // withRecoverUpdate wraps an update validation function with panic recovery logic.
259
+ // It takes a validation function with the ValidateUpdateDeclaratively signature
260
+ // and returns a function with the same signature.
261
+ // The returned function will execute the wrapped function and handle any panics by
262
+ // incrementing the panic metric, and logging an error message
263
+ // if takeover=false, and adding a validation error if takeover=true.
264
+ func withRecoverUpdate (
265
+ validateUpdateFunc func (ctx context.Context , options sets.Set [string ], scheme * runtime.Scheme , obj , oldObj runtime.Object ) field.ErrorList ,
266
+ takeover bool ,
267
+ ) func (ctx context.Context , options sets.Set [string ], scheme * runtime.Scheme , obj , oldObj runtime.Object ) field.ErrorList {
268
+ return func (ctx context.Context , options sets.Set [string ], scheme * runtime.Scheme , obj , oldObj runtime.Object ) (errs field.ErrorList ) {
269
+ defer createDeclarativeValidationPanicHandler (ctx , & errs , takeover )()
270
+
271
+ return validateUpdateFunc (ctx , options , scheme , obj , oldObj )
272
+ }
273
+ }
274
+
275
+ // ValidateDeclarativelyWithRecovery validates obj against declarative validation tags
276
+ // with panic recovery logic. It uses the API version extracted from ctx and the
277
+ // provided scheme for validation.
278
+ //
279
+ // The ctx MUST contain requestInfo, which determines the target API for
280
+ // validation. The obj is converted to the API version using the provided scheme
281
+ // before validation occurs. The scheme MUST have the declarative validation
282
+ // registered for the requested resource/subresource.
283
+ //
284
+ // option should contain any validation options that the declarative validation
285
+ // tags expect.
286
+ //
287
+ // takeover determines if panic recovery should return validation errors (true) or
288
+ // just log warnings (false).
289
+ //
290
+ // Returns a field.ErrorList containing any validation errors. An internal error
291
+ // is included if requestInfo is missing from the context, if version
292
+ // conversion fails, or if a panic occurs during validation when
293
+ // takeover is true.
294
+ func ValidateDeclarativelyWithRecovery (ctx context.Context , options sets.Set [string ], scheme * runtime.Scheme , obj runtime.Object , takeover bool ) field.ErrorList {
295
+ return withRecover (ValidateDeclaratively , takeover )(ctx , options , scheme , obj )
296
+ }
297
+
298
+ // ValidateUpdateDeclarativelyWithRecovery validates obj and oldObj against declarative
299
+ // validation tags with panic recovery logic. It uses the API version extracted from
300
+ // ctx and the provided scheme for validation.
301
+ //
302
+ // The ctx MUST contain requestInfo, which determines the target API for
303
+ // validation. The obj is converted to the API version using the provided scheme
304
+ // before validation occurs. The scheme MUST have the declarative validation
305
+ // registered for the requested resource/subresource.
306
+ //
307
+ // option should contain any validation options that the declarative validation
308
+ // tags expect.
309
+ //
310
+ // takeover determines if panic recovery should return validation errors (true) or
311
+ // just log warnings (false).
312
+ //
313
+ // Returns a field.ErrorList containing any validation errors. An internal error
314
+ // is included if requestInfo is missing from the context, if version
315
+ // conversion fails, or if a panic occurs during validation when
316
+ // takeover is true.
317
+ func ValidateUpdateDeclarativelyWithRecovery (ctx context.Context , options sets.Set [string ], scheme * runtime.Scheme , obj , oldObj runtime.Object , takeover bool ) field.ErrorList {
318
+ return withRecoverUpdate (ValidateUpdateDeclaratively , takeover )(ctx , options , scheme , obj , oldObj )
319
+ }
0 commit comments