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/bootc/executor.go b/internal/bootc/executor.go index 3452930..ec95021 100644 --- a/internal/bootc/executor.go +++ b/internal/bootc/executor.go @@ -20,6 +20,29 @@ type Executor interface { Status(ctx context.Context) ([]byte, error) Stage(ctx context.Context, image string) error Reboot(ctx context.Context) 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. @@ -41,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) @@ -67,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 @@ -144,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 { @@ -152,3 +175,15 @@ func (e *HostExecutor) Reboot(ctx context.Context) error { } return nil } + +func (e *HostExecutor) ApplyUpdate(ctx context.Context, softReboot bool) error { + log := logf.FromContext(ctx) + + cmd := e.nsenterCmd(ctx, bootcApplyUpdateArgs(softReboot)...) + 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/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/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/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) } } diff --git a/internal/daemon/fake_test.go b/internal/daemon/fake_test.go index 5f5f7b5..fba756b 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) ApplyUpdate(_ 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{ diff --git a/internal/daemon/reconciler.go b/internal/daemon/reconciler.go index 2f77c99..694a865 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.ApplyUpdate(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..3ea02ae 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 ApplyUpdate") + 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 ApplyUpdate") +} + func TestRollback(t *testing.T) { g := NewWithT(t) g.SetDefaultEventuallyTimeout(pollTimeout) 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, 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)