Skip to content
11 changes: 11 additions & 0 deletions api/v1alpha1/bootcnode_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions config/crd/bases/node.bootc.dev_bootcnodes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -79,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", "switch", "--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 @@ -152,11 +175,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 @@ -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)
Expand All @@ -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").
Expand Down Expand Up @@ -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 {
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 @@ -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.
Expand Down Expand Up @@ -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()
Expand Down
44 changes: 44 additions & 0 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
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 @@ -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.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 @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading