mirror of
https://github.com/fleetdm/fleet
synced 2026-05-24 09:28:54 +00:00
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #40122 # Details * Adds deprecation warnings to `fleetctl apply` * Adds alias conflict errors (i.e. using both new and deprecated keys in the same spec) to `fleetctl apply` * Adds logic around all deprecated field warnings to check the topic first * Disables deprecation warnings by default for `fleet serve`, `fleetctl gitops` and `fleetctl apply` * Enables deprecation warnings for dogfood via env var To turn on warnings: * In `fleet serve`, use either `--logging_enable_topics=deprecated-field-names` or the `FLEET_LOGGING_ENABLE_TOPICS=deprecated-field-names` env var * In `fleetctl gitops` / `fleetctl apply` use either `--enable-log-topics=deprecated-field-names` or `FLEET_ENABLE_LOG_TOPICS=deprecated-field-names` # 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 tested in `fleetctl apply`, `fleet serve` and `fleet gitops` that warnings are suppressed by default and added when the appropriate env var or CLI option is used
264 lines
8.1 KiB
Go
264 lines
8.1 KiB
Go
package endpointer
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
|
|
"github.com/go-json-experiment/json/jsontext"
|
|
)
|
|
|
|
// AliasConflictError is returned when both the deprecated and new field names
|
|
// are specified in the same JSON object scope. For example, if "team_id" is
|
|
// renamed to "fleet_id", and a request contains both, this error is returned.
|
|
type AliasConflictError struct {
|
|
Old string
|
|
New string
|
|
}
|
|
|
|
func (e *AliasConflictError) Error() string {
|
|
return fmt.Sprintf("Conflicting field names: cannot specify both `%s` (deprecated) and `%s` in the same request", e.Old, e.New)
|
|
}
|
|
|
|
// AliasRule defines a key-rename rule: the deprecated (old) key name and its
|
|
// replacement (new) key name. The struct's json tag uses OldKey (the current
|
|
// name), and renameto specifies NewKey (the target name). The rewriter
|
|
// accepts both names in requests: OldKey passes through as-is (with
|
|
// deprecation tracking) and NewKey is rewritten to OldKey for deserialization.
|
|
type AliasRule struct {
|
|
OldKey string
|
|
NewKey string
|
|
}
|
|
|
|
// JSONKeyRewriteReader is a streaming io.Reader that handles
|
|
// JSON key aliasing while reading. It:
|
|
//
|
|
// - Passes through OldKey (deprecated) names as-is (the struct expects them)
|
|
// and tracks them in usedDeprecated for deprecation logging.
|
|
// - Rewrites NewKey names to OldKey so the struct can deserialize them.
|
|
// - Detects alias conflicts: if both OldKey and NewKey appear in the same
|
|
// JSON object scope, it returns an *AliasConflictError.
|
|
//
|
|
// It uses jsontext.Decoder/Encoder for token-level processing, delegating all
|
|
// JSON lexing (string escaping, unicode, whitespace) to the library.
|
|
type JSONKeyRewriteReader struct {
|
|
reader *bytes.Reader
|
|
initErr error
|
|
|
|
// Map from old (deprecated) key to its AliasRule for fast lookup.
|
|
oldKeyIndex map[string]AliasRule
|
|
// Map from new key to its AliasRule for fast lookup.
|
|
newKeyIndex map[string]AliasRule
|
|
|
|
// Tracks which deprecated keys have been used (old key -> true).
|
|
usedDeprecated map[string]bool
|
|
}
|
|
|
|
// NewJSONKeyRewriteReader creates a new JSONKeyRewriteReader that wraps the
|
|
// given reader and applies the provided alias rules. It reads JSON tokens
|
|
// from src, handles bidirectional key aliasing, detects conflicts, and
|
|
// writes the result to an internal buffer.
|
|
func NewJSONKeyRewriteReader(src io.Reader, rules []AliasRule) *JSONKeyRewriteReader {
|
|
oldIdx := make(map[string]AliasRule, len(rules))
|
|
newIdx := make(map[string]AliasRule, len(rules))
|
|
for _, r := range rules {
|
|
oldIdx[r.OldKey] = r
|
|
newIdx[r.NewKey] = r
|
|
}
|
|
|
|
rw := &JSONKeyRewriteReader{
|
|
oldKeyIndex: oldIdx,
|
|
newKeyIndex: newIdx,
|
|
usedDeprecated: make(map[string]bool),
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := rw.rewrite(src, &buf); err != nil {
|
|
rw.initErr = err
|
|
return rw
|
|
}
|
|
rw.reader = bytes.NewReader(buf.Bytes())
|
|
return rw
|
|
}
|
|
|
|
// UsedDeprecatedKeys returns the list of deprecated key names that were
|
|
// encountered during reading. This should be called after the reader has been
|
|
// fully consumed (i.e., after json.Decoder.Decode or similar has returned),
|
|
// which guarantees the background goroutine has finished.
|
|
func (r *JSONKeyRewriteReader) UsedDeprecatedKeys() []string {
|
|
keys := make([]string, 0, len(r.usedDeprecated))
|
|
for k := range r.usedDeprecated {
|
|
keys = append(keys, k)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
// Close closes the reader end of the pipe to unblock the transform goroutine
|
|
// if the consumer stops reading early.
|
|
func (r *JSONKeyRewriteReader) Close() error {
|
|
return nil
|
|
}
|
|
|
|
// Read implements io.Reader by reading from the pipe.
|
|
func (r *JSONKeyRewriteReader) Read(p []byte) (int, error) {
|
|
if r.initErr != nil {
|
|
return 0, r.initErr
|
|
}
|
|
if r.reader == nil {
|
|
return 0, io.EOF
|
|
}
|
|
return r.reader.Read(p)
|
|
}
|
|
|
|
// RewriteDeprecatedKeys handles JSON key aliasing in data using
|
|
// the provided alias rules. It rewrites NewKey→OldKey (so the struct can
|
|
// deserialize), passes through OldKey as-is, and returns an error if both
|
|
// appear in the same scope (alias conflict) or the JSON is malformed.
|
|
//
|
|
// This is useful when a request body is captured as json.RawMessage and later
|
|
// decoded into a struct with `renameto` tags — the rewriter in MakeDecoder
|
|
// won't have seen the inner fields, so this function can be called before the
|
|
// deferred unmarshal.
|
|
func RewriteDeprecatedKeys(data []byte, rules []AliasRule) ([]byte, map[string]string, error) {
|
|
if len(rules) == 0 || len(data) == 0 {
|
|
return data, nil, nil
|
|
}
|
|
oldIdx := make(map[string]AliasRule, len(rules))
|
|
newIdx := make(map[string]AliasRule, len(rules))
|
|
for _, r := range rules {
|
|
oldIdx[r.OldKey] = r
|
|
newIdx[r.NewKey] = r
|
|
}
|
|
rw := &JSONKeyRewriteReader{
|
|
oldKeyIndex: oldIdx,
|
|
newKeyIndex: newIdx,
|
|
usedDeprecated: make(map[string]bool),
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := rw.rewrite(bytes.NewReader(data), &buf); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
deprecatedKeysMap := make(map[string]string, len(rw.usedDeprecated))
|
|
for k := range rw.usedDeprecated {
|
|
deprecatedKeysMap[k] = rw.oldKeyIndex[k].NewKey
|
|
}
|
|
return buf.Bytes(), deprecatedKeysMap, nil
|
|
}
|
|
|
|
// rewrite reads tokens from src, rewrites deprecated keys, checks for alias
|
|
// conflicts, and writes the transformed JSON to w.
|
|
func (r *JSONKeyRewriteReader) rewrite(src io.Reader, w io.Writer) error {
|
|
dec := jsontext.NewDecoder(src, jsontext.AllowDuplicateNames(true))
|
|
enc := jsontext.NewEncoder(w, jsontext.AllowDuplicateNames(true))
|
|
|
|
// Stack of per-object-scope key sets for conflict detection.
|
|
// Pushed on '{', popped on '}'.
|
|
var keyScopes []map[string]bool
|
|
|
|
for {
|
|
tok, err := dec.ReadToken()
|
|
if err != nil {
|
|
if err == io.EOF {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
kind := tok.Kind()
|
|
|
|
switch kind {
|
|
case '{':
|
|
keyScopes = append(keyScopes, make(map[string]bool))
|
|
if err := enc.WriteToken(tok); err != nil {
|
|
return err
|
|
}
|
|
|
|
case '}':
|
|
if len(keyScopes) > 0 {
|
|
keyScopes = keyScopes[:len(keyScopes)-1]
|
|
}
|
|
if err := enc.WriteToken(tok); err != nil {
|
|
return err
|
|
}
|
|
|
|
case '"':
|
|
// Determine if this string is an object key by checking the
|
|
// decoder's stack: inside an object ('{') at an odd length
|
|
// means we just read a key (name).
|
|
isKey := false
|
|
depth := dec.StackDepth()
|
|
if depth > 0 {
|
|
parentKind, length := dec.StackIndex(depth)
|
|
// length is odd after reading a name (names and values
|
|
// are counted separately).
|
|
if parentKind == '{' && length%2 == 1 {
|
|
isKey = true
|
|
}
|
|
}
|
|
|
|
if isKey {
|
|
keyName := tok.String()
|
|
|
|
// Use OldKey as the canonical key for scope tracking.
|
|
// Both OldKey (pass-through) and NewKey (rewrite) resolve
|
|
// to the same canonical key for conflict detection.
|
|
|
|
if rule, ok := r.oldKeyIndex[keyName]; ok {
|
|
// This is an OldKey (deprecated name). Pass through
|
|
// as-is — the struct expects this name. Track it for
|
|
// deprecation logging.
|
|
canonicalKey := rule.OldKey
|
|
r.usedDeprecated[keyName] = true
|
|
|
|
// Conflict detection.
|
|
if len(keyScopes) > 0 {
|
|
scope := keyScopes[len(keyScopes)-1]
|
|
if scope[canonicalKey] {
|
|
return &AliasConflictError{Old: rule.OldKey, New: rule.NewKey}
|
|
}
|
|
scope[canonicalKey] = true
|
|
}
|
|
|
|
// Write the key as-is (old name, which the struct expects).
|
|
if err := enc.WriteToken(tok); err != nil {
|
|
return err
|
|
}
|
|
} else if rule, ok := r.newKeyIndex[keyName]; ok {
|
|
// This is a NewKey. Rewrite it to OldKey so the
|
|
// struct can deserialize it.
|
|
canonicalKey := rule.OldKey
|
|
|
|
// Conflict detection.
|
|
if len(keyScopes) > 0 {
|
|
scope := keyScopes[len(keyScopes)-1]
|
|
if scope[canonicalKey] {
|
|
return &AliasConflictError{Old: rule.OldKey, New: rule.NewKey}
|
|
}
|
|
scope[canonicalKey] = true
|
|
}
|
|
|
|
// Write the rewritten (old) key.
|
|
if err := enc.WriteToken(jsontext.String(canonicalKey)); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
// Not an aliased key — pass through unchanged.
|
|
if err := enc.WriteToken(tok); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
} else {
|
|
// String value — pass through unchanged.
|
|
if err := enc.WriteToken(tok); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
default:
|
|
// All other tokens: [, ], numbers, bools, null — pass through.
|
|
if err := enc.WriteToken(tok); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|