perf(controller): repair mirror drift from watch events instead of resync

Mirror update events were filtered out, so an in-place edit of a
mirrored copy was invisible to the controller. The periodic resync did
not catch it either: NeedsSync only compares the source content hash
with the recorded annotation, and tampered mirror data leaves both
unchanged.

Changes:
- The mirror watch now passes real update events (resourceVersion
  changed, object is a managed mirror) and maps them to the source.
  Informer resync replays keep an unchanged resourceVersion and stay
  filtered.
- reconcileMirror detects in-place drift by rebuilding the expected
  mirror in memory and comparing content hashes. Metadata differences
  never count as drift, so the controller's own writes converge in one
  no-op pass.
- The resync-period default moves from 10m to 1h. Watches now cover
  source changes, mirror deletion, mirror drift, and namespace changes,
  so the resync is only a safety net for missed watch events. The chart
  default moves with it.
- Add predicate and tamper-repair regression tests; drift repair is
  verified to make no direct apiserver reads.
This commit is contained in:
2026-08-29 13:44:57 +01:00
parent eaa1246fb7
commit 8988893160
6 changed files with 147 additions and 19 deletions
+73
View File
@@ -18,6 +18,7 @@ import (
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/event"
"github.com/lukaszraczylo/kubemirror/pkg/config"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
@@ -1364,3 +1365,75 @@ func TestSourceReconciler_getSourceWithFreshness_DefaultNoDirectRead(t *testing.
assert.Equal(t, "test-secret", got.GetName())
assert.Zero(t, reader.gets, "default config must not verify freshness against the apiserver")
}
// TestMirrorEventPredicate locks the mirror-watch gating: real writes to a
// mirror enqueue its source for drift repair, while informer resync replays
// (unchanged resourceVersion), non-mirror objects, and creates stay silent.
func TestMirrorEventPredicate(t *testing.T) {
source := newMirrorTestSource(t)
mirrorObj, err := CreateMirror(source, "target-ns")
require.NoError(t, err)
mirror, ok := mirrorObj.(*unstructured.Unstructured)
require.True(t, ok)
mirror.SetResourceVersion("100")
changed := mirror.DeepCopy()
changed.SetResourceVersion("101")
nonMirrorOld := source.DeepCopy()
nonMirrorOld.SetResourceVersion("1")
nonMirrorNew := source.DeepCopy()
nonMirrorNew.SetResourceVersion("2")
p := mirrorEventPredicate()
assert.False(t, p.Create(event.CreateEvent{Object: mirror}),
"mirror create must not enqueue")
assert.True(t, p.Update(event.UpdateEvent{ObjectOld: mirror, ObjectNew: changed}),
"real mirror write must enqueue the source for drift repair")
assert.False(t, p.Update(event.UpdateEvent{ObjectOld: mirror, ObjectNew: mirror}),
"informer resync replay with unchanged resourceVersion must not enqueue")
assert.False(t, p.Update(event.UpdateEvent{ObjectOld: nonMirrorOld, ObjectNew: nonMirrorNew}),
"non-mirror update must not enqueue")
assert.True(t, p.Delete(event.DeleteEvent{Object: mirror}),
"mirror delete must enqueue the source for recreation")
assert.False(t, p.Delete(event.DeleteEvent{Object: source}),
"non-mirror delete must not enqueue")
}
// TestSourceReconciler_reconcileMirror_RepairsDriftedMirror covers in-place
// mirror tampering: the mirrored data is edited but the bookkeeping
// annotations are left intact, so the source-hash check alone would consider
// the mirror up to date. The reconciler must detect the content divergence
// and rewrite the mirror — still without any direct API reads.
func TestSourceReconciler_reconcileMirror_RepairsDriftedMirror(t *testing.T) {
source := newMirrorTestSource(t)
mirrorObj, err := CreateMirror(source, "target-ns")
require.NoError(t, err)
mirror, ok := mirrorObj.(*unstructured.Unstructured)
require.True(t, ok)
mirror.SetResourceVersion("100")
// Tamper with the mirrored content in place; annotations stay intact.
require.NoError(t, unstructured.SetNestedStringMap(mirror.Object,
map[string]string{"tampered-field": "dGFtcGVyZWQ="}, "data"))
mockClient := new(MockClient)
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "target-ns", Name: "test-secret"}, mock.Anything).
Return(nil, mirror)
mockClient.On("Update", mock.Anything, mock.Anything, mock.Anything).Return(nil)
reader := &countingReader{}
r := &SourceReconciler{
Client: mockClient,
APIReader: reader,
Config: &config.Config{},
GVK: source.GroupVersionKind(),
}
require.NoError(t, r.reconcileMirror(context.Background(), source, source, "target-ns"))
mockClient.AssertCalled(t, "Update", mock.Anything, mock.Anything, mock.Anything)
assert.Zero(t, reader.gets, "drift repair must not read from the apiserver directly")
}