From 19a1773d2e512972d4b5bba1a3ad80c2b6964043 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Wed, 5 Aug 2026 14:49:56 +0200 Subject: [PATCH 01/11] controller: return the rollout state results Refactor the function driveRollout to return the rolloutState result. In this way, they can be used to aggregate and set the status in the pool in the next commits. Assisted-by: AI Signed-off-by: Alice Frosi --- internal/controller/bootcnodepool_controller.go | 3 ++- internal/controller/rollout.go | 16 ++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/internal/controller/bootcnodepool_controller.go b/internal/controller/bootcnodepool_controller.go index a859bd3..32f82be 100644 --- a/internal/controller/bootcnodepool_controller.go +++ b/internal/controller/bootcnodepool_controller.go @@ -287,7 +287,8 @@ func (r *BootcNodePoolReconciler) Reconcile(ctx context.Context, req ctrl.Reques // reconciliation run. // Drive the rollout state machine. - if err := r.driveRollout(ctx, &pool, ownedBootcNodes); err != nil { + _, err = r.driveRollout(ctx, &pool, ownedBootcNodes) + if err != nil { if isInvalidSpecError(err) { return r.setInvalidSpecCondition(ctx, &pool, err) } diff --git a/internal/controller/rollout.go b/internal/controller/rollout.go index 0ccbb58..17c9d1e 100644 --- a/internal/controller/rollout.go +++ b/internal/controller/rollout.go @@ -57,13 +57,13 @@ func (rs *rolloutState) nodeCount() int { } // driveRollout is the main function that advances the rollout state machine. -func (r *BootcNodePoolReconciler) driveRollout(ctx context.Context, pool *bootcv1alpha1.BootcNodePool, ownedBootcNodes map[string]*bootcv1alpha1.BootcNode) error { +func (r *BootcNodePoolReconciler) driveRollout(ctx context.Context, pool *bootcv1alpha1.BootcNodePool, ownedBootcNodes map[string]*bootcv1alpha1.BootcNode) (*rolloutState, error) { log := logf.FromContext(ctx) // Process drain results first. This isn't really ordering dependent, // but it feels natural to do this upfront before classifying. if err := r.collectDrainResults(ctx, ownedBootcNodes); err != nil { - return fmt.Errorf("collecting drain results: %w", err) + return nil, fmt.Errorf("collecting drain results: %w", err) } rs := buildRolloutState(log, ownedBootcNodes) @@ -80,7 +80,7 @@ func (r *BootcNodePoolReconciler) driveRollout(ctx context.Context, pool *bootcv // the desired image. This runs before computing available slots so // that freed capacity is immediately usable for new candidates. if err := r.freeCompletedSlots(ctx, rs); err != nil { - return fmt.Errorf("freeing completed slots: %w", err) + return nil, fmt.Errorf("freeing completed slots: %w", err) } // Check for unhealthy nodes on the target digest in reboot slots. If @@ -107,12 +107,12 @@ func (r *BootcNodePoolReconciler) driveRollout(ctx context.Context, pool *bootcv // reconciles will still have their results collected and // desiredImageState set to Booted. Trying to "un-drain" and // uncordon fully drained nodes is out of scope for now. - return nil + return rs, nil } maxUnavail, err := resolveMaxUnavailable(pool, rs.nodeCount()) if err != nil { - return err + return nil, err } availableSlots := max(0, maxUnavail-rs.occupiedSlots) @@ -136,10 +136,10 @@ func (r *BootcNodePoolReconciler) driveRollout(ctx context.Context, pool *bootcv for _, bn := range candidates { var node corev1.Node if err := r.Get(ctx, types.NamespacedName{Name: bn.Name}, &node); err != nil { - return fmt.Errorf("fetching node %s: %w", bn.Name, err) + return nil, fmt.Errorf("fetching node %s: %w", bn.Name, err) } if err := r.assignRebootSlot(ctx, bn, &node); err != nil { - return fmt.Errorf("assigning reboot slot to %s: %w", bn.Name, err) + return nil, fmt.Errorf("assigning reboot slot to %s: %w", bn.Name, err) } } @@ -155,7 +155,7 @@ func (r *BootcNodePoolReconciler) driveRollout(ctx context.Context, pool *bootcv r.ensureDrain(ctx, pool, bn) } - return nil + return rs, nil } // assignRebootSlot marks a BootcNode as occupying a reboot slot and From 329daa954688e5d6bcecb0ef9c775807003dd3c2 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Wed, 5 Aug 2026 13:14:20 +0000 Subject: [PATCH 02/11] controller: add pool status aggregation logic Add helped functions to populate the pool status. This commit adds also the ObservedGeneration which was missing. Fixes: 35 Assisted-by: AI Signed-off-by: Alice Frosi --- internal/controller/status.go | 87 +++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 internal/controller/status.go diff --git a/internal/controller/status.go b/internal/controller/status.go new file mode 100644 index 0000000..62eec56 --- /dev/null +++ b/internal/controller/status.go @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "fmt" + "strings" + + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + bootcv1alpha1 "github.com/bootc-dev/bootc-operator/api/v1alpha1" +) + +// syncPoolStatus populates the pool's status counts, digest tracking, +// and UpToDate condition from the current rollout state. +func syncPoolStatus(pool *bootcv1alpha1.BootcNodePool, rs *rolloutState) { + if pool == nil || rs == nil { + return + } + pool.Status.ObservedGeneration = pool.Generation + pool.Status.NodeCount = int32(rs.nodeCount()) + pool.Status.UpdatedCount = int32(len(rs.upToDate)) + pool.Status.UpdatingCount = int32(len(rs.pending) + len(rs.staging) + len(rs.staged) + len(rs.rebooting)) + pool.Status.DegradedCount = int32(len(rs.degraded)) + + if pool.Status.NodeCount == pool.Status.UpdatedCount { + pool.Status.DeployedDigest = pool.Status.TargetDigest + } + pool.Status.UpdateAvailable = pool.Status.TargetDigest != pool.Status.DeployedDigest + + syncUpToDateCondition(pool, rs) +} + +func syncUpToDateCondition(pool *bootcv1alpha1.BootcNodePool, rs *rolloutState) { + if pool == nil || rs == nil { + return + } + switch { + case pool.Spec.Rollout != nil && pool.Spec.Rollout.Paused && pool.Status.NodeCount != pool.Status.UpdatedCount: + apimeta.SetStatusCondition(&pool.Status.Conditions, metav1.Condition{ + Type: bootcv1alpha1.PoolUpToDate, + Status: metav1.ConditionFalse, + Reason: bootcv1alpha1.PoolPaused, + Message: rolloutBreakdown(pool, rs), + }) + case pool.Status.NodeCount != pool.Status.UpdatedCount: + apimeta.SetStatusCondition(&pool.Status.Conditions, metav1.Condition{ + Type: bootcv1alpha1.PoolUpToDate, + Status: metav1.ConditionFalse, + Reason: bootcv1alpha1.PoolRolloutInProgress, + Message: rolloutBreakdown(pool, rs), + }) + default: + apimeta.SetStatusCondition(&pool.Status.Conditions, metav1.Condition{ + Type: bootcv1alpha1.PoolUpToDate, + Status: metav1.ConditionTrue, + Reason: bootcv1alpha1.PoolAllUpdated, + }) + } +} + +func rolloutBreakdown(pool *bootcv1alpha1.BootcNodePool, rs *rolloutState) string { + var b strings.Builder + fmt.Fprintf(&b, "%d/%d updated", len(rs.upToDate), rs.nodeCount()) + + type bucket struct { + name string + count int + } + buckets := []bucket{ + {"pending", len(rs.pending)}, + {"staging", len(rs.staging)}, + {"staged", len(rs.staged)}, + {"rebooting", len(rs.rebooting)}, + {"degraded", len(rs.degraded)}, + } + + b.WriteString("; ") + for i, bk := range buckets { + if i > 0 { + b.WriteString(", ") + } + fmt.Fprintf(&b, "%d %s", bk.count, bk.name) + } + return b.String() +} From eb81c8b57a9fd97d81d88d11166340b0e5fdc186 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Wed, 5 Aug 2026 13:26:03 +0000 Subject: [PATCH 03/11] controller: wire syncPoolStatus into Reconcile Call the syncPoolStatus in the reconcile loop. Assisted-by: AI Signed-off-by: Alice Frosi --- internal/controller/bootcnodepool_controller.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/controller/bootcnodepool_controller.go b/internal/controller/bootcnodepool_controller.go index 32f82be..59eb7fe 100644 --- a/internal/controller/bootcnodepool_controller.go +++ b/internal/controller/bootcnodepool_controller.go @@ -287,7 +287,7 @@ func (r *BootcNodePoolReconciler) Reconcile(ctx context.Context, req ctrl.Reques // reconciliation run. // Drive the rollout state machine. - _, err = r.driveRollout(ctx, &pool, ownedBootcNodes) + rs, err := r.driveRollout(ctx, &pool, ownedBootcNodes) if err != nil { if isInvalidSpecError(err) { return r.setInvalidSpecCondition(ctx, &pool, err) @@ -295,6 +295,11 @@ func (r *BootcNodePoolReconciler) Reconcile(ctx context.Context, req ctrl.Reques return ctrl.Result{}, fmt.Errorf("driving rollout: %w", err) } + // Early-return paths above (TargetDigest empty, InvalidSpec) skip + // aggregation. In-flight updates may complete during error conditions + // but counts catch up on the next successful reconcile. + syncPoolStatus(&pool, rs) + return complete(resolveResult) } From 8722d7c40d084b9aa437f964b6183a224418dd2f Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Wed, 5 Aug 2026 13:38:44 +0000 Subject: [PATCH 04/11] tests: add unit tests for syncPoolStatus Each test sets the pool result and then calls the syncPoolStatus and check if the pool status contains the correct values and condition. Tests: - TestSyncPoolStatusAllUpdated - TestSyncPoolStatusRolloutInProgress - TestSyncPoolStatusPaused - TestSyncPoolStatusEmptyPool - TestSyncPoolStatusDeployedDigestPreserved Assisted-by: AI Signed-off-by: Alice Frosi --- internal/controller/status_test.go | 161 +++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 internal/controller/status_test.go diff --git a/internal/controller/status_test.go b/internal/controller/status_test.go new file mode 100644 index 0000000..4402401 --- /dev/null +++ b/internal/controller/status_test.go @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "testing" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + bootcv1alpha1 "github.com/bootc-dev/bootc-operator/api/v1alpha1" + testutil "github.com/bootc-dev/bootc-operator/test/util" +) + +// Verifies that when all nodes are running the target digest, the counts +// are correct, UpToDate is True/AllUpdated, and deployedDigest is set. +func TestSyncPoolStatusAllUpdated(t *testing.T) { + g := NewWithT(t) + + pool := testutil.NewPool("test", testImageDigestRefA, testutil.WithWorkerSelector()) + pool.Status.TargetDigest = testDigestA + + rs := &rolloutState{ + upToDate: []*bootcv1alpha1.BootcNode{ + testutil.NewNode("n1", testImageDigestRefA, testutil.WithBootedDigest(testDigestA)), + testutil.NewNode("n2", testImageDigestRefA, testutil.WithBootedDigest(testDigestA)), + testutil.NewNode("n3", testImageDigestRefA, testutil.WithBootedDigest(testDigestA)), + }, + } + + syncPoolStatus(pool, rs) + + g.Expect(pool.Status.NodeCount).To(Equal(int32(3))) + g.Expect(pool.Status.UpdatedCount).To(Equal(int32(3))) + g.Expect(pool.Status.UpdatingCount).To(Equal(int32(0))) + g.Expect(pool.Status.DegradedCount).To(Equal(int32(0))) + g.Expect(pool.Status.DeployedDigest).To(Equal(testDigestA)) + g.Expect(pool.Status.UpdateAvailable).To(BeFalse()) + g.Expect(pool.Status.Conditions).To(ContainElement(And( + HaveField("Type", bootcv1alpha1.PoolUpToDate), + HaveField("Status", metav1.ConditionTrue), + HaveField("Reason", bootcv1alpha1.PoolAllUpdated), + ))) +} + +// Verifies that a rollout in progress with nodes in every bucket produces +// the correct counts, UpToDate False/RolloutInProgress, and a breakdown message. +func TestSyncPoolStatusRolloutInProgress(t *testing.T) { + g := NewWithT(t) + + pool := testutil.NewPool("test", testImageDigestRefA, testutil.WithWorkerSelector()) + pool.Status.TargetDigest = testDigestA + + rs := &rolloutState{ + upToDate: []*bootcv1alpha1.BootcNode{ + testutil.NewNode("n1", testImageDigestRefA, testutil.WithBootedDigest(testDigestA)), + }, + pending: []*bootcv1alpha1.BootcNode{ + testutil.NewNode("n2", testImageDigestRefA, testutil.WithBootedDigest(testDigestB)), + }, + staging: []*bootcv1alpha1.BootcNode{ + testutil.NewNode("n3", testImageDigestRefA, testutil.WithBootedDigest(testDigestB)), + }, + staged: []*bootcv1alpha1.BootcNode{ + testutil.NewNode("n4", testImageDigestRefA, testutil.WithBootedDigest(testDigestB)), + }, + rebooting: []*bootcv1alpha1.BootcNode{ + testutil.NewNode("n5", testImageDigestRefA, testutil.WithBootedDigest(testDigestB)), + }, + } + + syncPoolStatus(pool, rs) + + g.Expect(pool.Status.NodeCount).To(Equal(int32(5))) + g.Expect(pool.Status.UpdatedCount).To(Equal(int32(1))) + g.Expect(pool.Status.UpdatingCount).To(Equal(int32(4))) + g.Expect(pool.Status.DegradedCount).To(Equal(int32(0))) + g.Expect(pool.Status.Conditions).To(ContainElement(And( + HaveField("Type", bootcv1alpha1.PoolUpToDate), + HaveField("Status", metav1.ConditionFalse), + HaveField("Reason", bootcv1alpha1.PoolRolloutInProgress), + HaveField("Message", Equal("1/5 updated; 1 pending, 1 staging, 1 staged, 1 rebooting, 0 degraded")), + ))) +} + +// Verifies that a paused pool with pending nodes reports UpToDate +// False/Paused and includes the breakdown message. +func TestSyncPoolStatusPaused(t *testing.T) { + g := NewWithT(t) + + pool := testutil.NewPool("test", testImageDigestRefA, testutil.WithWorkerSelector(), testutil.WithPaused(true)) + pool.Status.TargetDigest = testDigestA + + rs := &rolloutState{ + upToDate: []*bootcv1alpha1.BootcNode{ + testutil.NewNode("n1", testImageDigestRefA, testutil.WithBootedDigest(testDigestA)), + }, + pending: []*bootcv1alpha1.BootcNode{ + testutil.NewNode("n2", testImageDigestRefA, testutil.WithBootedDigest(testDigestB)), + testutil.NewNode("n3", testImageDigestRefA, testutil.WithBootedDigest(testDigestB)), + }, + } + + syncPoolStatus(pool, rs) + + g.Expect(pool.Status.NodeCount).To(Equal(int32(3))) + g.Expect(pool.Status.UpdatedCount).To(Equal(int32(1))) + g.Expect(pool.Status.UpdatingCount).To(Equal(int32(2))) + g.Expect(pool.Status.Conditions).To(ContainElement(And( + HaveField("Type", bootcv1alpha1.PoolUpToDate), + HaveField("Status", metav1.ConditionFalse), + HaveField("Reason", bootcv1alpha1.PoolPaused), + HaveField("Message", Equal("1/3 updated; 2 pending, 0 staging, 0 staged, 0 rebooting, 0 degraded")), + ))) +} + +// Verifies that an empty pool with no nodes is vacuously considered +// up-to-date, with all counts at zero and deployedDigest set. +func TestSyncPoolStatusEmptyPool(t *testing.T) { + g := NewWithT(t) + + pool := testutil.NewPool("test", testImageDigestRefA, testutil.WithWorkerSelector()) + pool.Status.TargetDigest = testDigestA + + rs := &rolloutState{} + + syncPoolStatus(pool, rs) + + g.Expect(pool.Status.NodeCount).To(Equal(int32(0))) + g.Expect(pool.Status.UpdatedCount).To(Equal(int32(0))) + g.Expect(pool.Status.UpdatingCount).To(Equal(int32(0))) + g.Expect(pool.Status.DegradedCount).To(Equal(int32(0))) + g.Expect(pool.Status.DeployedDigest).To(Equal(testDigestA)) + g.Expect(pool.Status.UpdateAvailable).To(BeFalse()) + g.Expect(pool.Status.Conditions).To(ContainElement(And( + HaveField("Type", bootcv1alpha1.PoolUpToDate), + HaveField("Status", metav1.ConditionTrue), + HaveField("Reason", bootcv1alpha1.PoolAllUpdated), + ))) +} + +// Verifies that deployedDigest retains its previous value while a rollout +// is in progress, and updateAvailable is true. +func TestSyncPoolStatusDeployedDigestPreserved(t *testing.T) { + g := NewWithT(t) + + pool := testutil.NewPool("test", testImageDigestRefB, testutil.WithWorkerSelector()) + pool.Status.TargetDigest = testDigestB + pool.Status.DeployedDigest = testDigestA + + rs := &rolloutState{ + pending: []*bootcv1alpha1.BootcNode{ + testutil.NewNode("n1", testImageDigestRefB, testutil.WithBootedDigest(testDigestA)), + }, + } + + syncPoolStatus(pool, rs) + + g.Expect(pool.Status.DeployedDigest).To(Equal(testDigestA)) + g.Expect(pool.Status.UpdateAvailable).To(BeTrue()) +} From ec4249fcf32a3d4313f2701d0c895c0ca3d51b32 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Wed, 5 Aug 2026 14:02:11 +0000 Subject: [PATCH 05/11] tests: add pool status assertions to e2e tests Verify the status aggregation for existing e2e tests. Assisted-by: AI Signed-off-by: Alice Frosi --- test/e2e/bootcnode_test.go | 76 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/test/e2e/bootcnode_test.go b/test/e2e/bootcnode_test.go index 7ddf51e..120b755 100644 --- a/test/e2e/bootcnode_test.go +++ b/test/e2e/bootcnode_test.go @@ -11,6 +11,7 @@ import ( "time" . "github.com/onsi/gomega" + "github.com/onsi/gomega/types" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" @@ -92,6 +93,9 @@ func TestControllerMembership(t *testing.T) { HaveField("Reason", bootcv1alpha1.NodeReasonIdle), ))), )) + + // Verify pool status reflects steady state. + g.Eventually(fetchPoolStatus(ctx, env.Client, pool)).Should(poolAllUpdated(1, env.NodeImageDigest())) } // TestUpdateReboot provisions a worker node, creates a pool with the @@ -150,6 +154,19 @@ func TestUpdateReboot(t *testing.T) { t.Logf("Node %q is Rebooting", nodeName) + // Verify pool status during rollout. + g.Eventually(fetchPoolStatus(ctx, env.Client, pool)).Should(And( + HaveField("NodeCount", BeEquivalentTo(1)), + HaveField("UpdatedCount", BeEquivalentTo(0)), + HaveField("UpdatingCount", BeEquivalentTo(1)), + HaveField("UpdateAvailable", BeTrue()), + HaveField("Conditions", ContainElement(And( + HaveField("Type", bootcv1alpha1.PoolUpToDate), + HaveField("Status", metav1.ConditionFalse), + HaveField("Reason", bootcv1alpha1.PoolRolloutInProgress), + ))), + )) + // Phase 4: Wait for Idle with the update digest — proves the full // update lifecycle completed (staging, reboot, boot into new image). g.Eventually(func() (bootcv1alpha1.BootcNodeStatus, error) { @@ -170,6 +187,9 @@ func TestUpdateReboot(t *testing.T) { t.Logf("Node %q is Idle with update image", nodeName) + // Verify pool status after rollout completes. + g.Eventually(fetchPoolStatus(ctx, env.Client, pool)).Should(poolAllUpdated(1, env.NodeImageUpdateDigest())) + // Phase 5: Verify node is schedulable (uncordoned after reboot). g.Eventually(func() (bool, error) { var node corev1.Node @@ -256,6 +276,9 @@ func TestUpdateReboot(t *testing.T) { ), "expected node to reach Idle with original image after rollback") t.Logf("Node %q successfully rolled back to original image", nodeName) + + // Verify pool status after rollback completes. + g.Eventually(fetchPoolStatus(ctx, env.Client, pool)).Should(poolAllUpdated(1, env.NodeImageDigest())) } // TestTagResolution creates a pool with a tag-based image ref, verifies @@ -416,6 +439,18 @@ func TestPauseResume(t *testing.T) { t.Logf("Node %q staged update but did not reboot (paused)", nodeName) + // Verify pool status while paused. + g.Eventually(fetchPoolStatus(ctx, env.Client, pool)).Should(And( + HaveField("NodeCount", BeEquivalentTo(1)), + HaveField("UpdatedCount", BeEquivalentTo(0)), + HaveField("UpdateAvailable", BeTrue()), + HaveField("Conditions", ContainElement(And( + HaveField("Type", bootcv1alpha1.PoolUpToDate), + HaveField("Status", metav1.ConditionFalse), + HaveField("Reason", bootcv1alpha1.PoolPaused), + ))), + )) + // Verify the node stays Staged and does not proceed to reboot. g.Consistently(func() ([]metav1.Condition, error) { var bn2 bootcv1alpha1.BootcNode @@ -453,6 +488,9 @@ func TestPauseResume(t *testing.T) { ), "expected node to reach Idle with update image after resume") t.Logf("Node %q completed update after resume", nodeName) + + // Verify pool status after resume completes. + g.Eventually(fetchPoolStatus(ctx, env.Client, pool)).Should(poolAllUpdated(1, env.NodeImageUpdateDigest())) } // TestNonExistingImage provisions a worker node, creates a pool with the @@ -517,6 +555,20 @@ func TestNonExistingImage(t *testing.T) { t.Logf("Node %q entered degraded state as expected", nodeName) + // Verify pool status reflects degraded node. + g.Eventually(fetchPoolStatus(ctx, env.Client, pool)).Should(And( + HaveField("NodeCount", BeEquivalentTo(1)), + HaveField("UpdatedCount", BeEquivalentTo(0)), + HaveField("UpdatingCount", BeEquivalentTo(0)), + HaveField("DegradedCount", BeEquivalentTo(1)), + HaveField("UpdateAvailable", BeTrue()), + HaveField("Conditions", ContainElement(And( + HaveField("Type", bootcv1alpha1.PoolDegraded), + HaveField("Status", metav1.ConditionTrue), + HaveField("Reason", bootcv1alpha1.PoolNodeDegraded), + ))), + )) + // Phase 4: Verify the node did not stage the non-existing image. g.Expect(env.Client.Get(ctx, client.ObjectKey{Name: nodeName}, &bn)).To(Succeed()) g.Expect(bn.Status.Staged).To(BeNil(), @@ -524,3 +576,27 @@ func TestNonExistingImage(t *testing.T) { t.Logf("Verified node %q did not stage non-existing image", nodeName) } + +func fetchPoolStatus(ctx context.Context, c client.Client, pool *bootcv1alpha1.BootcNodePool) func() (bootcv1alpha1.BootcNodePoolStatus, error) { + return func() (bootcv1alpha1.BootcNodePoolStatus, error) { + var p bootcv1alpha1.BootcNodePool + err := c.Get(ctx, client.ObjectKeyFromObject(pool), &p) + return p.Status, err + } +} + +func poolAllUpdated(nodeCount int32, deployedDigest string) types.GomegaMatcher { + return And( + HaveField("NodeCount", BeEquivalentTo(nodeCount)), + HaveField("UpdatedCount", BeEquivalentTo(nodeCount)), + HaveField("UpdatingCount", BeEquivalentTo(0)), + HaveField("DegradedCount", BeEquivalentTo(0)), + HaveField("DeployedDigest", Equal(deployedDigest)), + HaveField("UpdateAvailable", BeFalse()), + HaveField("Conditions", ContainElement(And( + HaveField("Type", bootcv1alpha1.PoolUpToDate), + HaveField("Status", metav1.ConditionTrue), + HaveField("Reason", bootcv1alpha1.PoolAllUpdated), + ))), + ) +} From 9da0121ca2e976710c359c66f23ca13310d1aa69 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Wed, 5 Aug 2026 14:23:09 +0000 Subject: [PATCH 06/11] api: add kubectl print columns for pool status counts Add the coulumns: nodeCount, updatedCount, updatingCount and degradedCount. Assisted-by: AI Signed-off-by: Alice Frosi --- api/v1alpha1/bootcnodepool_types.go | 4 ++++ .../crd/bases/node.bootc.dev_bootcnodepools.yaml | 15 ++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/api/v1alpha1/bootcnodepool_types.go b/api/v1alpha1/bootcnodepool_types.go index e750ff3..9c32f67 100644 --- a/api/v1alpha1/bootcnodepool_types.go +++ b/api/v1alpha1/bootcnodepool_types.go @@ -212,6 +212,10 @@ type BootcNodePoolStatus struct { // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:resource:scope=Cluster +// +kubebuilder:printcolumn:name="Nodes",type=integer,JSONPath=`.status.nodeCount` +// +kubebuilder:printcolumn:name="Updated",type=integer,JSONPath=`.status.updatedCount` +// +kubebuilder:printcolumn:name="Updating",type=integer,JSONPath=`.status.updatingCount` +// +kubebuilder:printcolumn:name="Degraded",type=integer,JSONPath=`.status.degradedCount` // BootcNodePool defines a group of nodes and their desired OS image state. // Users create BootcNodePool resources to register nodes with the bootc diff --git a/config/crd/bases/node.bootc.dev_bootcnodepools.yaml b/config/crd/bases/node.bootc.dev_bootcnodepools.yaml index 7e8da3e..4aec9ab 100644 --- a/config/crd/bases/node.bootc.dev_bootcnodepools.yaml +++ b/config/crd/bases/node.bootc.dev_bootcnodepools.yaml @@ -14,7 +14,20 @@ spec: singular: bootcnodepool scope: Cluster versions: - - name: v1alpha1 + - additionalPrinterColumns: + - jsonPath: .status.nodeCount + name: Nodes + type: integer + - jsonPath: .status.updatedCount + name: Updated + type: integer + - jsonPath: .status.updatingCount + name: Updating + type: integer + - jsonPath: .status.degradedCount + name: Degraded + type: integer + name: v1alpha1 schema: openAPIV3Schema: description: |- From 57c028780d1289acc2d73f8086d5cab5f5f14549 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Wed, 5 Aug 2026 14:29:59 +0000 Subject: [PATCH 07/11] api: add short names for CRDs (bnp, bn) Assisted-by: AI Signed-off-by: Alice Frosi --- api/v1alpha1/bootcnode_types.go | 2 +- api/v1alpha1/bootcnodepool_types.go | 2 +- config/crd/bases/node.bootc.dev_bootcnodepools.yaml | 2 ++ config/crd/bases/node.bootc.dev_bootcnodes.yaml | 2 ++ 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/api/v1alpha1/bootcnode_types.go b/api/v1alpha1/bootcnode_types.go index 88079fb..1e26e44 100644 --- a/api/v1alpha1/bootcnode_types.go +++ b/api/v1alpha1/bootcnode_types.go @@ -158,7 +158,7 @@ type BootcNodeStatus struct { // +kubebuilder:object:root=true // +kubebuilder:subresource:status -// +kubebuilder:resource:scope=Cluster +// +kubebuilder:resource:scope=Cluster,shortName=bn // BootcNode represents a single node managed by the bootc operator. // BootcNode objects are auto-created by the controller (one per managed diff --git a/api/v1alpha1/bootcnodepool_types.go b/api/v1alpha1/bootcnodepool_types.go index 9c32f67..6fc5f6f 100644 --- a/api/v1alpha1/bootcnodepool_types.go +++ b/api/v1alpha1/bootcnodepool_types.go @@ -211,7 +211,7 @@ type BootcNodePoolStatus struct { // +kubebuilder:object:root=true // +kubebuilder:subresource:status -// +kubebuilder:resource:scope=Cluster +// +kubebuilder:resource:scope=Cluster,shortName=bnp // +kubebuilder:printcolumn:name="Nodes",type=integer,JSONPath=`.status.nodeCount` // +kubebuilder:printcolumn:name="Updated",type=integer,JSONPath=`.status.updatedCount` // +kubebuilder:printcolumn:name="Updating",type=integer,JSONPath=`.status.updatingCount` diff --git a/config/crd/bases/node.bootc.dev_bootcnodepools.yaml b/config/crd/bases/node.bootc.dev_bootcnodepools.yaml index 4aec9ab..9ce421a 100644 --- a/config/crd/bases/node.bootc.dev_bootcnodepools.yaml +++ b/config/crd/bases/node.bootc.dev_bootcnodepools.yaml @@ -11,6 +11,8 @@ spec: kind: BootcNodePool listKind: BootcNodePoolList plural: bootcnodepools + shortNames: + - bnp singular: bootcnodepool scope: Cluster versions: diff --git a/config/crd/bases/node.bootc.dev_bootcnodes.yaml b/config/crd/bases/node.bootc.dev_bootcnodes.yaml index 88538fc..a769bf1 100644 --- a/config/crd/bases/node.bootc.dev_bootcnodes.yaml +++ b/config/crd/bases/node.bootc.dev_bootcnodes.yaml @@ -11,6 +11,8 @@ spec: kind: BootcNode listKind: BootcNodeList plural: bootcnodes + shortNames: + - bn singular: bootcnode scope: Cluster versions: From 94907e587939bb08537399186abcef7edd73582a Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Wed, 5 Aug 2026 15:17:26 +0000 Subject: [PATCH 08/11] docs: mark milestone 3c as completed Signed-off-by: Alice Frosi --- docs/IMPLEMENTATION_PLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 6ffe67e..e9fc387 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -172,7 +172,7 @@ the full controller+daemon loop can be tested end-to-end. NotReady), verify the controller stops assigning new slots even when `maxUnavailable` has capacity. -### 3c. Pool status aggregation +### 3c. Pool status aggregation ✅ - Compute `nodeCount`, `updatedCount`, `updatingCount`, `degradedCount` - `UpToDate` condition with reasons: `AllUpdated`, `RolloutInProgress`, From 14efd90b10197478d00b87bb3a4bb0e9619db831 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Thu, 6 Aug 2026 11:31:56 +0000 Subject: [PATCH 09/11] ci: bump e2e test timeout from 20m to 30m The new tests added some additional delay. Assisted-by: AI Signed-off-by: Alice Frosi --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 5fec160..b3beaad 100644 --- a/Makefile +++ b/Makefile @@ -73,7 +73,7 @@ e2e: ## Run e2e tests (requires: make deploy-bink). V=1 for verbose. RUN= ARTIFACTS=$(ARTIFACTS) \ BINK_NODE_IMAGE_DIGEST=$$(skopeo inspect --tls-verify=false --format '{{.Digest}}' docker://localhost:5000/node:latest) \ BINK_NODE_IMAGE_UPDATE_DIGEST=$$(skopeo inspect --tls-verify=false docker://localhost:5000/node:update | jq -r '.Digest') \ - go test -timeout 20m -count=1 $(if $(V),-v) $(if $(RUN),-run $(RUN)) . + go test -timeout 30m -count=1 $(if $(V),-v) $(if $(RUN),-run $(RUN)) . ##@ Build From 1ca9d8e48728fd04c0055026c02020e22f7ba882 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Thu, 6 Aug 2026 12:58:29 +0000 Subject: [PATCH 10/11] ci: move aa-teardown after podman install The passt package, pulled in as a podman dependency, loads its AppArmor profile during installation. Running aa-teardown before apt-get install meant the profile was immediately re-loaded, causing passt to fail with "Permission denied" when creating VMs. Assisted-by: AI Signed-off-by: Alice Frosi --- .github/workflows/ci.yaml | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 011385d..fc45a52 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -153,15 +153,6 @@ jobs: - name: Set up KVM run: sudo chmod 666 /dev/kvm - - name: Configure kernel - run: | - # Unload AppArmor profiles — the passt profile blocks remount - # operations needed for passt's self-sandboxing inside containers. - sudo aa-teardown 2>/dev/null || true - # Allow unprivileged user namespace creation (needed by passt - # inside containers). - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - - name: Enable KSM run: | sudo sh -c 'echo 1 > /sys/kernel/mm/ksm/run' @@ -172,6 +163,17 @@ jobs: sudo apt-get update sudo apt-get install -y podman + - name: Configure kernel + run: | + # Unload AppArmor profiles — the host-loaded passt profile + # blocks passt's self-sandboxing inside bink node containers. + # Must run AFTER installing podman, whose passt dependency + # loads the profile during package installation. + sudo aa-teardown 2>/dev/null || true + # Allow unprivileged user namespace creation (needed by passt + # inside containers). + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + - name: Start podman socket run: systemctl --user start podman.socket From 9e4ec70cb880dc0da800d89d68117ebd9994602e Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Thu, 6 Aug 2026 14:25:14 +0000 Subject: [PATCH 11/11] daemon: filter BootcNode watch to spec-only changes The daemon watches its own BootcNode with For(), which fires on all changes including status patches. When staging fails, the reconciler sets Degraded=True and returns RequeueAfter for backoff, but the status patch immediately re-triggers a reconcile that bypasses the backoff and retries staging in a tight loop. Add GenerationChangedPredicate so the For() watch only fires on spec changes (generation increments from the pool controller). Status patches no longer self-trigger. The daemon still reconciles on staging completion (stageDone channel) and bootc status changes (StatusWatcher channel). Assisted-by: AI Signed-off-by: Alice Frosi --- internal/daemon/reconciler.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/daemon/reconciler.go b/internal/daemon/reconciler.go index d5222b6..d670e20 100644 --- a/internal/daemon/reconciler.go +++ b/internal/daemon/reconciler.go @@ -16,10 +16,12 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/source" bootcv1alpha1 "github.com/bootc-dev/bootc-operator/api/v1alpha1" @@ -66,7 +68,7 @@ func (r *BootcNodeReconciler) SetupWithManager(mgr ctrl.Manager) error { r.stageDone = make(chan event.GenericEvent, 1) return ctrl.NewControllerManagedBy(mgr). - For(&bootcv1alpha1.BootcNode{}). + For(&bootcv1alpha1.BootcNode{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). WatchesRawSource(source.Channel(r.stageDone, &handler.EnqueueRequestForObject{})). WatchesRawSource(source.Channel(r.StatusWatcher.Events, &handler.EnqueueRequestForObject{})). Named("bootcnode").