From a102610961130b2b26ffb497cea8a5d83b1851b3 Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Wed, 26 Aug 2026 10:42:01 +0530 Subject: [PATCH 1/6] api: Add rebootPolicy to BootcNodeSpec The daemon needs to know the pool's reboot policy to decide between a full reboot and a soft reboot. Add a RebootPolicy field to BootcNodeSpec, reusing the existing type from bootcnodepool_types.go. Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- api/v1alpha1/bootcnode_types.go | 5 +++++ config/crd/bases/node.bootc.dev_bootcnodes.yaml | 8 ++++++++ internal/controller/crd_test.go | 15 +++++++++++++++ test/util/builders.go | 7 +++++++ 4 files changed, 35 insertions(+) diff --git a/api/v1alpha1/bootcnode_types.go b/api/v1alpha1/bootcnode_types.go index 1e26e44..ddeefc1 100644 --- a/api/v1alpha1/bootcnode_types.go +++ b/api/v1alpha1/bootcnode_types.go @@ -124,6 +124,11 @@ type BootcNodeSpec struct { // the secret and updates the host filesystem. // +optional PullSecretHash string `json:"pullSecretHash,omitempty"` + + // rebootPolicy defines how the node should be rebooted during + // updates. Copied from the owning pool's disruption.rebootPolicy. + // +optional + RebootPolicy RebootPolicy `json:"rebootPolicy,omitempty"` } // BootcNodeStatus defines the observed state of a BootcNode. diff --git a/config/crd/bases/node.bootc.dev_bootcnodes.yaml b/config/crd/bases/node.bootc.dev_bootcnodes.yaml index a769bf1..a29e18c 100644 --- a/config/crd/bases/node.bootc.dev_bootcnodes.yaml +++ b/config/crd/bases/node.bootc.dev_bootcnodes.yaml @@ -85,6 +85,14 @@ spec: - name - namespace type: object + rebootPolicy: + description: |- + rebootPolicy defines how the node should be rebooted during + updates. Copied from the owning pool's disruption.rebootPolicy. + enum: + - RebootOnly + - AllowSoftReboot + type: string required: - desiredImage - desiredImageState diff --git a/internal/controller/crd_test.go b/internal/controller/crd_test.go index 595767a..afa0101 100644 --- a/internal/controller/crd_test.go +++ b/internal/controller/crd_test.go @@ -64,6 +64,7 @@ func TestBootcNodeCRD(t *testing.T) { node := testutil.NewNode("worker-1", testImageDigestRefA, testutil.WithNodePullSecret(testSecretName, testSecretNS, testSecretHash), + testutil.WithNodeRebootPolicy(bootcv1alpha1.RebootPolicyAllowSoftReboot), ) // Save the spec before Create, which mutates node in-place. @@ -159,6 +160,20 @@ func TestBootcNodeEnumValidation(t *testing.T) { g.Expect(err).To(MatchError(apierrors.IsInvalid, "IsInvalid")) } +func TestBootcNodeRebootPolicyEnumValidation(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + + node := testutil.NewNode("invalid-reboot-policy", testImageDigestRefA, + testutil.WithNodeRebootPolicy("Invalid"), + ) + err := k8sClient.Create(ctx, node) + if err == nil { + _ = k8sClient.Delete(ctx, node) + } + g.Expect(err).To(MatchError(apierrors.IsInvalid, "IsInvalid")) +} + func TestBootcNodePoolMinLengthValidation(t *testing.T) { g := NewWithT(t) ctx := context.Background() diff --git a/test/util/builders.go b/test/util/builders.go index a6a882d..f04fc7c 100644 --- a/test/util/builders.go +++ b/test/util/builders.go @@ -206,6 +206,13 @@ func WithNodePullSecret(name, namespace, hash string) NodeOption { } } +// WithNodeRebootPolicy sets the reboot policy on a BootcNode. +func WithNodeRebootPolicy(p bootcv1alpha1.RebootPolicy) NodeOption { + return func(node *bootcv1alpha1.BootcNode) { + node.Spec.RebootPolicy = p + } +} + // K8sNodeOption configures a corev1.Node. type K8sNodeOption func(*corev1.Node) From cc9bf6fced4ea99c8608e91216a32c36be50d07f Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Wed, 26 Aug 2026 10:43:19 +0530 Subject: [PATCH 2/6] executor: Add Apply method for soft reboot path Add Apply(ctx, softReboot) to the Executor interface. When softReboot is true, it runs bootc upgrade --from-downloaded --apply --soft-reboot=auto via nsenter, which performs a userspace-only restart when the kernel hasn't changed. Uses bootc upgrade (not switch) because current bink images predate bootc#2342 which adds --from-downloaded to switch. Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- internal/bootc/executor.go | 18 ++++++++++++++++++ internal/daemon/fake_test.go | 24 +++++++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/internal/bootc/executor.go b/internal/bootc/executor.go index 3452930..f7babdb 100644 --- a/internal/bootc/executor.go +++ b/internal/bootc/executor.go @@ -20,6 +20,7 @@ type Executor interface { Status(ctx context.Context) ([]byte, error) Stage(ctx context.Context, image string) error Reboot(ctx context.Context) error + Apply(ctx context.Context, softReboot bool) error } // HostExecutor runs bootc commands on the host via nsenter. @@ -152,3 +153,20 @@ func (e *HostExecutor) Reboot(ctx context.Context) error { } return nil } + +func (e *HostExecutor) Apply(ctx context.Context, softReboot bool) error { + log := logf.FromContext(ctx) + + args := []string{"bootc", "upgrade", "--from-downloaded", "--apply"} + if softReboot { + args = append(args, "--soft-reboot=auto") + } + + cmd := e.nsenterCmd(ctx, args...) + log.Info("Executing", "cmd", strings.Join(cmd.Args, " ")) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("running bootc apply: %s: %w", out, err) + } + return nil +} diff --git a/internal/daemon/fake_test.go b/internal/daemon/fake_test.go index 5f5f7b5..7b1b03c 100644 --- a/internal/daemon/fake_test.go +++ b/internal/daemon/fake_test.go @@ -22,7 +22,9 @@ type fakeExecutor struct { stageImg string stageHook func() - rebooted bool + rebooted bool + applied bool + appliedSoft bool } func (f *fakeExecutor) Status(_ context.Context) ([]byte, error) { @@ -64,6 +66,14 @@ func (f *fakeExecutor) Reboot(_ context.Context) error { return nil } +func (f *fakeExecutor) Apply(_ context.Context, softReboot bool) error { + f.mu.Lock() + defer f.mu.Unlock() + f.applied = true + f.appliedSoft = softReboot + return nil +} + func (f *fakeExecutor) setStatusErr(err error) { f.mu.Lock() defer f.mu.Unlock() @@ -94,6 +104,18 @@ func (f *fakeExecutor) getRebooted() bool { return f.rebooted } +func (f *fakeExecutor) getApplied() bool { + f.mu.Lock() + defer f.mu.Unlock() + return f.applied +} + +func (f *fakeExecutor) getAppliedSoft() bool { + f.mu.Lock() + defer f.mu.Unlock() + return f.appliedSoft +} + func newBootEntry(image, digest string) *bootc.BootEntry { return &bootc.BootEntry{ Image: &bootc.ImageStatus{ From 8e71fb5d096f66912bf05dad8669ce316d5e5cd9 Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Wed, 26 Aug 2026 10:43:42 +0530 Subject: [PATCH 3/6] controller: Propagate rebootPolicy from pool to BootcNode Copy the pool's disruption.rebootPolicy to each BootcNode's spec on creation and on sync, following the same pattern as pullSecretRef propagation. Defaults to RebootOnly when the pool has no disruption spec. Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- .../controller/bootcnodepool_controller.go | 15 +++++ internal/controller/membership_test.go | 61 +++++++++++++++++-- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/internal/controller/bootcnodepool_controller.go b/internal/controller/bootcnodepool_controller.go index 4caef48..025b17a 100644 --- a/internal/controller/bootcnodepool_controller.go +++ b/internal/controller/bootcnodepool_controller.go @@ -610,6 +610,12 @@ func (r *BootcNodePoolReconciler) syncBootcNodeSpec( needPatch = true } + newRebootPolicy := effectiveRebootPolicy(pool) + if modified.Spec.RebootPolicy != newRebootPolicy { + modified.Spec.RebootPolicy = newRebootPolicy + needPatch = true + } + if needPatch { if err := r.Patch(ctx, modified, client.MergeFrom(bn)); err != nil { return fmt.Errorf("patching BootcNode: %w", err) @@ -620,6 +626,13 @@ func (r *BootcNodePoolReconciler) syncBootcNodeSpec( return nil } +func effectiveRebootPolicy(pool *bootcv1alpha1.BootcNodePool) bootcv1alpha1.RebootPolicy { + if pool.Spec.Disruption != nil && pool.Spec.Disruption.RebootPolicy != "" { + return pool.Spec.Disruption.RebootPolicy + } + return bootcv1alpha1.RebootPolicyRebootOnly +} + // desiredImageFromPool constructs the desiredImage pullspec from the // pool's image name and resolved targetDigest (e.g. // "quay.io/example/myos@sha256:abc123"). @@ -650,6 +663,8 @@ func (r *BootcNodePoolReconciler) createBootcNode( bn.Spec.PullSecretRef = pool.Spec.PullSecretRef.DeepCopy() } + bn.Spec.RebootPolicy = effectiveRebootPolicy(pool) + // Set ownerReference so the BootcNode is cleaned up if the pool is // deleted and so the Owns() watch routes BootcNode events to this pool. if err := controllerutil.SetControllerReference(pool, bn, r.Scheme); err != nil { diff --git a/internal/controller/membership_test.go b/internal/controller/membership_test.go index c4ba9ab..4b31ffe 100644 --- a/internal/controller/membership_test.go +++ b/internal/controller/membership_test.go @@ -211,6 +211,50 @@ func TestMembershipSyncsDesiredImage(t *testing.T) { )) } +func TestMembershipSyncsRebootPolicy(t *testing.T) { + g := NewWithT(t) + g.SetDefaultEventuallyTimeout(pollTimeout) + g.SetDefaultEventuallyPollingInterval(pollInterval) + ctx := context.Background() + + node := testutil.NewK8sNode("mem-reboot-1", testutil.WorkerLabels()) + g.Expect(k8sClient.Create(ctx, node)).To(Succeed()) + t.Cleanup(func() { + _ = k8sClient.Delete(ctx, node) + }) + + pool := testutil.NewPool("mem-reboot-pool", testImageDigestRefA, + testutil.WithWorkerSelector(), + testutil.WithRebootPolicy(bootcv1alpha1.RebootPolicyAllowSoftReboot), + ) + g.Expect(k8sClient.Create(ctx, pool)).To(Succeed()) + t.Cleanup(func() { + _ = k8sClient.Delete(ctx, pool) + }) + + // Wait for BootcNode to be created with AllowSoftReboot. + g.Eventually(func() (bootcv1alpha1.RebootPolicy, error) { + var bn bootcv1alpha1.BootcNode + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(node), &bn) + return bn.Spec.RebootPolicy, err + }).Should(Equal(bootcv1alpha1.RebootPolicyAllowSoftReboot)) + + // Update pool to RebootOnly. + var freshPool bootcv1alpha1.BootcNodePool + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(pool), &freshPool)).To(Succeed()) + freshPool.Spec.Disruption = &bootcv1alpha1.DisruptionSpec{ + RebootPolicy: bootcv1alpha1.RebootPolicyRebootOnly, + } + g.Expect(k8sClient.Update(ctx, &freshPool)).To(Succeed()) + + // Wait for BootcNode to be updated to RebootOnly. + g.Eventually(func() (bootcv1alpha1.RebootPolicy, error) { + var bn bootcv1alpha1.BootcNode + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(node), &bn) + return bn.Spec.RebootPolicy, err + }).Should(Equal(bootcv1alpha1.RebootPolicyRebootOnly)) +} + // TestPoolDeletionRemovesManagedLabel verifies that when a BootcNodePool is // deleted, the controller removes the bootc.dev/managed label from all member // nodes and deletes all owned BootcNode objects. It also verifies that @@ -242,7 +286,11 @@ func TestPoolDeletionRemovesManagedLabel(t *testing.T) { } // Create a worker pool and a separate control-plane pool. - workerPool := testutil.NewPool("del-workers", testImageDigestRefA, testutil.WithWorkerSelector()) + workerPool := testutil.NewPool( + "del-workers", + testImageDigestRefA, + testutil.WithWorkerSelector(), + ) g.Expect(k8sClient.Create(ctx, workerPool)).To(Succeed()) cpPool := testutil.NewPool("del-control-plane", testImageDigestRefA, @@ -286,7 +334,11 @@ func TestPoolDeletionRemovesManagedLabel(t *testing.T) { // The worker pool itself should be fully deleted (finalizer removed). g.Eventually(func() error { - return k8sClient.Get(ctx, client.ObjectKeyFromObject(workerPool), &bootcv1alpha1.BootcNodePool{}) + return k8sClient.Get( + ctx, + client.ObjectKeyFromObject(workerPool), + &bootcv1alpha1.BootcNodePool{}, + ) }).Should(MatchError(apierrors.IsNotFound, "IsNotFound"), "worker pool should be fully deleted") // Control-plane nodes must still carry the managed label — their pool was not deleted. @@ -299,8 +351,9 @@ func TestPoolDeletionRemovesManagedLabel(t *testing.T) { // Control-plane BootcNodes must still exist. for _, node := range controlPlaneNodes { - g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: node.Name}, &bootcv1alpha1.BootcNode{})).To(Succeed(), - "BootcNode %s should still exist", node.Name) + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: node.Name}, &bootcv1alpha1.BootcNode{})). + To(Succeed(), + "BootcNode %s should still exist", node.Name) } } From c688f729c24d88d2a3599308d2d8d028f80098ff Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Wed, 26 Aug 2026 10:43:58 +0530 Subject: [PATCH 4/6] daemon: Implement soft reboot support When the BootcNode's rebootPolicy is AllowSoftReboot, use Executor.Apply(ctx, true) instead of Executor.Reboot(ctx). This calls bootc with --soft-reboot=auto, which performs a userspace-only restart when the kernel hasn't changed, avoiding a full hardware reboot. Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- internal/daemon/reconciler.go | 16 +++++-- internal/daemon/reconciler_test.go | 71 ++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/internal/daemon/reconciler.go b/internal/daemon/reconciler.go index 2f77c99..d79df5f 100644 --- a/internal/daemon/reconciler.go +++ b/internal/daemon/reconciler.go @@ -136,11 +136,17 @@ func (r *BootcNodeReconciler) Reconcile( // Reboot after the status patch so the Rebooting condition is persisted before the node goes down. if res.needsReboot { - log.Info("Starting reboot") - if err := r.Executor.Reboot(ctx); err != nil { - return ctrl.Result{}, fmt.Errorf("reboot: %w", err) + if res.softReboot { + log.Info("Applying update with soft reboot") + if err := r.Executor.Apply(ctx, true); err != nil { + return ctrl.Result{}, fmt.Errorf("apply with soft reboot: %w", err) + } + } else { + log.Info("Starting reboot") + if err := r.Executor.Reboot(ctx); err != nil { + return ctrl.Result{}, fmt.Errorf("reboot: %w", err) + } } - // Record if the reboot was issued in this way we can transition from Staged to Rebooting r.rebootIssued = true } @@ -151,6 +157,7 @@ type reconcileResult struct { result ctrl.Result degradedMsg string needsReboot bool + softReboot bool } // reconcileBootcNode defines the result of the reconcile of the bootc nodes. It returns the results for the reconcile, @@ -225,6 +232,7 @@ func (r *BootcNodeReconciler) reconcileBootcNode( case actionReboot: reason = bootcv1alpha1.NodeReasonRebooting res.needsReboot = true + res.softReboot = bn.Spec.RebootPolicy == bootcv1alpha1.RebootPolicyAllowSoftReboot case actionAwaitBooted: reason = bootcv1alpha1.NodeReasonStaged diff --git a/internal/daemon/reconciler_test.go b/internal/daemon/reconciler_test.go index 81f654a..a4257ae 100644 --- a/internal/daemon/reconciler_test.go +++ b/internal/daemon/reconciler_test.go @@ -277,6 +277,77 @@ func TestRebootingSet(t *testing.T) { g.Expect(fake.getRebooted()).To(BeTrue()) } +func TestSoftReboot(t *testing.T) { + g := NewWithT(t) + g.SetDefaultEventuallyTimeout(pollTimeout) + g.SetDefaultEventuallyPollingInterval(pollInterval) + ctx := context.Background() + + fake := newTestEnv() + fake.status = newBootcStatus(testutil.DigestA) + fake.status.Status.Staged = newBootEntry(testutil.ImageDigestRefB, testutil.DigestB) + + bn := testutil.NewNode( + testNodeName, + testutil.ImageDigestRefB, + testutil.WithDesiredImageState(bootcv1alpha1.DesiredImageStateBooted), + testutil.WithNodeRebootPolicy(bootcv1alpha1.RebootPolicyAllowSoftReboot), + ) + g.Expect(k8sClient.Create(ctx, bn)).To(Succeed()) + t.Cleanup(func() { + _ = k8sClient.Delete(ctx, bn) + }) + + g.Eventually(func() ([]metav1.Condition, error) { + var got bootcv1alpha1.BootcNode + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(bn), &got) + return got.Status.Conditions, err + }).Should(ContainElement(And( + HaveField("Type", bootcv1alpha1.NodeIdle), + HaveField("Status", metav1.ConditionFalse), + HaveField("Reason", bootcv1alpha1.NodeReasonRebooting), + ))) + + g.Expect(fake.getRebooted()).To(BeFalse(), "should not use systemctl reboot") + g.Expect(fake.getApplied()).To(BeTrue(), "should use Apply") + g.Expect(fake.getAppliedSoft()).To(BeTrue(), "should pass softReboot=true") +} + +func TestRebootOnlyPolicy(t *testing.T) { + g := NewWithT(t) + g.SetDefaultEventuallyTimeout(pollTimeout) + g.SetDefaultEventuallyPollingInterval(pollInterval) + ctx := context.Background() + + fake := newTestEnv() + fake.status = newBootcStatus(testutil.DigestA) + fake.status.Status.Staged = newBootEntry(testutil.ImageDigestRefB, testutil.DigestB) + + bn := testutil.NewNode( + testNodeName, + testutil.ImageDigestRefB, + testutil.WithDesiredImageState(bootcv1alpha1.DesiredImageStateBooted), + testutil.WithNodeRebootPolicy(bootcv1alpha1.RebootPolicyRebootOnly), + ) + g.Expect(k8sClient.Create(ctx, bn)).To(Succeed()) + t.Cleanup(func() { + _ = k8sClient.Delete(ctx, bn) + }) + + g.Eventually(func() ([]metav1.Condition, error) { + var got bootcv1alpha1.BootcNode + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(bn), &got) + return got.Status.Conditions, err + }).Should(ContainElement(And( + HaveField("Type", bootcv1alpha1.NodeIdle), + HaveField("Status", metav1.ConditionFalse), + HaveField("Reason", bootcv1alpha1.NodeReasonRebooting), + ))) + + g.Expect(fake.getRebooted()).To(BeTrue(), "should use systemctl reboot") + g.Expect(fake.getApplied()).To(BeFalse(), "should not use Apply") +} + func TestRollback(t *testing.T) { g := NewWithT(t) g.SetDefaultEventuallyTimeout(pollTimeout) From fed3a504bd69fa27483b69aa5f57a1e3c3bb41e3 Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Wed, 26 Aug 2026 10:44:13 +0530 Subject: [PATCH 5/6] test/e2e: Add TestSoftReboot Verify the full soft reboot lifecycle: create a pool with AllowSoftReboot, trigger an update, and confirm the node comes back with the same boot ID (kernel stayed up, only userspace restarted). Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- test/e2e/bootcnode_test.go | 147 +++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/test/e2e/bootcnode_test.go b/test/e2e/bootcnode_test.go index 5c2c330..7234fa3 100644 --- a/test/e2e/bootcnode_test.go +++ b/test/e2e/bootcnode_test.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "os/exec" + "strings" "testing" "time" @@ -18,6 +19,7 @@ import ( bootcv1alpha1 "github.com/bootc-dev/bootc-operator/api/v1alpha1" "github.com/bootc-dev/bootc-operator/test/e2e/e2eutil" + testutil "github.com/bootc-dev/bootc-operator/test/util" ) const ( @@ -582,6 +584,151 @@ func TestNonExistingImage(t *testing.T) { t.Logf("Verified node %q did not stage non-existing image", nodeName) } +// TestSoftReboot provisions a worker node, creates a pool with +// AllowSoftReboot, triggers an update, and verifies the node comes back +// up without a full reboot by checking that the boot ID is preserved. +func TestSoftReboot(t *testing.T) { + g := NewWithT(t) + g.SetDefaultEventuallyTimeout(pollTimeout) + g.SetDefaultEventuallyPollingInterval(pollInterval) + + env := e2eutil.New(t) + nodeName := env.AddNode(t) + + ctx := context.Background() + + // Phase 1: Create pool with AllowSoftReboot and original image. + pool := env.NewPool("soft-reboot", env.NodeImageDigestedPullSpec(), + testutil.WithRebootPolicy(bootcv1alpha1.RebootPolicyAllowSoftReboot), + ) + g.Expect(env.Client.Create(ctx, pool)).To(Succeed()) + + g.Eventually(func() (bootcv1alpha1.BootcNodeStatus, error) { + var bn bootcv1alpha1.BootcNode + err := env.Client.Get(ctx, client.ObjectKey{Name: nodeName}, &bn) + return bn.Status, err + }).WithTimeout(3 * time.Minute).Should(And( + HaveField("Booted", Not(BeNil())), + HaveField("Conditions", ContainElement(And( + HaveField("Type", bootcv1alpha1.NodeIdle), + HaveField("Status", metav1.ConditionTrue), + HaveField("Reason", bootcv1alpha1.NodeReasonIdle), + ))), + )) + + t.Logf("Node %q is Idle with original image", nodeName) + + // Verify rebootPolicy was propagated to the BootcNode. + var bn bootcv1alpha1.BootcNode + g.Expect(env.Client.Get(ctx, client.ObjectKey{Name: nodeName}, &bn)).To(Succeed()) + g.Expect(bn.Spec.RebootPolicy).To(Equal(bootcv1alpha1.RebootPolicyAllowSoftReboot), + "expected rebootPolicy to be propagated to BootcNode") + + // Phase 2: Capture boot ID before update. + bootIDBefore := readBootID(t, ctx, env, nodeName) + t.Logf("Boot ID before update: %s", bootIDBefore) + + // Phase 3: Patch pool to update image. + updateRef := env.NodeImageUpdateDigestedPullSpec() + + modified := pool.DeepCopy() + modified.Spec.Image.Ref = updateRef + g.Expect(env.Client.Patch(ctx, modified, client.MergeFrom(pool))).To(Succeed()) + *pool = *modified + + t.Logf("Patched pool to update image %s", updateRef) + + // Phase 4: Wait for Rebooting state. + g.Eventually(func() ([]metav1.Condition, error) { + var bn bootcv1alpha1.BootcNode + err := env.Client.Get(ctx, client.ObjectKey{Name: nodeName}, &bn) + return bn.Status.Conditions, err + }).WithTimeout(5*time.Minute).Should(ContainElement(And( + HaveField("Type", bootcv1alpha1.NodeIdle), + HaveField("Status", metav1.ConditionFalse), + HaveField("Reason", bootcv1alpha1.NodeReasonRebooting), + )), "expected node to reach Rebooting state") + + t.Logf("Node %q is Rebooting", nodeName) + + // Phase 5: Wait for Idle with update image. + g.Eventually(func() (bootcv1alpha1.BootcNodeStatus, error) { + var bn bootcv1alpha1.BootcNode + err := env.Client.Get(ctx, client.ObjectKey{Name: nodeName}, &bn) + return bn.Status, err + }).WithTimeout(5*time.Minute).Should(And( + HaveField("Booted", And( + Not(BeNil()), + HaveField("ImageDigest", env.NodeImageUpdateDigest()), + )), + HaveField("Conditions", ContainElement(And( + HaveField("Type", bootcv1alpha1.NodeIdle), + HaveField("Status", metav1.ConditionTrue), + HaveField("Reason", bootcv1alpha1.NodeReasonIdle), + ))), + ), "expected node to reach Idle with update image after soft reboot") + + t.Logf("Node %q is Idle with update image", nodeName) + + // Phase 6: Verify boot ID is preserved (soft reboot does not change boot ID). + bootIDAfter := readBootID(t, ctx, env, nodeName) + t.Logf("Boot ID after update: %s", bootIDAfter) + + g.Expect(bootIDAfter).To(Equal(bootIDBefore), + "boot ID should be preserved after soft reboot (no kernel change)") + + // Phase 7: Verify pool status. + g.Eventually(fetchPoolStatus(ctx, env.Client, pool)). + Should(poolAllUpdated(1, env.NodeImageUpdateDigest())) + + // Phase 8: Verify node is schedulable (uncordoned after update). + g.Eventually(func() (bool, error) { + var node corev1.Node + err := env.Client.Get(ctx, client.ObjectKey{Name: nodeName}, &node) + return node.Spec.Unschedulable, err + }).WithTimeout(3*time.Minute).Should(BeFalse(), "expected node to be schedulable after update") +} + +// readBootID reads /proc/sys/kernel/random/boot_id from the host via +// kubectl exec into the daemon pod on the given node. It polls until a +// running daemon pod is found (the pod may be restarting after a reboot). +func readBootID(t *testing.T, ctx context.Context, env *e2eutil.Env, nodeName string) string { + t.Helper() + g := NewWithT(t) + + var podName string + g.Eventually(func() string { + var daemonPods corev1.PodList + if err := env.Client.List(ctx, &daemonPods, + client.InNamespace("bootc-operator"), + client.MatchingLabels{ + "app.kubernetes.io/name": "bootc-operator", + "app.kubernetes.io/component": "daemon", + }, + ); err != nil { + return "" + } + for _, p := range daemonPods.Items { + if p.Spec.NodeName == nodeName && p.Status.Phase == corev1.PodRunning { + podName = p.Name + return podName + } + } + return "" + }).WithTimeout(2*time.Minute).WithPolling(2*time.Second).ShouldNot(BeEmpty(), + "running daemon pod not found on %s", nodeName) + + kubeconfigPath := os.Getenv("KUBECONFIG") + cmd := exec.CommandContext(ctx, "kubectl", "--kubeconfig", kubeconfigPath, + "-n", "bootc-operator", "exec", podName, "--", + "cat", "/proc/sys/kernel/random/boot_id") + out, err := cmd.CombinedOutput() + g.Expect(err).NotTo(HaveOccurred(), + fmt.Sprintf("failed to read boot_id: %s", string(out))) + + return strings.TrimSpace(string(out)) +} + func fetchPoolStatus( ctx context.Context, c client.Client, From f8a7506a357fe8d464240c9678beee0398177039 Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Wed, 26 Aug 2026 17:45:22 +0530 Subject: [PATCH 6/6] executor: Rename Apply to ApplyUpdate, centralize bootc commands Rename Apply to ApplyUpdate for clarity per review feedback. Extract bootc command construction into builder functions (bootcStatusArgs, bootcSwitchArgs, bootcApplyUpdateArgs, systemctlRebootArgs) so all bootc invocations are defined in one place. Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- internal/bootc/executor.go | 49 ++++++++++++++++++++---------- internal/daemon/fake_test.go | 2 +- internal/daemon/reconciler.go | 2 +- internal/daemon/reconciler_test.go | 4 +-- 4 files changed, 37 insertions(+), 20 deletions(-) diff --git a/internal/bootc/executor.go b/internal/bootc/executor.go index f7babdb..ec95021 100644 --- a/internal/bootc/executor.go +++ b/internal/bootc/executor.go @@ -20,7 +20,29 @@ type Executor interface { Status(ctx context.Context) ([]byte, error) Stage(ctx context.Context, image string) error Reboot(ctx context.Context) error - Apply(ctx context.Context, softReboot bool) error + ApplyUpdate(ctx context.Context, softReboot bool) error +} + +// Centralized bootc command builders. + +func bootcStatusArgs() []string { + return []string{"bootc", "status", "--json", "--format-version", "1"} +} + +func bootcSwitchArgs(image string) []string { + return []string{"bootc", "switch", image} +} + +func bootcApplyUpdateArgs(softReboot bool) []string { + args := []string{"bootc", "upgrade", "--from-downloaded", "--apply"} + if softReboot { + args = append(args, "--soft-reboot=auto") + } + return args +} + +func systemctlRebootArgs() []string { + return []string{"systemctl", "reboot"} } // HostExecutor runs bootc commands on the host via nsenter. @@ -42,7 +64,7 @@ func (e *HostExecutor) nsenterCmd(ctx context.Context, args ...string) *exec.Cmd } func (e *HostExecutor) Status(ctx context.Context) ([]byte, error) { - cmd := e.nsenterCmd(ctx, "bootc", "status", "--json", "--format-version", "1") + cmd := e.nsenterCmd(ctx, bootcStatusArgs()...) out, err := cmd.Output() if err != nil { return nil, fmt.Errorf("running bootc status: %w", err) @@ -68,13 +90,13 @@ func (e *HostExecutor) Stage(ctx context.Context, image string) error { // Ideally we'd use systemd-run's `--pipe` here, which would avoid // having to fetch the unit journal down below, but SELinux blocks it // (dbus-broker can't access container-labeled fds). - cmd := e.nsenterCmd(ctx, - "systemd-run", "--wait", "--collect", - "--unit", stageUnitName, - // TODO: use --download-only once available - // (https://github.com/bootc-dev/bootc/issues/2137) - "bootc", "switch", image, + // TODO: use --download-only once available + // (https://github.com/bootc-dev/bootc/issues/2137) + stageArgs := append( + []string{"systemd-run", "--wait", "--collect", "--unit", stageUnitName}, + bootcSwitchArgs(image)..., ) + cmd := e.nsenterCmd(ctx, stageArgs...) cmd.Cancel = func() error { e.stopStageUnit() return nil @@ -145,7 +167,7 @@ func (e *HostExecutor) copyJournalUnitLogs(log logr.Logger, unit string, cursor func (e *HostExecutor) Reboot(ctx context.Context) error { log := logf.FromContext(ctx) - cmd := e.nsenterCmd(ctx, "systemctl", "reboot") + cmd := e.nsenterCmd(ctx, systemctlRebootArgs()...) log.Info("Executing", "cmd", strings.Join(cmd.Args, " ")) out, err := cmd.CombinedOutput() if err != nil { @@ -154,15 +176,10 @@ func (e *HostExecutor) Reboot(ctx context.Context) error { return nil } -func (e *HostExecutor) Apply(ctx context.Context, softReboot bool) error { +func (e *HostExecutor) ApplyUpdate(ctx context.Context, softReboot bool) error { log := logf.FromContext(ctx) - args := []string{"bootc", "upgrade", "--from-downloaded", "--apply"} - if softReboot { - args = append(args, "--soft-reboot=auto") - } - - cmd := e.nsenterCmd(ctx, args...) + cmd := e.nsenterCmd(ctx, bootcApplyUpdateArgs(softReboot)...) log.Info("Executing", "cmd", strings.Join(cmd.Args, " ")) out, err := cmd.CombinedOutput() if err != nil { diff --git a/internal/daemon/fake_test.go b/internal/daemon/fake_test.go index 7b1b03c..fba756b 100644 --- a/internal/daemon/fake_test.go +++ b/internal/daemon/fake_test.go @@ -66,7 +66,7 @@ func (f *fakeExecutor) Reboot(_ context.Context) error { return nil } -func (f *fakeExecutor) Apply(_ context.Context, softReboot bool) error { +func (f *fakeExecutor) ApplyUpdate(_ context.Context, softReboot bool) error { f.mu.Lock() defer f.mu.Unlock() f.applied = true diff --git a/internal/daemon/reconciler.go b/internal/daemon/reconciler.go index d79df5f..694a865 100644 --- a/internal/daemon/reconciler.go +++ b/internal/daemon/reconciler.go @@ -138,7 +138,7 @@ func (r *BootcNodeReconciler) Reconcile( if res.needsReboot { if res.softReboot { log.Info("Applying update with soft reboot") - if err := r.Executor.Apply(ctx, true); err != nil { + if err := r.Executor.ApplyUpdate(ctx, true); err != nil { return ctrl.Result{}, fmt.Errorf("apply with soft reboot: %w", err) } } else { diff --git a/internal/daemon/reconciler_test.go b/internal/daemon/reconciler_test.go index a4257ae..3ea02ae 100644 --- a/internal/daemon/reconciler_test.go +++ b/internal/daemon/reconciler_test.go @@ -309,7 +309,7 @@ func TestSoftReboot(t *testing.T) { ))) g.Expect(fake.getRebooted()).To(BeFalse(), "should not use systemctl reboot") - g.Expect(fake.getApplied()).To(BeTrue(), "should use Apply") + g.Expect(fake.getApplied()).To(BeTrue(), "should use ApplyUpdate") g.Expect(fake.getAppliedSoft()).To(BeTrue(), "should pass softReboot=true") } @@ -345,7 +345,7 @@ func TestRebootOnlyPolicy(t *testing.T) { ))) g.Expect(fake.getRebooted()).To(BeTrue(), "should use systemctl reboot") - g.Expect(fake.getApplied()).To(BeFalse(), "should not use Apply") + g.Expect(fake.getApplied()).To(BeFalse(), "should not use ApplyUpdate") } func TestRollback(t *testing.T) {