fleet/server/platform/endpointer/json_key_duplicator.go
Scott Gress 01d13f5080
add keymap for new renames, and shallow duplication (#41682)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** For #41091 

# Details

This PR finishes the work of aliasing multi-platform keys by:

* Added the renames to the list maintained by generate-gitops so that
`fleetctl get` can use the new names
* Updated the code that adds the new names to API and `fleetctl get`
output to only add new nested keys under new parents, e.g. add
`apple_settings.configuration_profiles`, but not
`macos_settings.configuration_profiles`.

The API key duplicator now runs through `RewriteDeprecatedKeys` which is
a little heavier per-token, but for old keys we're doing less work so I
think this ends up being slightly more performant than before, at least
for large payloads.

# 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, changelog for new keys added in previous PR

## Testing

- [X] Added/updated automated tests
  updated tests for the duplicators 
- [X] QA'd all new/changed functionality manually
- [X] `/config` and `/fleets` APIs now only return new keys under new
parents
  - [X] `fleetctl get fleets` now returns new multiplatform keys
2026-03-16 08:37:23 -05:00

181 lines
5.2 KiB
Go

package endpointer
import (
"bytes"
"io"
"github.com/go-json-experiment/json/jsontext"
)
// DuplicateJSONKeysOpts controls optional behavior of DuplicateJSONKeys.
type DuplicateJSONKeysOpts struct {
// Compact disables pretty-printing. By default, output is indented with
// two spaces to match the standard API response format.
Compact bool
}
// DuplicateJSONKeys takes marshaled JSON and, for each AliasRule, duplicates
// keys so that both the old (native) and new names appear in the output.
// For example, if a rule maps OldKey:"team_id" → NewKey:"fleet_id", and the
// JSON contains "team_id": 42, the output will contain both "team_id": 42
// and "fleet_id": 42.
//
// If the new key already exists in the same object scope, the duplication is
// skipped for that key (to avoid producing duplicate keys when the source
// struct already has both, or when the function is called more than once).
//
// The function uses jsontext.Decoder/Encoder for token-level processing,
// delegating all JSON lexing (string escaping, unicode, nesting) to the
// library. Duplicates are deferred until the closing '}' of each object so
// that naturally-occurring new keys can be detected and skipped.
func DuplicateJSONKeys(data []byte, rules []AliasRule, opts ...DuplicateJSONKeysOpts) []byte {
if len(rules) == 0 || len(data) == 0 {
return data
}
oldToNew := make(map[string]string, len(rules))
newToOld := make(map[string]string, len(rules))
for _, r := range rules {
oldToNew[r.OldKey] = r.NewKey
newToOld[r.NewKey] = r.OldKey
}
var buf bytes.Buffer
dec := jsontext.NewDecoder(bytes.NewReader(data), jsontext.AllowDuplicateNames(true))
encOpts := []jsontext.Options{jsontext.AllowDuplicateNames(true)}
if len(opts) == 0 || !opts[0].Compact {
encOpts = append(encOpts, jsontext.WithIndent(" "))
}
enc := jsontext.NewEncoder(&buf, encOpts...)
// pendingDup holds a key-value pair that should be inserted as a
// duplicate at the end of the current object scope (before '}'),
// unless the new key was found naturally in the same scope.
type pendingDup struct {
newKey string
value jsontext.Value
}
// Per-object-scope state: pending duplicates and naturally-seen new keys.
type scopeState struct {
pending []pendingDup
naturalNew map[string]bool
}
var scopes []scopeState
for {
tok, err := dec.ReadToken()
if err != nil {
if err == io.EOF {
break
}
// On any error, return the original data unchanged.
return data
}
kind := tok.Kind()
switch kind {
case '{':
scopes = append(scopes, scopeState{naturalNew: make(map[string]bool)})
if err := enc.WriteToken(tok); err != nil {
return data
}
case '}':
// Before closing the object, emit any pending duplicates whose
// new key was not seen naturally in this scope.
if len(scopes) > 0 {
scope := scopes[len(scopes)-1]
for _, dup := range scope.pending {
if scope.naturalNew[dup.newKey] {
continue // new key exists naturally; skip duplicate
}
if err := enc.WriteToken(jsontext.String(dup.newKey)); err != nil {
return data
}
if err := enc.WriteValue(dup.value); err != nil {
return data
}
}
scopes = scopes[:len(scopes)-1]
}
if err := enc.WriteToken(tok); err != nil {
return data
}
case '"':
// Determine if this string is an object key.
isKey := false
depth := dec.StackDepth()
if depth > 0 {
parentKind, length := dec.StackIndex(depth)
if parentKind == '{' && length%2 == 1 {
isKey = true
}
}
if isKey {
keyName := tok.String()
// Track new keys that appear naturally.
if len(scopes) > 0 && newToOld[keyName] != "" {
scopes[len(scopes)-1].naturalNew[keyName] = true
}
// Check if this key is deprecated and should generate a duplicate.
newKey, shouldDuplicate := oldToNew[keyName]
if shouldDuplicate {
// Write the old key.
if err := enc.WriteToken(tok); err != nil {
return data
}
// Read the raw value.
val, err := dec.ReadValue()
if err != nil {
return data
}
// Write the original value as-is for the old key — it
// already uses old names from json.Marshal, so no
// transformation is needed.
if err := enc.WriteValue(val); err != nil {
return data
}
// For the new key, rename nested keys to new names only
// (removing old names) so the new-name subtree is clean.
newVal, renameErr := RewriteOldToNewKeys([]byte(val), rules)
if renameErr != nil {
newVal = []byte(val) // fall back to original value on error
}
// Defer the duplicate for emission at '}'.
if len(scopes) > 0 {
scopes[len(scopes)-1].pending = append(
scopes[len(scopes)-1].pending,
pendingDup{newKey: newKey, value: jsontext.Value(newVal)},
)
}
} else { // !shouldDuplicate (no old key match) — just write the key as-is
if err := enc.WriteToken(tok); err != nil {
return data
}
}
} else { // !isKey — string value, not a key — just write as-is
if err := enc.WriteToken(tok); err != nil {
return data
}
}
default:
// All other tokens: [, ], numbers, bools, null — pass through.
if err := enc.WriteToken(tok); err != nil {
return data
}
}
}
return buf.Bytes()
}