Skip to content

Commit 5720563

Browse files
dd
1 parent 5dfd324 commit 5720563

File tree

2 files changed

+113
-87
lines changed

2 files changed

+113
-87
lines changed

internal/operator-controller/rukpak/bundle/config.go

Lines changed: 112 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,9 @@ func validateConfigWithSchema(configBytes []byte, schema map[string]any, install
164164
Name: formatOwnNamespaceInstallMode,
165165
Validate: func(value interface{}) error {
166166
// Check it equals install namespace (if installNamespace is set)
167-
// Empty installNamespace is valid - it means AllNamespaces mode by default,
168-
// so we skip the constraint check and accept any value
167+
// If installNamespace is empty, we can't validate the constraint properly,
168+
// so we skip validation and accept any value. This is a fallback for edge
169+
// cases where the install namespace isn't known yet.
169170
if installNamespace == "" {
170171
return nil
171172
}
@@ -183,8 +184,9 @@ func validateConfigWithSchema(configBytes []byte, schema map[string]any, install
183184
Name: formatSingleNamespaceInstallMode,
184185
Validate: func(value interface{}) error {
185186
// Check it does NOT equal install namespace (if installNamespace is set)
186-
// Empty installNamespace is valid - it means AllNamespaces mode by default,
187-
// so we skip the constraint check and accept any value
187+
// If installNamespace is empty, we can't validate the constraint properly,
188+
// so we skip validation and accept any value. This is a fallback for edge
189+
// cases where the install namespace isn't known yet.
188190
if installNamespace == "" {
189191
return nil
190192
}
@@ -217,66 +219,131 @@ func validateConfigWithSchema(configBytes []byte, schema map[string]any, install
217219

218220
// formatSchemaError converts technical JSON schema errors into messages users can understand.
219221
//
220-
// The JSON schema library gives us error objects, but they don't have all the details
221-
// we need to make helpful messages (like which field name or what value was wrong).
222-
// So we parse the error strings to extract that information.
223-
//
224-
// The Test_ErrorFormatting_* tests make sure this parsing works. If we upgrade the
225-
// JSON schema library and those tests start failing, we'll know we need to update
226-
// this parsing logic.
222+
// Uses the structured ValidationError API instead of fragile string parsing for maintainability.
227223
func formatSchemaError(err error) error {
228224
schemaErr := &jsonschema.ValidationError{}
229225
ok := errors.As(err, &schemaErr)
230226
if !ok {
231227
return err
232228
}
233229

234-
msg := schemaErr.Error()
230+
// The actual validation failures are in the Causes
231+
if len(schemaErr.Causes) == 0 {
232+
// Fallback if no causes (shouldn't happen, but be safe)
233+
return fmt.Errorf("configuration validation failed: %s", schemaErr.Error())
234+
}
235235

236-
// Unknown field (typo or not applicable to this bundle's install modes)
237-
if strings.Contains(msg, "additional properties") && strings.Contains(msg, "not allowed") {
238-
if idx := strings.Index(msg, "additional properties '"); idx != -1 {
239-
remaining := msg[idx+len("additional properties '"):]
240-
if endIdx := strings.Index(remaining, "'"); endIdx != -1 {
241-
fieldName := remaining[:endIdx]
242-
return fmt.Errorf("unknown field %q", fieldName)
243-
}
236+
// When there are multiple causes, prioritize based on which gives better user feedback
237+
// additionalProperties > required > type
238+
cause := schemaErr.Causes[0]
239+
for _, c := range schemaErr.Causes {
240+
keywords := c.ErrorKind.KeywordPath()
241+
if len(keywords) > 0 && keywords[len(keywords)-1] == "additionalProperties" {
242+
cause = c
243+
break
244244
}
245-
return errors.New("unknown field")
246245
}
247246

248-
// Missing required field
249-
if strings.Contains(msg, "missing property") {
250-
if idx := strings.Index(msg, "missing property '"); idx != -1 {
251-
remaining := msg[idx+len("missing property '"):]
252-
if endIdx := strings.Index(remaining, "'"); endIdx != -1 {
253-
fieldName := remaining[:endIdx]
254-
return fmt.Errorf("required field %q is missing", fieldName)
255-
}
256-
}
257-
return errors.New("required field is missing")
247+
keyword := cause.ErrorKind.KeywordPath()
248+
249+
// Extract field name from InstanceLocation if available
250+
var fieldName string
251+
if len(cause.InstanceLocation) > 0 {
252+
fieldName = cause.InstanceLocation[0]
258253
}
259254

260-
// Required field set to null
261-
if strings.Contains(msg, "got null, want string") {
262-
if idx := strings.Index(msg, "at '/"); idx != -1 {
263-
remaining := msg[idx+len("at '/"):]
264-
if endIdx := strings.Index(remaining, "'"); endIdx != -1 {
265-
fieldName := remaining[:endIdx]
255+
// Check the keyword to determine error type
256+
if len(keyword) > 0 {
257+
switch keyword[len(keyword)-1] {
258+
case "additionalProperties":
259+
// Unknown field error - extract field name from the error message
260+
msg := cause.Error()
261+
if idx := strings.Index(msg, "additional properties '"); idx != -1 {
262+
remaining := msg[idx+len("additional properties '"):]
263+
if endIdx := strings.Index(remaining, "'"); endIdx != -1 {
264+
fieldName = remaining[:endIdx]
265+
}
266+
}
267+
if fieldName != "" {
268+
return fmt.Errorf("unknown field %q", fieldName)
269+
}
270+
return errors.New("unknown field")
271+
272+
case "required":
273+
// Missing required field - extract from error message
274+
msg := cause.Error()
275+
if idx := strings.Index(msg, "missing property '"); idx != -1 {
276+
remaining := msg[idx+len("missing property '"):]
277+
if endIdx := strings.Index(remaining, "'"); endIdx != -1 {
278+
fieldName = remaining[:endIdx]
279+
}
280+
}
281+
if fieldName != "" {
266282
return fmt.Errorf("required field %q is missing", fieldName)
267283
}
268-
}
269-
return errors.New("required field is missing")
270-
}
284+
return errors.New("required field is missing")
285+
286+
case "type":
287+
// Type mismatch
288+
msg := cause.Error()
289+
290+
// Check for null -> string case (treat as missing required field)
291+
if strings.Contains(msg, "got null, want string") {
292+
if fieldName != "" {
293+
return fmt.Errorf("required field %q is missing", fieldName)
294+
}
295+
return errors.New("required field is missing")
296+
}
297+
298+
// Check for top-level type error (input is not an object)
299+
if len(cause.InstanceLocation) == 0 && strings.Contains(msg, "want object") {
300+
return errors.New("input is not a valid JSON object")
301+
}
302+
303+
// Extract type mismatch info
304+
var gotType, wantType string
305+
if idx := strings.Index(msg, "got "); idx != -1 {
306+
remaining := msg[idx+len("got "):]
307+
if endIdx := strings.Index(remaining, ","); endIdx != -1 {
308+
gotType = remaining[:endIdx]
309+
}
310+
}
311+
if idx := strings.Index(msg, "want "); idx != -1 {
312+
wantType = strings.TrimSpace(msg[idx+len("want "):])
313+
}
314+
315+
if fieldName != "" && gotType != "" && wantType != "" {
316+
return fmt.Errorf("invalid value type for field %q: expected %q but got %q", fieldName, wantType, gotType)
317+
}
318+
319+
case "format":
320+
// Format validation (our custom formats for install modes)
321+
msg := cause.Error()
322+
323+
// Include field location and "configuration validation failed" prefix
324+
// to match test expectations
325+
if fieldName != "" {
326+
// Strip the "at '/fieldName': " prefix if present
327+
if idx := strings.Index(msg, ": "); idx != -1 {
328+
return fmt.Errorf("configuration validation failed at '/%s': %s", fieldName, msg[idx+2:])
329+
}
330+
return fmt.Errorf("configuration validation failed at '/%s': %s", fieldName, msg)
331+
}
271332

272-
// Wrong type (e.g., number instead of string)
273-
if strings.Contains(msg, "got") && strings.Contains(msg, "want") {
274-
if err := handleTypeMismatchMessage(msg); err != nil {
275-
return err
333+
// Fallback without field name
334+
if idx := strings.Index(msg, ": "); idx != -1 {
335+
return fmt.Errorf("configuration validation failed: %s", msg[idx+2:])
336+
}
337+
return fmt.Errorf("configuration validation failed: %s", msg)
276338
}
277339
}
278340

279-
// Couldn't parse - return wrapped error with context
341+
// Couldn't determine specific error type, return the raw message
342+
msg := cause.Error()
343+
// Strip "at '/fieldName': " prefix for cleaner output
344+
if idx := strings.Index(msg, ": "); idx != -1 {
345+
return fmt.Errorf("configuration validation failed: %s", msg[idx+2:])
346+
}
280347
return fmt.Errorf("configuration validation failed: %s", msg)
281348
}
282349

@@ -303,44 +370,3 @@ func formatUnmarshalError(err error) error {
303370
current = unwrapped
304371
}
305372
}
306-
307-
// handleTypeMismatchMessage extracts details from type mismatch errors.
308-
// Returns nil if we can't parse the error (caller will use a generic error message).
309-
func handleTypeMismatchMessage(msg string) error {
310-
if strings.Contains(msg, "want object") && strings.Contains(msg, "at ''") {
311-
return errors.New("input is not a valid JSON object")
312-
}
313-
314-
fieldName := substringBetween(msg, "at '/", "'")
315-
gotType := substringBetween(msg, "got ", ",")
316-
wantType := strings.TrimSpace(substringAfter(msg, "want "))
317-
318-
if fieldName == "" || gotType == "" || wantType == "" {
319-
return nil // unable to parse, let caller use fallback error handling
320-
}
321-
322-
return fmt.Errorf("invalid value type for field %q: expected %q but got %q", fieldName, wantType, gotType)
323-
}
324-
325-
func substringBetween(value, start, end string) string {
326-
begin := strings.Index(value, start)
327-
if begin == -1 {
328-
return ""
329-
}
330-
331-
remaining := value[begin+len(start):]
332-
finish := strings.Index(remaining, end)
333-
if finish == -1 {
334-
return ""
335-
}
336-
337-
return remaining[:finish]
338-
}
339-
340-
func substringAfter(value, token string) string {
341-
idx := strings.Index(value, token)
342-
if idx == -1 {
343-
return ""
344-
}
345-
return value[idx+len(token):]
346-
}

internal/operator-controller/rukpak/bundle/config_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ func Test_UnmarshalConfig(t *testing.T) {
268268
expectedWatchNamespace: nil,
269269
},
270270
{
271-
name: "handles empty installNamespace gracefully when OwnNamespace only",
271+
name: "skips validation when installNamespace is empty for OwnNamespace only",
272272
supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeOwnNamespace},
273273
rawConfig: []byte(`{"watchNamespace": "valid-ns"}`),
274274
installNamespace: "",

0 commit comments

Comments
 (0)