<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #44333 # Checklist for submitter - [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. Also added some integration tests as a follow-up of the first PR (https://github.com/fleetdm/fleet/pull/44390). - [x] QA'd all new/changed functionality manually #### generate-gitops - Branched off to main, no URLs set, then ran generate-gitops on this branch. Deprecated keys gone, new keys present. <img width="447" height="170" alt="nourls_new" src="https://github.com/user-attachments/assets/61931615-d61b-44d3-8095-f7a2b9bd8871" /> - Branched off to main, set external URLs for both light and dark modes, then ran generate-gitops on this branch. Deprecated keys gone, new keys set with the external URLs. <img width="637" height="471" alt="externalurl_main" src="https://github.com/user-attachments/assets/c3782756-acc2-4b99-812d-86e145f11ad5" /> <img width="459" height="168" alt="externalurl_new" src="https://github.com/user-attachments/assets/aa2d8825-3c47-40ba-ab91-bb8202afe81a" /> - Within this branch, after uploading a custom logo for light mode, ran generate-gitops. The logo was saved in lib/org_logo/light.webp <img width="1510" height="639" alt="Screenshot 2026-05-04 at 4 06 59 PM" src="https://github.com/user-attachments/assets/13318c24-8fa4-4e29-b629-ff723d4afe5a" /> <img width="786" height="172" alt="Screenshot 2026-05-04 at 4 07 30 PM" src="https://github.com/user-attachments/assets/b46bd1df-7dcd-4489-b7da-4cbad77b25b8" /> #### gitops - Applied gitops with two external URLs. Verified in the UI that those are still present <img width="944" height="189" alt="Screenshot 2026-05-04 at 7 54 53 AM" src="https://github.com/user-attachments/assets/a34813ca-beb1-403e-9793-d42cc9c72f8b" /> <img width="637" height="259" alt="Screenshot 2026-05-04 at 8 01 04 AM" src="https://github.com/user-attachments/assets/74c2cd56-ab1d-4ddd-9b8e-22c49e9ae9d5" /> - Applied gitops with "" as the URLs to clear them. Verified the default fleet logo is shown. <img width="460" height="201" alt="Screenshot 2026-05-04 at 8 15 11 AM" src="https://github.com/user-attachments/assets/dcbafea3-b4ea-44aa-9045-08c4f5a64e98" /> <img width="648" height="269" alt="Screenshot 2026-05-04 at 8 15 50 AM" src="https://github.com/user-attachments/assets/451a28f9-e929-4b84-93d3-a7dd9afd5eca" /> - Applied gitops with a custom logo for light theme, using **org_logo_path_light_mode**: <img width="948" height="207" alt="Screenshot 2026-05-04 at 4 10 05 PM" src="https://github.com/user-attachments/assets/b1418cd4-31cc-4e53-b566-9af11ec21970" /> <img width="774" height="168" alt="Screenshot 2026-05-04 at 4 10 35 PM" src="https://github.com/user-attachments/assets/63f596eb-308f-4122-ad86-e1d718e9b525" /> ## New Fleet configuration settings - [x] Verified that the setting is exported via `fleetctl generate-gitops` - [x] Verified the setting is documented in a separate PR to [the GitOps documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485) - See https://github.com/fleetdm/fleet/pull/43808. - [x] Verified that the setting is cleared on the server if it is not supplied in a YAML file (or that it is documented as being optional) - [x] Verified that any relevant UI is disabled when GitOps mode is enabled <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * GitOps support for uploading custom org logos (dark/light) via local files. * `fleetctl generate-gitops` exports Fleet-hosted logos as local files and inserts path references. * New API endpoints to upload, delete, and fetch org logos. * **Deprecated** * Legacy logo keys consolidated into mode-specific URL keys (`org_logo_url_dark_mode`, `org_logo_url_light_mode`). * **Bug Fixes / Validation** * Validation/error when both a path and URL are provided for the same mode; file size and image-format checks enforced. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
299 lines
9.0 KiB
Go
299 lines
9.0 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)
|
|
|
|
if strings.Contains(t.Name(), "BoolOr") && field.Name == "Other" {
|
|
collectFields(field.Type, keys)
|
|
}
|
|
|
|
// 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](),
|
|
"org_info": reflect.TypeFor[GitOpsOrgInfo](),
|
|
},
|
|
}
|
|
|
|
// 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()
|
|
}
|