fleet/pkg/spec/gitops_validate.go
Scott Gress 2bf46b14ad
Detect unknown keys in top-level GitOps settings (#41303)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #41280

# Details

Phase 2 of the "detect unknown keys in GitOps" work. The `org_settings`
and `settings` top-level keys mainly shadow the `fleet.AppConfig` and
`fleet.TeamConfig` types, but they have a couple of extra GitOps-only
fields, so we add new GitOps-specific types for them (similar to what we
already have for `GitOpsControls` and `GitOpsSoftware`. The
`org_settings:` case is further complicated by the fact that its extra
fields are themselves `any` types which we need to parse, so we add
those to the `anyFieldTypes` registry in the validator to tell it what
types to check them against.

Also had to add some new logic to handle the GoogleCalendarAPI case
which doesn't expose its keys as `json` tags at all, since we use a
special method to obfuscate the values.

I've tested this by routing the output from `fleetctl generate_gitops`
back through `fleetctl gitops`, which is how I caught the
`end_user_license_agreement` issue.

# Checklist for submitter

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

- [ ] 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.
n/a - already added in previous PR

## Testing

- [X] Added/updated automated tests
- [X] QA'd all new/changed functionality manually
Did the `fleetctl generate-gitops` -> `fleetctl gitops` loop as
mentioned above.

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

## Summary by CodeRabbit

## Release Notes

* **New Features**
* Added support for managing secrets and certificate authorities through
GitOps configuration
* Improved detection of configuration errors with clear error messages
when using unknown or misspelled settings keys, including suggestions
for common typos
* Enhanced error reporting for nested configuration files with precise
location information

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Ian Littman <iansltx@gmail.com>
2026-03-11 08:26:39 -05:00

293 lines
8.9 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
}
// ValidKeysProvider is implemented by types with custom JSON marshaling
// that want to declare valid keys for gitops unknown-key validation.
type ValidKeysProvider interface {
ValidKeys() []string
}
var validKeysProviderType = reflect.TypeFor[ValidKeysProvider]()
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.
// For types implementing ValidKeysProvider, the declared keys are used instead.
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)
// If the type (or pointer to it) implements ValidKeysProvider, use those
// keys instead of reflecting on struct fields. This handles types with
// custom JSON marshaling (e.g. GoogleCalendarApiKey).
if reflect.PointerTo(t).Implements(validKeysProviderType) || t.Implements(validKeysProviderType) {
provider := reflect.New(t).Interface().(ValidKeysProvider)
for _, name := range provider.ValidKeys() {
keys[name] = fieldInfo{
jsonName: name,
typ: reflect.TypeFor[any](),
}
}
} else {
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](),
},
reflect.TypeFor[GitOpsOrgSettings](): {
"certificate_authorities": reflect.TypeFor[fleet.GroupedCertificateAuthorities](),
"mdm": reflect.TypeFor[GitOpsMDM](),
},
}
// 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 len(known) == 0 {
// No JSON-tagged fields: either not a struct or a struct with custom
// serialization. Skip validation since we don't know the expected keys.
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
// Check the override registry for this field. This handles two cases:
// 1. `any`/`interface{}` fields that need a concrete type for recursion
// 2. Struct fields that need a gitops-extended type (e.g. fleet.MDM -> GitOpsMDM)
if override, ok := parentOverrides[key]; ok {
fieldType = override
} else if fieldType.Kind() == reflect.Interface {
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()
}