Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions api/v1alpha1/bootcnode_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions config/crd/bases/node.bootc.dev_bootcnodes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 43 additions & 8 deletions internal/bootc/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -144,11 +167,23 @@ 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 {
return fmt.Errorf("running systemctl reboot: %s: %w", out, err)
}
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
}
15 changes: 15 additions & 0 deletions internal/controller/bootcnodepool_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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").
Expand Down Expand Up @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions internal/controller/crd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down
61 changes: 57 additions & 4 deletions internal/controller/membership_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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)
}
}

Expand Down
24 changes: 23 additions & 1 deletion internal/daemon/fake_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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{
Expand Down
16 changes: 12 additions & 4 deletions internal/daemon/reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading