mirror of
https://github.com/vee1e/krply.git
synced 2026-09-01 17:57:03 +00:00
Storage: fixed-width millisecond timestamp format with a one-time migration so lexicographic comparisons stay chronological; snapshot auto-IDs get nanosecond precision; Close is synchronized; commit failures roll back; DSN paths are URI-escaped; the dedup index is no longer rebuilt every startup; the in-memory store now dedups by event_id and honors Limit/Offset/SinceSeq. Event/discovery: stream IDs URL-escape the selector so slash-containing label selectors round-trip and cannot collide; alias resolution fills empty fields only and no longer clobbers explicit group/version/kind; rc maps to replicationcontrollers and rs to replicasets. Watch: the label selector is now actually applied to List and Watch; the 410 relist path is backoff-throttled, backoff resets after a healthy watch, an empty list resourceVersion backs off instead of looping forever, non-410 watch errors reconnect without writing a spurious permanent gap, an idle watch timer forces reconnects, and Run cancels sibling streams and recovers from panics. Synthetic relist events carry a distinct event_id so unchanged objects re-listed after a gap survive dedup. Materialize: a baseline now resets object state (objects deleted during a gap disappear) and heals open gaps; diff ignores server metadata by path, not by key name, so user fields named status/uid are kept; nested add/remove carry Added/Removed flags; dotted paths are escaped; Diff validates the window and treats a zero before as empty; Snapshot records per-stream watermarks and writes a TypeSnapshot journal record. Replay: apply requires a successful dry run; gvrFor covers every policy kind so Include* toggles work; unsupported kinds are reported as skipped instead of silently dropped; NodePort services lose clusterIP and nodePort while headless keeps clusterIP: None; targetNS is always honored; namespace mapping collisions are detected; plan IDs and field managers are collision-safe and plans are mutex-guarded against concurrent dry-runs/applies. API: cursor pagination pushes ingest_seq into the SQL filter (export --server no longer truncates); gap records expose their payload; /v1/diff requires a cluster_id; dry-run/apply no longer accept a client-supplied kubeconfig; plans carry target_context; HTTP server gets timeouts. Metrics: the watch collector and replay planner now bump the registered counters, and the server refreshes store-derived gauges on a ticker. CLI: replay apply works locally and refuses to apply after a failed dry run; coverage --server follows cursors and shows gap details; timeline auto-detects namespaces and resolves cluster-scoped objects; diff validates the window; export writes 0600 files; PVCs are no longer treated as cluster-scoped; krply-server reports the build version.
279 lines
7.3 KiB
Go
279 lines
7.3 KiB
Go
package materialize
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// DiffResult is the semantic diff between two points in time.
|
|
type DiffResult struct {
|
|
Before time.Time
|
|
After time.Time
|
|
Changes []ObjectDiff
|
|
HasGaps bool
|
|
Warning string
|
|
}
|
|
|
|
// ObjectDiff is the set of field changes for one object.
|
|
type ObjectDiff struct {
|
|
Namespace string
|
|
Name string
|
|
Kind string
|
|
Changes []FieldChange
|
|
}
|
|
|
|
// FieldChange is one changed field, addressed by a dotted JSON path.
|
|
type FieldChange struct {
|
|
Path string
|
|
Before any
|
|
After any
|
|
Added bool
|
|
Removed bool
|
|
}
|
|
|
|
// metadataIgnoreKeys are server-owned metadata fields, ignored only when they
|
|
// appear directly under metadata.
|
|
var metadataIgnoreKeys = map[string]bool{
|
|
"uid": true,
|
|
"resourceVersion": true,
|
|
"creationTimestamp": true,
|
|
"generation": true,
|
|
"managedFields": true,
|
|
"deletionTimestamp": true,
|
|
"ownerReferences": true,
|
|
}
|
|
|
|
// ignoreKey reports whether a map key at parentPath is server-owned noise.
|
|
// Ignoring is path-scoped so a user field named "status", "uid" or
|
|
// "resourceVersion" inside data or spec is never silently dropped.
|
|
func ignoreKey(parentPath, key string) bool {
|
|
if key == "status" && parentPath == "" {
|
|
return true
|
|
}
|
|
if parentPath == "metadata" && metadataIgnoreKeys[key] {
|
|
return true
|
|
}
|
|
if key == "kubectl.kubernetes.io/last-applied-configuration" && parentPath == "metadata.annotations" {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Diff reconstructs cluster state before and after, intersects object keys,
|
|
// and reports semantic field changes, ignoring server-owned metadata.
|
|
func (m *Materializer) Diff(ctx context.Context, clusterID, namespace string, before, after time.Time) (*DiffResult, error) {
|
|
if !before.IsZero() && !after.IsZero() && before.After(after) {
|
|
return nil, fmt.Errorf("materialize: before (%s) must not be after after (%s)", before.Format(time.RFC3339), after.Format(time.RFC3339))
|
|
}
|
|
streams, err := m.store.Streams(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
res := &DiffResult{Before: before, After: after}
|
|
beforeStates := map[string]ObjectState{}
|
|
afterStates := map[string]ObjectState{}
|
|
var gapped []string
|
|
|
|
for _, s := range streams {
|
|
if s.ClusterID != clusterID {
|
|
continue
|
|
}
|
|
if !before.IsZero() {
|
|
b, err := m.StreamState(ctx, s.StreamID, before)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if b.HasGaps {
|
|
gapped = append(gapped, s.StreamID)
|
|
}
|
|
for _, st := range b.Objects {
|
|
if namespace != "" && st.Namespace != namespace {
|
|
continue
|
|
}
|
|
beforeStates[st.StreamID+"|"+st.Namespace+"/"+st.Name] = st
|
|
}
|
|
}
|
|
a, err := m.StreamState(ctx, s.StreamID, after)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if a.HasGaps {
|
|
gapped = append(gapped, s.StreamID)
|
|
}
|
|
for _, st := range a.Objects {
|
|
if namespace != "" && st.Namespace != namespace {
|
|
continue
|
|
}
|
|
afterStates[st.StreamID+"|"+st.Namespace+"/"+st.Name] = st
|
|
}
|
|
}
|
|
|
|
keys := make([]string, 0, len(beforeStates)+len(afterStates))
|
|
seen := map[string]bool{}
|
|
for k := range beforeStates {
|
|
if !seen[k] {
|
|
seen[k] = true
|
|
keys = append(keys, k)
|
|
}
|
|
}
|
|
for k := range afterStates {
|
|
if !seen[k] {
|
|
seen[k] = true
|
|
keys = append(keys, k)
|
|
}
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
for _, k := range keys {
|
|
b, okB := beforeStates[k]
|
|
a, okA := afterStates[k]
|
|
od := ObjectDiff{}
|
|
var beforeAny, afterAny any
|
|
if okB {
|
|
od.Namespace, od.Name, od.Kind = b.Namespace, b.Name, b.Kind
|
|
beforeAny = parseObject(b.Object)
|
|
}
|
|
if okA {
|
|
od.Namespace, od.Name, od.Kind = a.Namespace, a.Name, a.Kind
|
|
afterAny = parseObject(a.Object)
|
|
}
|
|
switch {
|
|
case okB && !okA:
|
|
od.Changes = []FieldChange{{Path: "", Removed: true, Before: beforeAny}}
|
|
case !okB && okA:
|
|
od.Changes = []FieldChange{{Path: "", Added: true, After: afterAny}}
|
|
default:
|
|
diffValue("", beforeAny, afterAny, &od.Changes)
|
|
sort.Slice(od.Changes, func(i, j int) bool { return od.Changes[i].Path < od.Changes[j].Path })
|
|
if len(od.Changes) == 0 {
|
|
continue
|
|
}
|
|
}
|
|
res.Changes = append(res.Changes, od)
|
|
}
|
|
|
|
sort.Slice(res.Changes, func(i, j int) bool {
|
|
if res.Changes[i].Namespace != res.Changes[j].Namespace {
|
|
return res.Changes[i].Namespace < res.Changes[j].Namespace
|
|
}
|
|
if res.Changes[i].Name != res.Changes[j].Name {
|
|
return res.Changes[i].Name < res.Changes[j].Name
|
|
}
|
|
return res.Changes[i].Kind < res.Changes[j].Kind
|
|
})
|
|
|
|
if len(gapped) > 0 {
|
|
sort.Strings(gapped)
|
|
res.HasGaps = true
|
|
res.Warning = "coverage incomplete for stream(s): " + strings.Join(gapped, ", ")
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
// DiffObjects computes the field changes between two raw object payloads,
|
|
// ignoring server-owned metadata. It is used for timeline summaries.
|
|
func DiffObjects(before, after json.RawMessage) []FieldChange {
|
|
var out []FieldChange
|
|
if len(before) == 0 && len(after) == 0 {
|
|
return out
|
|
}
|
|
if len(before) == 0 {
|
|
return []FieldChange{{Path: "", Added: true, After: parseObject(after)}}
|
|
}
|
|
if len(after) == 0 {
|
|
return []FieldChange{{Path: "", Removed: true, Before: parseObject(before)}}
|
|
}
|
|
diffValue("", parseObject(before), parseObject(after), &out)
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
|
return out
|
|
}
|
|
|
|
// parseObject decodes a raw object into an arbitrary JSON value.
|
|
func parseObject(raw json.RawMessage) any {
|
|
var v any
|
|
if err := json.Unmarshal(raw, &v); err != nil {
|
|
return string(raw)
|
|
}
|
|
return v
|
|
}
|
|
|
|
// diffValue recurses into before/after and appends field changes at out.
|
|
// Arrays are compared by index; a length change is reported as one change at
|
|
// the array's path. Map objects recurse by key; keys present on only one side
|
|
// are reported as a single Added/Removed change.
|
|
func diffValue(path string, before, after any, out *[]FieldChange) {
|
|
if reflect.DeepEqual(before, after) {
|
|
return
|
|
}
|
|
bm, bOK := before.(map[string]any)
|
|
am, aOK := after.(map[string]any)
|
|
if bOK && aOK {
|
|
keys := map[string]bool{}
|
|
for k := range bm {
|
|
keys[k] = true
|
|
}
|
|
for k := range am {
|
|
keys[k] = true
|
|
}
|
|
sorted := make([]string, 0, len(keys))
|
|
for k := range keys {
|
|
sorted = append(sorted, k)
|
|
}
|
|
sort.Strings(sorted)
|
|
for _, k := range sorted {
|
|
if ignoreKey(path, k) {
|
|
continue
|
|
}
|
|
childPath := joinPath(path, k)
|
|
_, beforeHas := bm[k]
|
|
_, afterHas := am[k]
|
|
switch {
|
|
case beforeHas && !afterHas:
|
|
*out = append(*out, FieldChange{Path: childPath, Before: bm[k], Removed: true})
|
|
case !beforeHas && afterHas:
|
|
*out = append(*out, FieldChange{Path: childPath, After: am[k], Added: true})
|
|
default:
|
|
diffValue(childPath, bm[k], am[k], out)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
bl, bArr := before.([]any)
|
|
al, aArr := after.([]any)
|
|
if bArr && aArr {
|
|
if len(bl) != len(al) {
|
|
*out = append(*out, FieldChange{Path: path, Before: before, After: after})
|
|
return
|
|
}
|
|
for i := range bl {
|
|
diffValue(fmt.Sprintf("%s[%d]", path, i), bl[i], al[i], out)
|
|
}
|
|
return
|
|
}
|
|
*out = append(*out, FieldChange{Path: path, Before: before, After: after})
|
|
}
|
|
|
|
// joinPath builds a dotted path. Segments that could collide with the path
|
|
// syntax ('.', '[', ']', '\') are backslash-escaped.
|
|
func joinPath(parent, key string) string {
|
|
key = escapePathSegment(key)
|
|
if parent == "" {
|
|
return key
|
|
}
|
|
return parent + "." + key
|
|
}
|
|
|
|
func escapePathSegment(k string) string {
|
|
k = strings.ReplaceAll(k, `\`, `\\`)
|
|
k = strings.ReplaceAll(k, `.`, `\.`)
|
|
k = strings.ReplaceAll(k, `[`, `\[`)
|
|
k = strings.ReplaceAll(k, `]`, `\]`)
|
|
return k
|
|
}
|