fleet/pkg/spec/gitops_validate.go
Scott Gress d5eee802eb
Detect unknown keys in GitOps (phase 1) (#40963)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #40496

# Details

This is the first phase of an effort to detect unknown keys in GitOps
.yml files. In the regular `fleetctl gitops` case, it will fail when
unknown keys are detected. This behavior can be changed with a new
`--allow-unknown-keys` flag which will log the issues and continue.

In this first phase we are detecting unknown keys in _most_ GitOps
sections, other than the top-level `org_settings:` and `settings:`
sections which have more complicated typing. I will tackle those
separately as they require a bit more thought. Also ultimately I'd like
us to be doing this validation in a more top-down fashion in one place,
rather than spreading it across the code by doing it in each individual
section, but this is a good first step.

As a bonus, I invited my pal Mr. Levenshtein to the party so that we can
make suggestions when unknown keys are detected, like:

```
 * unknown key "queyr" in "./lib/some-report.yml"; did you mean "query"?
```
> Note: the goal is to return as many validation errors as possible to
the user, so they don't have to keep running `fleetctl gitops` to get
the next error. I did _not_ update any other errors to stop returning
early, in an effort to keep this as low-touch as possible.

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [X] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [X] Added/updated automated tests
- [X] QA'd all new/changed functionality manually
- [X] Tested this against existing it-and-security folder and one with
updated keys from https://github.com/fleetdm/fleet/pull/40959; no
unknown keys detected
- [X] Added unknown keys at various levels, GitOps errored with helpful
messages
- [X] Same as above but with `--allow-unknown-keys`; GitOps outputted
helpful messages but continued.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* GitOps runs now fail when unknown or misspelled keys are present in
configuration files.
* New CLI flag --allow-unknown-keys lets unknown keys be treated as
warnings instead of errors.
* Unknown-key messages include suggested valid key names to help correct
mistakes.

* **Tests**
* Expanded test coverage to validate unknown-key detection and the
allow-as-warning option.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Ian Littman <iansltx@gmail.com>
2026-03-06 16:16:17 -06:00

263 lines
7.6 KiB
Go

package spec
import (
"encoding/json"
"fmt"
"reflect"
"slices"
"strings"
"sync"
"github.com/agnivade/levenshtein"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/hashicorp/go-multierror"
)
// fieldInfo holds metadata about a struct field extracted from its JSON tag.
type fieldInfo struct {
jsonName string
typ reflect.Type
}
var (
knownKeysCache = make(map[reflect.Type]map[string]fieldInfo)
knownKeysCacheMu sync.Mutex
)
// knownJSONKeys extracts the set of valid JSON field names from a struct type,
// including fields from embedded structs. Results are cached per type.
func knownJSONKeys(t reflect.Type) map[string]fieldInfo {
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return nil
}
knownKeysCacheMu.Lock()
defer knownKeysCacheMu.Unlock()
if cached, ok := knownKeysCache[t]; ok {
return cached
}
keys := make(map[string]fieldInfo)
collectFields(t, keys)
knownKeysCache[t] = keys
return keys
}
// collectFields recursively extracts JSON field names from a struct type,
// handling embedded structs by inlining their fields.
func collectFields(t reflect.Type, keys map[string]fieldInfo) {
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
// Handle embedded structs: inline their fields.
if field.Anonymous {
ft := field.Type
if ft.Kind() == reflect.Ptr {
ft = ft.Elem()
}
if ft.Kind() == reflect.Struct {
collectFields(ft, keys)
}
continue
}
tag := field.Tag.Get("json")
if tag == "" || tag == "-" {
continue
}
name := strings.Split(tag, ",")[0]
if name == "" {
continue
}
keys[name] = fieldInfo{
jsonName: name,
typ: field.Type,
}
// Also register the "renameto" alias (deprecated field name mappings)
// so that both old and new names are accepted.
if alias := field.Tag.Get("renameto"); alias != "" {
keys[alias] = fieldInfo{
jsonName: alias,
typ: field.Type,
}
}
}
}
// anyFieldTypes maps parent struct types to overrides for fields typed as `any`/`interface{}`.
// When the walker encounters an `any`-typed field, it checks this registry to determine
// the concrete type to use for recursive validation.
var anyFieldTypes = map[reflect.Type]map[string]reflect.Type{
reflect.TypeFor[GitOpsControls](): {
"macos_updates": reflect.TypeFor[fleet.AppleOSUpdateSettings](),
"ios_updates": reflect.TypeFor[fleet.AppleOSUpdateSettings](),
"ipados_updates": reflect.TypeFor[fleet.AppleOSUpdateSettings](),
"macos_migration": reflect.TypeFor[fleet.MacOSMigration](),
"windows_updates": reflect.TypeFor[fleet.WindowsUpdates](),
"macos_settings": reflect.TypeFor[fleet.MacOSSettings](),
"windows_settings": reflect.TypeFor[fleet.WindowsSettings](),
"android_settings": reflect.TypeFor[fleet.AndroidSettings](),
},
}
// suggestKey returns the closest known key name if one is within a reasonable
// edit distance, or empty string if no good match exists.
func suggestKey(unknown string, known map[string]fieldInfo) string {
bestKey := ""
bestDist := len(unknown) // worst case: replace every character
for candidate := range known {
d := levenshtein.ComputeDistance(unknown, candidate)
if d < bestDist {
bestDist = d
bestKey = candidate
}
}
// Suggest only if the distance is at most ~40% of the longer string's length,
// with a minimum threshold of 1 (exact single-char typos always suggest).
maxDist := max(1, max(len(unknown), len(bestKey))*2/5)
if bestDist <= maxDist {
return bestKey
}
return ""
}
// validateUnknownKeys walks parsed JSON data and compares keys against
// struct field tags at every nesting level. Returns all unknown key errors found.
func validateUnknownKeys(data any, targetType reflect.Type, path []string, filePath string) []error {
if targetType.Kind() == reflect.Ptr {
targetType = targetType.Elem()
}
switch d := data.(type) {
case map[string]any:
return validateMapKeys(d, targetType, path, filePath)
case []any:
return validateSliceKeys(d, targetType, path, filePath)
default:
return nil
}
}
// validateMapKeys validates keys in a JSON object against the known keys
// for the target struct type.
func validateMapKeys(data map[string]any, targetType reflect.Type, path []string, filePath string) []error {
if targetType.Kind() == reflect.Ptr {
targetType = targetType.Elem()
}
if targetType.Kind() != reflect.Struct {
return nil
}
known := knownJSONKeys(targetType)
if known == nil {
return nil
}
var errs []error
parentOverrides := anyFieldTypes[targetType]
for key, val := range data {
fi, ok := known[key]
if !ok {
errs = append(errs, &ParseUnknownKeyError{
Filename: filePath,
Path: strings.Join(path, "."),
Field: key,
Suggestion: suggestKey(key, known),
})
continue
}
// Determine the type to recurse into.
fieldType := fi.typ
// If the field type is `any` (interface{}), check the override registry.
if fieldType.Kind() == reflect.Interface {
if override, ok := parentOverrides[key]; ok { // indexing a nil map is safe; ok will be false
fieldType = override
} else {
continue // any-typed field with no override, skip
}
}
// Recurse into nested structs or slices.
childPath := append(slices.Clone(path), key)
childErrs := validateUnknownKeys(val, fieldType, childPath, filePath)
errs = append(errs, childErrs...)
}
return errs
}
// validateSliceKeys validates each element in a JSON array.
func validateSliceKeys(data []any, targetType reflect.Type, path []string, filePath string) []error {
// Determine the element type from the target slice type.
var elemType reflect.Type
switch targetType.Kind() {
case reflect.Slice, reflect.Array:
elemType = targetType.Elem()
default:
// If targetType isn't a slice, we can't determine element type.
return nil
}
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
var errs []error
for i, elem := range data {
elemPath := append(slices.Clone(path), fmt.Sprintf("[%d]", i))
childErrs := validateUnknownKeys(elem, elemType, elemPath, filePath)
errs = append(errs, childErrs...)
}
return errs
}
// validateRawKeys unmarshals raw JSON into a generic structure and validates
// all keys against the target type. This is a convenience wrapper for use at
// each integration point.
func validateRawKeys(raw json.RawMessage, targetType reflect.Type, filePath string, keysPath []string) []error {
var data any
if err := json.Unmarshal(raw, &data); err != nil {
return []error{err} // parse errors already caught by the struct unmarshal
}
return validateUnknownKeys(data, targetType, keysPath, filePath)
}
// validateYAMLKeys unmarshals raw YAML into a generic structure and validates
// all keys against the target type. Use this for path-referenced files that
// contain YAML rather than JSON.
func validateYAMLKeys(yamlBytes []byte, targetType reflect.Type, filePath string, keysPath []string) []error {
var data any
if err := YamlUnmarshal(yamlBytes, &data); err != nil {
return []error{err}
}
return validateUnknownKeys(data, targetType, keysPath, filePath)
}
// filterWarnings removes errors matching the given types from a multierror,
// logging them as warnings instead. Returns the filtered error (nil if empty).
func filterWarnings(multiError *multierror.Error, logFn func(string, ...any), types ...reflect.Type) error {
if multiError == nil {
return nil
}
var filtered *multierror.Error
for _, err := range multiError.Errors {
if slices.Contains(types, reflect.TypeOf(err)) {
logFn("[!] warning: %s\n", err.Error())
} else {
filtered = multierror.Append(filtered, err)
}
}
return filtered.ErrorOrNil()
}