From 13cda515715dc5bcd344a2a4e362d9d81b820aa4 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Wed, 3 Jun 2026 14:20:52 +0000 Subject: [PATCH 1/6] daemon: add StatusWatcher for fsnotify + polling Add a StatusWatcher component that detects external bootc status changes via fsnotify on /proc/1/root/ostree/bootc (with fallback to /proc/1/root/sysroot/state/deploy for composefs), plus a configurable polling interval as a safety net. Assisted-by: Claude Opus 4.6 (1M context) Signed-off-by: Alice Frosi --- internal/daemon/watcher.go | 135 +++++++++++++++++++++++++++++++ internal/daemon/watcher_test.go | 137 ++++++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 internal/daemon/watcher.go create mode 100644 internal/daemon/watcher_test.go diff --git a/internal/daemon/watcher.go b/internal/daemon/watcher.go new file mode 100644 index 0000000..17d122b --- /dev/null +++ b/internal/daemon/watcher.go @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 + +package daemon + +import ( + "context" + "os" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/go-logr/logr" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + bootcv1alpha1 "github.com/bootc-dev/bootc-operator/api/v1alpha1" + "sigs.k8s.io/controller-runtime/pkg/event" +) + +const ( + // ostree backend + DefaultPrimaryPath = "/proc/1/root/ostree/bootc" + // composefs backend + DefaultFallbackPath = "/proc/1/root/sysroot/state/deploy" +) + +type StatusWatcher struct { + PollInterval time.Duration + PrimaryPath string + FallbackPath string + Events chan event.GenericEvent + NodeName string + Ready chan struct{} +} + +func (w *StatusWatcher) Start(ctx context.Context) error { + log := logf.FromContext(ctx).WithName("status-watcher") + + watchPath := w.resolveWatchPath() + + fsWatcher := w.setupFsnotify(log, watchPath) + + closeFsWatcher := func() { + if fsWatcher != nil { + _ = fsWatcher.Close() + fsWatcher = nil + } + } + defer closeFsWatcher() + + if w.PollInterval <= 0 { + w.PollInterval = 5 * time.Minute + } + + ticker := time.NewTicker(w.PollInterval) + defer ticker.Stop() + + var evCh <-chan fsnotify.Event + var errCh <-chan error + if fsWatcher != nil { + evCh = fsWatcher.Events + errCh = fsWatcher.Errors + } + + if w.Ready != nil { + close(w.Ready) + } + + for { + select { + case <-ctx.Done(): + return nil + case ev := <-evCh: + // bootc updates modify directory mtime, which inotify reports as IN_ATTRIB (Chmod). + if ev.Has(fsnotify.Chmod) { + log.V(1).Info("Detected bootc status change via fsnotify") + w.sendEvent() + } + // Tear down fsnotify so the loop continues with polling only. + // A broken inotify fd never delivers events again, so without this + // the watcher silently stops reacting to filesystem changes. + case err := <-errCh: + log.Error(err, "fsnotify error, degrading to polling only") + closeFsWatcher() + evCh = nil + errCh = nil + case <-ticker.C: + log.V(1).Info("Polling bootc status") + w.sendEvent() + } + } +} + +func (w *StatusWatcher) setupFsnotify(log logr.Logger, watchPath string) *fsnotify.Watcher { + if watchPath == "" { + log.Info("No bootc status path found, using polling only") + return nil + } + + fsWatcher, err := fsnotify.NewWatcher() + if err != nil { + log.Error(err, "Failed to create fsnotify watcher, falling back to polling") + return nil + } + + if err := fsWatcher.Add(watchPath); err != nil { + log.Error(err, "Failed to watch path, falling back to polling", "path", watchPath) + _ = fsWatcher.Close() + return nil + } + + log.Info("Watching path for bootc status changes", "path", watchPath) + return fsWatcher +} + +func (w *StatusWatcher) resolveWatchPath() string { + if _, err := os.Stat(w.PrimaryPath); err == nil { + return w.PrimaryPath + } + if _, err := os.Stat(w.FallbackPath); err == nil { + return w.FallbackPath + } + return "" +} + +func (w *StatusWatcher) sendEvent() { + ev := event.GenericEvent{ + Object: &bootcv1alpha1.BootcNode{ + ObjectMeta: metav1.ObjectMeta{Name: w.NodeName}, + }, + } + select { + case w.Events <- ev: + default: + } +} diff --git a/internal/daemon/watcher_test.go b/internal/daemon/watcher_test.go new file mode 100644 index 0000000..29de499 --- /dev/null +++ b/internal/daemon/watcher_test.go @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 + +package daemon + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "sigs.k8s.io/controller-runtime/pkg/event" +) + +func startWatcher(t *testing.T, w *StatusWatcher) (done <-chan error, cancel context.CancelFunc) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + ch := make(chan error, 1) + go func() { ch <- w.Start(ctx) }() + <-w.Ready + return ch, cancel +} + +func TestWatcherEvents(t *testing.T) { + tests := []struct { + name string + mkPrimary bool + mkFallback bool + touchPrimary bool + touchFallback bool + pollInterval time.Duration + }{ + { + name: "Fsnotify", + mkPrimary: true, + touchPrimary: true, + pollInterval: 10 * time.Minute, + }, + { + name: "FallbackPath", + mkFallback: true, + touchFallback: true, + pollInterval: 10 * time.Minute, + }, + { + name: "PollOnly", + pollInterval: 200 * time.Millisecond, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + primaryPath := filepath.Join(dir, "bootc") + fallbackPath := filepath.Join(dir, "deploy") + + if tt.mkPrimary { + if err := os.Mkdir(primaryPath, 0o755); err != nil { + t.Fatal(err) + } + } + if tt.mkFallback { + if err := os.Mkdir(fallbackPath, 0o755); err != nil { + t.Fatal(err) + } + } + + events := make(chan event.GenericEvent, 1) + w := &StatusWatcher{ + PollInterval: tt.pollInterval, + PrimaryPath: primaryPath, + FallbackPath: fallbackPath, + Events: events, + NodeName: "test-node", + Ready: make(chan struct{}), + } + + done, cancel := startWatcher(t, w) + defer cancel() + + now := time.Now() + if tt.touchPrimary { + if err := os.Chtimes(primaryPath, now, now); err != nil { + t.Fatal(err) + } + } + if tt.touchFallback { + if err := os.Chtimes(fallbackPath, now, now); err != nil { + t.Fatal(err) + } + } + + select { + case ev := <-events: + if ev.Object.GetName() != "test-node" { + t.Errorf("expected node name test-node, got %s", ev.Object.GetName()) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for event") + } + + cancel() + if err := <-done; err != nil { + t.Fatalf("watcher returned error: %v", err) + } + }) + } +} + +func TestWatcherShutdown(t *testing.T) { + dir := t.TempDir() + watchDir := filepath.Join(dir, "bootc") + if err := os.Mkdir(watchDir, 0o755); err != nil { + t.Fatal(err) + } + + w := &StatusWatcher{ + PollInterval: 10 * time.Minute, + PrimaryPath: watchDir, + FallbackPath: filepath.Join(dir, "nonexistent"), + Events: make(chan event.GenericEvent, 1), + NodeName: "test-node", + Ready: make(chan struct{}), + } + + done, cancel := startWatcher(t, w) + cancel() + + select { + case err := <-done: + if err != nil { + t.Fatalf("watcher returned error on shutdown: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for watcher to shut down") + } +} From 48032ff238234f66fcc8c9c0066f8041911b79b9 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Thu, 25 Jun 2026 09:30:28 +0000 Subject: [PATCH 2/6] daemon: make StatusWatcher a bootc status cache Turn the StatusWatcher into an informer-style cache for bootc status. The watcher owns the Executor, refreshes the cached *bootc.Status on fsnotify/poll events, and exposes GetStatus() for consumers. Polling only activates after fsnotify fails. Assisted-by: Claude Opus 4.6 (1M context) Signed-off-by: Alice Frosi --- internal/daemon/watcher.go | 75 +++++++++++++++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 5 deletions(-) diff --git a/internal/daemon/watcher.go b/internal/daemon/watcher.go index 17d122b..03a4d6d 100644 --- a/internal/daemon/watcher.go +++ b/internal/daemon/watcher.go @@ -5,6 +5,7 @@ package daemon import ( "context" "os" + "sync" "time" "github.com/fsnotify/fsnotify" @@ -13,6 +14,7 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" bootcv1alpha1 "github.com/bootc-dev/bootc-operator/api/v1alpha1" + "github.com/bootc-dev/bootc-operator/internal/bootc" "sigs.k8s.io/controller-runtime/pkg/event" ) @@ -29,7 +31,44 @@ type StatusWatcher struct { FallbackPath string Events chan event.GenericEvent NodeName string + Executor bootc.Executor Ready chan struct{} + + mu sync.RWMutex + cached *bootc.Status + // started is set by Start(); lets GetStatus serve from cache vs read live. + started bool +} + +// GetStatus returns the cached bootc status when the watcher loop is +// running (fsnotify or polling keeps the cache fresh). If the watcher +// has not been started, it always reads fresh from the host. +func (w *StatusWatcher) GetStatus(ctx context.Context) (*bootc.Status, error) { + w.mu.RLock() + s := w.cached + started := w.started + w.mu.RUnlock() + if started && s != nil { + return s, nil + } + return w.refresh(ctx) +} + +// refresh reads bootc status from the host and updates the cache. +// Returns the new status or an error. +func (w *StatusWatcher) refresh(ctx context.Context) (*bootc.Status, error) { + data, err := w.Executor.Status(ctx) + if err != nil { + return nil, err + } + s, err := bootc.ParseStatus(data) + if err != nil { + return nil, err + } + w.mu.Lock() + w.cached = s + w.mu.Unlock() + return s, nil } func (w *StatusWatcher) Start(ctx context.Context) error { @@ -51,8 +90,18 @@ func (w *StatusWatcher) Start(ctx context.Context) error { w.PollInterval = 5 * time.Minute } - ticker := time.NewTicker(w.PollInterval) - defer ticker.Stop() + // Only start polling once fsnotify is unavailable. + var ticker *time.Ticker + var tickerCh <-chan time.Time + if fsWatcher == nil { + ticker = time.NewTicker(w.PollInterval) + tickerCh = ticker.C + } + defer func() { + if ticker != nil { + ticker.Stop() + } + }() var evCh <-chan fsnotify.Event var errCh <-chan error @@ -61,6 +110,10 @@ func (w *StatusWatcher) Start(ctx context.Context) error { errCh = fsWatcher.Errors } + w.mu.Lock() + w.started = true + w.mu.Unlock() + if w.Ready != nil { close(w.Ready) } @@ -73,7 +126,7 @@ func (w *StatusWatcher) Start(ctx context.Context) error { // bootc updates modify directory mtime, which inotify reports as IN_ATTRIB (Chmod). if ev.Has(fsnotify.Chmod) { log.V(1).Info("Detected bootc status change via fsnotify") - w.sendEvent() + w.refreshAndNotify(ctx, log) } // Tear down fsnotify so the loop continues with polling only. // A broken inotify fd never delivers events again, so without this @@ -83,13 +136,25 @@ func (w *StatusWatcher) Start(ctx context.Context) error { closeFsWatcher() evCh = nil errCh = nil - case <-ticker.C: + ticker = time.NewTicker(w.PollInterval) + tickerCh = ticker.C + case <-tickerCh: log.V(1).Info("Polling bootc status") - w.sendEvent() + w.refreshAndNotify(ctx, log) } } } +// refreshAndNotify reads bootc status, updates the cache, and sends +// an event to trigger reconciliation. +func (w *StatusWatcher) refreshAndNotify(ctx context.Context, log logr.Logger) { + if _, err := w.refresh(ctx); err != nil { + log.Error(err, "Failed to refresh bootc status") + return + } + w.sendEvent() +} + func (w *StatusWatcher) setupFsnotify(log logr.Logger, watchPath string) *fsnotify.Watcher { if watchPath == "" { log.Info("No bootc status path found, using polling only") From 5f3adda07b6c428512c233f4f8d7e03fb0e5ed16 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Thu, 25 Jun 2026 09:39:28 +0000 Subject: [PATCH 3/6] daemon: add StatusWatcher cache tests Test warm cache, cold cache, poll deferral, and refactor existing tests to use a shared newTestWatcher helper. Assisted-by: Claude Opus 4.6 (1M context) Signed-off-by: Alice Frosi --- internal/daemon/watcher_test.go | 130 +++++++++++++++++++++++++++----- 1 file changed, 112 insertions(+), 18 deletions(-) diff --git a/internal/daemon/watcher_test.go b/internal/daemon/watcher_test.go index 29de499..b1608e0 100644 --- a/internal/daemon/watcher_test.go +++ b/internal/daemon/watcher_test.go @@ -10,6 +10,8 @@ import ( "time" "sigs.k8s.io/controller-runtime/pkg/event" + + testutil "github.com/bootc-dev/bootc-operator/test/util" ) func startWatcher(t *testing.T, w *StatusWatcher) (done <-chan error, cancel context.CancelFunc) { @@ -21,6 +23,20 @@ func startWatcher(t *testing.T, w *StatusWatcher) (done <-chan error, cancel con return ch, cancel } +func newTestWatcher(primaryPath, fallbackPath string, pollInterval time.Duration) *StatusWatcher { + f := &fakeExecutor{} + f.status = newBootcStatus(testutil.DigestA) + return &StatusWatcher{ + PollInterval: pollInterval, + PrimaryPath: primaryPath, + FallbackPath: fallbackPath, + Events: make(chan event.GenericEvent, 1), + NodeName: "test-node", + Executor: f, + Ready: make(chan struct{}), + } +} + func TestWatcherEvents(t *testing.T) { tests := []struct { name string @@ -65,15 +81,7 @@ func TestWatcherEvents(t *testing.T) { } } - events := make(chan event.GenericEvent, 1) - w := &StatusWatcher{ - PollInterval: tt.pollInterval, - PrimaryPath: primaryPath, - FallbackPath: fallbackPath, - Events: events, - NodeName: "test-node", - Ready: make(chan struct{}), - } + w := newTestWatcher(primaryPath, fallbackPath, tt.pollInterval) done, cancel := startWatcher(t, w) defer cancel() @@ -91,7 +99,7 @@ func TestWatcherEvents(t *testing.T) { } select { - case ev := <-events: + case ev := <-w.Events: if ev.Object.GetName() != "test-node" { t.Errorf("expected node name test-node, got %s", ev.Object.GetName()) } @@ -107,6 +115,72 @@ func TestWatcherEvents(t *testing.T) { } } +func TestWatcherCachesStatus(t *testing.T) { + dir := t.TempDir() + w := newTestWatcher(filepath.Join(dir, "nonexistent"), filepath.Join(dir, "nonexistent2"), 200*time.Millisecond) + + done, cancel := startWatcher(t, w) + defer cancel() + + // Wait for the poll to fire and populate the cache. + select { + case <-w.Events: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for event") + } + + status, err := w.GetStatus(context.Background()) + if err != nil { + t.Fatalf("GetStatus returned error: %v", err) + } + if status.Status.Booted == nil || status.Status.Booted.Image == nil { + t.Fatal("expected booted entry in cached status") + } + if status.Status.Booted.Image.ImageDigest != testutil.DigestA { + t.Errorf("expected digest %s, got %s", testutil.DigestA, status.Status.Booted.Image.ImageDigest) + } + + // Change the executor's data to simulate a stale cache where + // fsnotify hasn't fired yet. GetStatus must still return the + // cached (old) value, proving it serves from cache. + f := w.Executor.(*fakeExecutor) + f.mu.Lock() + f.status = newBootcStatus(testutil.DigestB) + f.mu.Unlock() + + status, err = w.GetStatus(context.Background()) + if err != nil { + t.Fatalf("GetStatus returned error after executor change: %v", err) + } + if status.Status.Booted.Image.ImageDigest != testutil.DigestA { + t.Errorf("expected cached digest %s, got %s", testutil.DigestA, status.Status.Booted.Image.ImageDigest) + } + + cancel() + if err := <-done; err != nil { + t.Fatalf("watcher returned error: %v", err) + } +} + +func TestWatcherGetStatusColdCache(t *testing.T) { + f := &fakeExecutor{} + f.status = newBootcStatus(testutil.DigestA) + + w := &StatusWatcher{ + Events: make(chan event.GenericEvent, 1), + NodeName: "test-node", + Executor: f, + } + + status, err := w.GetStatus(context.Background()) + if err != nil { + t.Fatalf("GetStatus returned error: %v", err) + } + if status.Status.Booted == nil || status.Status.Booted.Image == nil || status.Status.Booted.Image.ImageDigest != testutil.DigestA { + t.Fatalf("expected booted digest %s", testutil.DigestA) + } +} + func TestWatcherShutdown(t *testing.T) { dir := t.TempDir() watchDir := filepath.Join(dir, "bootc") @@ -114,14 +188,7 @@ func TestWatcherShutdown(t *testing.T) { t.Fatal(err) } - w := &StatusWatcher{ - PollInterval: 10 * time.Minute, - PrimaryPath: watchDir, - FallbackPath: filepath.Join(dir, "nonexistent"), - Events: make(chan event.GenericEvent, 1), - NodeName: "test-node", - Ready: make(chan struct{}), - } + w := newTestWatcher(watchDir, filepath.Join(dir, "nonexistent"), 10*time.Minute) done, cancel := startWatcher(t, w) cancel() @@ -135,3 +202,30 @@ func TestWatcherShutdown(t *testing.T) { t.Fatal("timed out waiting for watcher to shut down") } } + +func TestWatcherNoPollWhenFsnotifyHealthy(t *testing.T) { + dir := t.TempDir() + primaryPath := filepath.Join(dir, "bootc") + if err := os.Mkdir(primaryPath, 0o755); err != nil { + t.Fatal(err) + } + + w := newTestWatcher(primaryPath, filepath.Join(dir, "nonexistent"), 200*time.Millisecond) + + done, cancel := startWatcher(t, w) + defer cancel() + + // With fsnotify healthy and no filesystem events, the short poll + // interval should NOT fire (polling is deferred until fsnotify fails). + select { + case <-w.Events: + t.Fatal("received unexpected poll event while fsnotify is healthy") + case <-time.After(500 * time.Millisecond): + // expected: no events + } + + cancel() + if err := <-done; err != nil { + t.Fatalf("watcher returned error: %v", err) + } +} From a3fac571c8cace3f8bd14195bf7cffd9c4b6035b Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Thu, 25 Jun 2026 09:44:36 +0000 Subject: [PATCH 4/6] daemon: wire StatusWatcher cache into reconciler The reconciler reads bootc status from the watcher's cache via GetStatus() instead of shelling out on every reconcile. Assisted-by: Claude Opus 4.6 (1M context) Signed-off-by: Alice Frosi --- cmd/daemon/main.go | 31 ++++++++++++++++++++++++++----- internal/daemon/reconciler.go | 19 ++++++++----------- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index f0ee954..d865cf6 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -6,6 +6,7 @@ import ( "flag" "fmt" "os" + "time" "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/runtime" @@ -14,6 +15,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/log/zap" bootcv1alpha1 "github.com/bootc-dev/bootc-operator/api/v1alpha1" @@ -32,6 +34,9 @@ func init() { } func main() { + var pollInterval time.Duration + flag.DurationVar(&pollInterval, "bootc-poll-interval", 5*time.Minute, "Interval for polling bootc status as a fallback to fsnotify") + opts := zap.Options{ Development: true, } @@ -62,17 +67,33 @@ func main() { os.Exit(1) } + executor := bootc.NewHostExecutor() + + watcher := &daemon.StatusWatcher{ + PollInterval: pollInterval, + PrimaryPath: daemon.DefaultPrimaryPath, + FallbackPath: daemon.DefaultFallbackPath, + Events: make(chan event.GenericEvent, 1), + NodeName: nodeName, + Executor: executor, + } + if err := mgr.Add(watcher); err != nil { + setupLog.Error(err, "Failed to add status watcher") + os.Exit(1) + } + if err := (&daemon.BootcNodeReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - NodeName: nodeName, - Executor: bootc.NewHostExecutor(), + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + NodeName: nodeName, + Executor: executor, + StatusWatcher: watcher, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "Failed to create controller", "controller", "bootcnode") os.Exit(1) } - setupLog.Info("Starting daemon", "node", nodeName) + setupLog.Info("Starting daemon", "node", nodeName, "pollInterval", pollInterval) if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { setupLog.Error(err, "Failed to run daemon") os.Exit(1) diff --git a/internal/daemon/reconciler.go b/internal/daemon/reconciler.go index d86e697..a543446 100644 --- a/internal/daemon/reconciler.go +++ b/internal/daemon/reconciler.go @@ -46,13 +46,14 @@ type stageOp struct { } // BootcNodeReconciler reconciles the BootcNode for the node this daemon -// runs on. It reads bootc status from the host, detects image mismatches, -// and drives updates via bootc stage. +// runs on. It reads bootc status from the watcher's cache, detects image +// mismatches, and drives updates via bootc stage. type BootcNodeReconciler struct { client.Client - Scheme *runtime.Scheme - NodeName string - Executor bootc.Executor + Scheme *runtime.Scheme + NodeName string + Executor bootc.Executor + StatusWatcher *StatusWatcher inflight stageOp stageDone chan event.GenericEvent @@ -67,6 +68,7 @@ func (r *BootcNodeReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&bootcv1alpha1.BootcNode{}). WatchesRawSource(source.Channel(r.stageDone, &handler.EnqueueRequestForObject{})). + WatchesRawSource(source.Channel(r.StatusWatcher.Events, &handler.EnqueueRequestForObject{})). Named("bootcnode"). Complete(r) } @@ -305,16 +307,11 @@ func (s *stageOp) run(ctx context.Context, nodeName, image string, executor boot } func (r *BootcNodeReconciler) populateBootcFields(ctx context.Context, bn *bootcv1alpha1.BootcNode) error { - data, err := r.Executor.Status(ctx) + status, err := r.StatusWatcher.GetStatus(ctx) if err != nil { return fmt.Errorf("getting bootc status: %w", err) } - status, err := bootc.ParseStatus(data) - if err != nil { - return fmt.Errorf("failed to parse bootc status: %w", err) - } - bn.Status.Booted = convertBootEntry(status.Status.Booted) bn.Status.Staged = convertBootEntry(status.Status.Staged) bn.Status.Rollback = convertBootEntry(status.Status.Rollback) From a676abe51e6d5d9260b1570ec7b13077aa3ef0df Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Thu, 25 Jun 2026 09:44:40 +0000 Subject: [PATCH 5/6] daemon: update reconciler tests for StatusWatcher cache Each unit test creates its own watcher with a fresh cache via newTestEnv() to avoid test pollution. Assisted-by: Claude Opus 4.6 (1M context) Signed-off-by: Alice Frosi --- internal/daemon/reconciler_test.go | 16 ++++++++------- internal/daemon/suite_test.go | 33 +++++++++++++++++++++++------- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/internal/daemon/reconciler_test.go b/internal/daemon/reconciler_test.go index e5a5c8d..6fabcfb 100644 --- a/internal/daemon/reconciler_test.go +++ b/internal/daemon/reconciler_test.go @@ -32,6 +32,8 @@ func TestReconcilePopulatesStatus(t *testing.T) { g.SetDefaultEventuallyPollingInterval(pollInterval) ctx := context.Background() + fake := newTestEnv() + v1 := "v1" v2 := "v2" v3 := "v3" @@ -99,7 +101,7 @@ func TestReconcileBootcStatusError(t *testing.T) { g.SetDefaultEventuallyPollingInterval(pollInterval) ctx := context.Background() - fake.reset() + fake := newTestEnv() fake.setStatusErr(errors.New(bootcStatusErrMsg)) bn := testutil.NewNode(testNodeName, testutil.ImageDigestRefA) @@ -126,7 +128,7 @@ func TestStagingTriggered(t *testing.T) { g.SetDefaultEventuallyPollingInterval(pollInterval) ctx := context.Background() - fake.reset() + fake := newTestEnv() fake.status = newBootcStatus(testutil.DigestA) bn := testutil.NewNode(testNodeName, testutil.ImageDigestRefB) @@ -163,7 +165,7 @@ func TestStagingError(t *testing.T) { g.SetDefaultEventuallyPollingInterval(pollInterval) ctx := context.Background() - fake.reset() + fake := newTestEnv() fake.status = newBootcStatus(testutil.DigestA) fake.setStageErr(errors.New(stageErrMsg)) @@ -196,7 +198,7 @@ func TestAlreadyStaged(t *testing.T) { g.SetDefaultEventuallyPollingInterval(pollInterval) ctx := context.Background() - fake.reset() + fake := newTestEnv() fake.status = newBootcStatus(testutil.DigestA) fake.status.Status.Staged = newBootEntry(testutil.ImageDigestRefB, testutil.DigestB) @@ -225,7 +227,7 @@ func TestRebootingSet(t *testing.T) { g.SetDefaultEventuallyPollingInterval(pollInterval) ctx := context.Background() - fake.reset() + fake := newTestEnv() fake.status = newBootcStatus(testutil.DigestA) fake.status.Status.Staged = newBootEntry(testutil.ImageDigestRefB, testutil.DigestB) @@ -254,7 +256,7 @@ func TestRollback(t *testing.T) { g.SetDefaultEventuallyPollingInterval(pollInterval) ctx := context.Background() - fake.reset() + fake := newTestEnv() fake.status = newBootcStatus(testutil.DigestA) fake.status.Status.Staged = newBootEntry(testutil.ImageDigestRefB, testutil.DigestB) @@ -285,7 +287,7 @@ func TestCancelInflightStage(t *testing.T) { g.SetDefaultEventuallyPollingInterval(pollInterval) ctx := context.Background() - fake.reset() + fake := newTestEnv() fake.status = newBootcStatus(testutil.DigestA) firstBlock := make(chan struct{}) diff --git a/internal/daemon/suite_test.go b/internal/daemon/suite_test.go index f2cf140..96f8c7c 100644 --- a/internal/daemon/suite_test.go +++ b/internal/daemon/suite_test.go @@ -13,6 +13,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/envtest" + "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" @@ -22,9 +23,9 @@ import ( const testNodeName = "test-node" var ( - testEnv *envtest.Environment - k8sClient client.Client - fake *fakeExecutor + testEnv *envtest.Environment + k8sClient client.Client + testReconciler *BootcNodeReconciler ) func TestMain(m *testing.M) { @@ -52,8 +53,6 @@ func TestMain(m *testing.M) { os.Exit(1) } - fake = &fakeExecutor{} - mgr, err := ctrl.NewManager(cfg, ctrl.Options{ Scheme: scheme.Scheme, Metrics: metricsserver.Options{ @@ -65,12 +64,19 @@ func TestMain(m *testing.M) { os.Exit(1) } - if err := (&BootcNodeReconciler{ + fake := &fakeExecutor{} + testReconciler = &BootcNodeReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), NodeName: testNodeName, Executor: fake, - }).SetupWithManager(mgr); err != nil { + StatusWatcher: &StatusWatcher{ + Events: make(chan event.GenericEvent, 1), + NodeName: testNodeName, + Executor: fake, + }, + } + if err := testReconciler.SetupWithManager(mgr); err != nil { fmt.Fprintf(os.Stderr, "Failed to setup reconciler: %v\n", err) os.Exit(1) } @@ -97,3 +103,16 @@ func TestMain(m *testing.M) { os.Exit(code) } + +// newTestEnv creates a fresh fakeExecutor and StatusWatcher for a test, +// wiring them into the shared reconciler. Each test gets its own cache. +func newTestEnv() *fakeExecutor { + fake := &fakeExecutor{} + testReconciler.Executor = fake + testReconciler.StatusWatcher = &StatusWatcher{ + Events: testReconciler.StatusWatcher.Events, + NodeName: testNodeName, + Executor: fake, + } + return fake +} From 452f341ca5044714adce5a510619d816930b0e91 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Thu, 25 Jun 2026 11:04:42 +0000 Subject: [PATCH 6/6] daemon: remove unused fakeExecutor.reset method Assisted-by: Claude Opus 4.6 (1M context) Signed-off-by: Alice Frosi --- internal/daemon/fake_test.go | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/internal/daemon/fake_test.go b/internal/daemon/fake_test.go index bc192ec..5f5f7b5 100644 --- a/internal/daemon/fake_test.go +++ b/internal/daemon/fake_test.go @@ -94,17 +94,6 @@ func (f *fakeExecutor) getRebooted() bool { return f.rebooted } -func (f *fakeExecutor) reset() { - f.mu.Lock() - defer f.mu.Unlock() - f.status = bootc.Status{} - f.statusErr = nil - f.stageErr = nil - f.stageImg = "" - f.stageHook = nil - f.rebooted = false -} - func newBootEntry(image, digest string) *bootc.BootEntry { return &bootc.BootEntry{ Image: &bootc.ImageStatus{