From 95b4ab0c07acced8b0888e667003b706df3df65e Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Wed, 26 Aug 2026 10:42:01 +0530 Subject: [PATCH 1/9] 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 | 11 +++++++++++ config/crd/bases/node.bootc.dev_bootcnodes.yaml | 8 ++++++++ internal/controller/crd_test.go | 15 +++++++++++++++ test/util/builders.go | 7 +++++++ 4 files changed, 41 insertions(+) diff --git a/api/v1alpha1/bootcnode_types.go b/api/v1alpha1/bootcnode_types.go index 381bf55a..ddeefc1b 100644 --- a/api/v1alpha1/bootcnode_types.go +++ b/api/v1alpha1/bootcnode_types.go @@ -118,6 +118,17 @@ type BootcNodeSpec struct { // credentials. Copied from the owning pool's spec. // +optional PullSecretRef *PullSecretRef `json:"pullSecretRef,omitempty"` + + // pullSecretHash is a hash of the pull secret's content, used to + // detect changes. When this value changes, the daemon re-fetches + // 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 32ff4b69..69941818 100644 --- a/config/crd/bases/node.bootc.dev_bootcnodes.yaml +++ b/config/crd/bases/node.bootc.dev_bootcnodes.yaml @@ -79,6 +79,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 7c20b6a7..8ee88d79 100644 --- a/internal/controller/crd_test.go +++ b/internal/controller/crd_test.go @@ -63,6 +63,7 @@ func TestBootcNodeCRD(t *testing.T) { node := testutil.NewNode("worker-1", testImageDigestRefA, testutil.WithNodePullSecret(testSecretName, testSecretNS), + testutil.WithNodeRebootPolicy(bootcv1alpha1.RebootPolicyAllowSoftReboot), ) // Save the spec before Create, which mutates node in-place. @@ -158,6 +159,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 318954ad..ef826a0f 100644 --- a/test/util/builders.go +++ b/test/util/builders.go @@ -241,6 +241,13 @@ func WithNodePullSecret(name, namespace 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 7110a93933252e98216d3903bee027a13f9d246f Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Wed, 26 Aug 2026 10:43:19 +0530 Subject: [PATCH 2/9] 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 64d10860..42e59536 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. @@ -160,3 +161,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 5f5f7b57..7b1b03cc 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 b7dba7676610a3df86deaf5a6c0d9cac737a5a73 Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Wed, 26 Aug 2026 10:43:42 +0530 Subject: [PATCH 3/9] 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 | 44 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/internal/controller/bootcnodepool_controller.go b/internal/controller/bootcnodepool_controller.go index 787e654b..9ba633d1 100644 --- a/internal/controller/bootcnodepool_controller.go +++ b/internal/controller/bootcnodepool_controller.go @@ -746,6 +746,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) @@ -756,6 +762,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"). @@ -786,6 +799,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 1ec38287..4b31ffe3 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 From 37dad20b7c22232b7eae9958aafc01bddfba6e8d Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Wed, 26 Aug 2026 10:43:58 +0530 Subject: [PATCH 4/9] 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 e8759338..714a7be1 100644 --- a/internal/daemon/reconciler.go +++ b/internal/daemon/reconciler.go @@ -144,11 +144,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 } @@ -159,6 +165,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, @@ -235,6 +242,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 81f654a0..a4257ae5 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 0b37d3114341692ccb256da7b55835927752fe46 Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Wed, 26 Aug 2026 10:44:13 +0530 Subject: [PATCH 5/9] 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 | 125 +++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/test/e2e/bootcnode_test.go b/test/e2e/bootcnode_test.go index 709e97b4..df0f927e 100644 --- a/test/e2e/bootcnode_test.go +++ b/test/e2e/bootcnode_test.go @@ -703,6 +703,97 @@ func TestNonExistingImage(t *testing.T) { t.Logf("Verified node %q did not stage non-existing image", nodeName) } +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() + + 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) + + 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") + + bootIDBefore := readBootID(t, ctx, env, nodeName) + t.Logf("Boot ID before update: %s", bootIDBefore) + + 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) + + 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) + + 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) + + 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)") + + g.Eventually(fetchPoolStatus(ctx, env.Client, pool)). + Should(poolAllUpdated(1, env.NodeImageUpdateDigest())) + + 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") +} + // TestPullSecretAuth provisions a worker node, creates a // dockerconfigjson Secret with credentials, and verifies that the // daemon can stage from the auth-protected registry. The auth @@ -893,6 +984,40 @@ func scaleController(t *testing.T, env *e2eutil.Env, ctx context.Context, replic "expected controller to scale to %d replica(s)", replicas) } +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 0cfed31f48358cc079caa8f2f2defa57574e6299 Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Wed, 26 Aug 2026 17:45:22 +0530 Subject: [PATCH 6/9] 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 42e59536..78e01cee 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 @@ -153,7 +175,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 { @@ -162,15 +184,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 7b1b03cc..fba756bd 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 714a7be1..6bf422b3 100644 --- a/internal/daemon/reconciler.go +++ b/internal/daemon/reconciler.go @@ -146,7 +146,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 a4257ae5..3ea02aec 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) { From f3b09aa2902fed3d5865ffd2cb779ea4e0095c8f Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Fri, 9 Oct 2026 11:24:36 +0530 Subject: [PATCH 7/9] test/e2e: Read host boot ID via nsenter Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- test/e2e/bootcnode_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/test/e2e/bootcnode_test.go b/test/e2e/bootcnode_test.go index df0f927e..b2f324d8 100644 --- a/test/e2e/bootcnode_test.go +++ b/test/e2e/bootcnode_test.go @@ -1012,6 +1012,7 @@ func readBootID(t *testing.T, ctx context.Context, env *e2eutil.Env, nodeName st kubeconfigPath := os.Getenv("KUBECONFIG") cmd := exec.CommandContext(ctx, "kubectl", "--kubeconfig", kubeconfigPath, "-n", "bootc-operator", "exec", podName, "--", + "nsenter", "--target", "1", "--mount", "--pid", "--", "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))) From f27f926b596579771aae5520194a0d5c9d4fbe97 Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Fri, 9 Oct 2026 11:25:21 +0530 Subject: [PATCH 8/9] api: Regenerate BootcNode CRD schema Include pullSecretHash in the generated CRD so the published schema matches the BootcNode API type. Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- config/crd/bases/node.bootc.dev_bootcnodes.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config/crd/bases/node.bootc.dev_bootcnodes.yaml b/config/crd/bases/node.bootc.dev_bootcnodes.yaml index 69941818..a29e18ca 100644 --- a/config/crd/bases/node.bootc.dev_bootcnodes.yaml +++ b/config/crd/bases/node.bootc.dev_bootcnodes.yaml @@ -62,6 +62,12 @@ spec: - Staged - Booted type: string + pullSecretHash: + description: |- + pullSecretHash is a hash of the pull secret's content, used to + detect changes. When this value changes, the daemon re-fetches + the secret and updates the host filesystem. + type: string pullSecretRef: description: |- pullSecretRef references a Secret containing image pull From c107e686cf79812da82d620b3d55363aadb099e1 Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Fri, 9 Oct 2026 12:10:25 +0530 Subject: [PATCH 9/9] executor: Restore bootc switch for staged apply The rebase picked up an older local commit that used bootc upgrade. Restore the switch command used by the PR before the rebase. Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- internal/bootc/executor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/bootc/executor.go b/internal/bootc/executor.go index 78e01cee..6d7a0a7c 100644 --- a/internal/bootc/executor.go +++ b/internal/bootc/executor.go @@ -34,7 +34,7 @@ func bootcSwitchArgs(image string) []string { } func bootcApplyUpdateArgs(softReboot bool) []string { - args := []string{"bootc", "upgrade", "--from-downloaded", "--apply"} + args := []string{"bootc", "switch", "--from-downloaded", "--apply"} if softReboot { args = append(args, "--soft-reboot=auto") }