mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 21:47:20 +00:00
<!-- 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>
293 lines
8.9 KiB
Go
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()
|
|
}
|