package gitops import ( "encoding/json" "fmt" "strconv" ) // DriftEntry is one field where the repo-declared value differs from the live // stored value. Values are display strings; comparison is done on normalized // forms so cosmetic differences (default coercion, YAML int vs JSON number) // don't register as drift. type DriftEntry struct { Field string `json:"field"` RepoValue string `json:"repo_value"` LiveValue string `json:"live_value"` } // driftFieldOrder is the stable order drift entries are reported in. var driftFieldOrder = []string{keyPort, keyHealthcheck, keyDeployStrategy} // Drift compares the declared overlay (the present, source-supported fields) // against the live source_config and returns the fields that differ. Only // declared fields are considered — a key the file omits is "unmanaged", // neither drift nor clean (review C5). Comparison is post-normalization. func Drift(spec Spec, live json.RawMessage, sourceKind string) ([]DriftEntry, error) { liveMap := map[string]any{} if len(live) > 0 { if err := json.Unmarshal(live, &liveMap); err != nil { return nil, fmt.Errorf("gitops: decode live source_config: %w", err) } } allowed := supportedKeys(sourceKind) declared := declaredValues(spec) var entries []DriftEntry for _, k := range driftFieldOrder { repoVal, ok := declared[k] if !ok || !allowed[k] { continue } liveVal, livePresent := liveMap[k] if normalizeField(k, repoVal) == normalizeField(k, liveVal) { continue } entries = append(entries, DriftEntry{ Field: k, RepoValue: displayField(k, repoVal, true), LiveValue: displayField(k, liveVal, livePresent), }) } return entries, nil } // normalizeField returns the canonical comparison form of a field value. func normalizeField(key string, v any) string { switch key { case keyDeployStrategy: // "" and "recreate" are the same effective strategy for dockerfile and // static (see each source's effectiveStrategy). s := toStr(v) if s == "" || s == "recreate" { return "recreate" } return s case keyPort: return canonInt(v) default: return toStr(v) } } // displayField renders a value for the UI. present=false means the key is // absent from the live config. func displayField(key string, v any, present bool) string { if !present { return "(unset)" } if key == keyDeployStrategy { if s := toStr(v); s == "" { return "recreate (default)" } } switch n := v.(type) { case float64: // JSON numbers decode as float64; show whole numbers without ".0". return strconv.FormatInt(int64(n), 10) case nil: return "(unset)" default: return fmt.Sprint(v) } } // canonInt coerces any numeric representation (YAML int, JSON float64, etc.) // to a base-10 integer string for value-equality comparison. func canonInt(v any) string { switch n := v.(type) { case int: return strconv.Itoa(n) case int64: return strconv.FormatInt(n, 10) case float64: return strconv.FormatInt(int64(n), 10) case json.Number: return n.String() case nil: return "0" default: return fmt.Sprint(v) } } func toStr(v any) string { if v == nil { return "" } if s, ok := v.(string); ok { return s } return fmt.Sprint(v) }