Skip to content

Commit 6f510fd

Browse files
committed
Support dual-mode to run both httpboot and ipxeboot without race-condition
1 parent c61c895 commit 6f510fd

7 files changed

Lines changed: 682 additions & 46 deletions

cmd/main.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,16 @@ func main() {
294294
}
295295
}
296296

297+
if err = (&controller.ServerBootConfigurationReadinessReconciler{
298+
Client: mgr.GetClient(),
299+
Scheme: mgr.GetScheme(),
300+
RequireHTTPBoot: controllers.Enabled(serverBootConfigControllerHttp),
301+
RequireIPXEBoot: controllers.Enabled(serverBootConfigControllerPxe),
302+
}).SetupWithManager(mgr); err != nil {
303+
setupLog.Error(err, "unable to create controller", "controller", "ServerBootConfigReadiness")
304+
os.Exit(1)
305+
}
306+
297307
//+kubebuilder:scaffold:builder
298308

299309
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {

internal/controller/serverbootconfig_helpers.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
apimeta "k8s.io/apimachinery/pkg/api/meta"
2828
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2929
"k8s.io/apimachinery/pkg/types"
30+
"k8s.io/client-go/util/retry"
3031
ctrl "sigs.k8s.io/controller-runtime"
3132
"sigs.k8s.io/controller-runtime/pkg/client"
3233
"sigs.k8s.io/controller-runtime/pkg/reconcile"
@@ -143,3 +144,29 @@ func PatchServerBootConfigWithError(
143144

144145
return c.Status().Patch(ctx, &cur, client.MergeFrom(base))
145146
}
147+
148+
// PatchServerBootConfigCondition patches a single condition on the ServerBootConfiguration status.
149+
// Callers should only set condition types they own. Retries on conflict so concurrent condition
150+
// writes from HTTP and PXE controllers do not lose each other's updates.
151+
func PatchServerBootConfigCondition(
152+
ctx context.Context,
153+
c client.Client,
154+
namespacedName types.NamespacedName,
155+
condition metav1.Condition,
156+
) error {
157+
return retry.RetryOnConflict(retry.DefaultRetry, func() error {
158+
var cur metalv1alpha1.ServerBootConfiguration
159+
if fetchErr := c.Get(ctx, namespacedName, &cur); fetchErr != nil {
160+
return fmt.Errorf("failed to fetch ServerBootConfiguration: %w", fetchErr)
161+
}
162+
base := cur.DeepCopy()
163+
164+
// Default to current generation if caller didn't set it.
165+
if condition.ObservedGeneration == 0 {
166+
condition.ObservedGeneration = cur.Generation
167+
}
168+
apimeta.SetStatusCondition(&cur.Status.Conditions, condition)
169+
170+
return c.Status().Patch(ctx, &cur, client.MergeFrom(base))
171+
})
172+
}

internal/controller/serverbootconfiguration_http_controller.go

Lines changed: 42 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
bootv1alpha1 "github.com/ironcore-dev/boot-operator/api/v1alpha1"
2525
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2626
"k8s.io/apimachinery/pkg/runtime"
27+
"k8s.io/client-go/util/retry"
2728
ctrl "sigs.k8s.io/controller-runtime"
2829
"sigs.k8s.io/controller-runtime/pkg/client"
2930
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
@@ -90,9 +91,16 @@ func (r *ServerBootConfigurationHTTPReconciler) reconcile(ctx context.Context, l
9091
ukiURL, err := r.constructUKIURL(ctx, config.Spec.Image)
9192
if err != nil {
9293
log.Error(err, "Failed to construct UKI URL")
93-
if patchErr := PatchServerBootConfigWithError(ctx, r.Client,
94-
types.NamespacedName{Name: config.Name, Namespace: config.Namespace}, err); patchErr != nil {
95-
return ctrl.Result{}, fmt.Errorf("failed to patch state to error: %w (original error: %w)", patchErr, err)
94+
if patchErr := PatchServerBootConfigCondition(ctx, r.Client,
95+
types.NamespacedName{Name: config.Name, Namespace: config.Namespace},
96+
metav1.Condition{
97+
Type: HTTPBootReadyConditionType,
98+
Status: metav1.ConditionFalse,
99+
Reason: "UKIURLConstructionFailed",
100+
Message: err.Error(),
101+
ObservedGeneration: config.Generation,
102+
}); patchErr != nil {
103+
return ctrl.Result{}, fmt.Errorf("failed to patch %s condition: %w (original error: %w)", HTTPBootReadyConditionType, patchErr, err)
96104
}
97105
return ctrl.Result{}, err
98106
}
@@ -135,37 +143,46 @@ func (r *ServerBootConfigurationHTTPReconciler) reconcile(ctx context.Context, l
135143
return ctrl.Result{}, fmt.Errorf("failed to get HTTPBoot config: %w", err)
136144
}
137145

138-
if err := r.patchConfigStateFromHTTPState(ctx, httpBootConfig, config); err != nil {
139-
return ctrl.Result{}, fmt.Errorf("failed to patch server boot config state to %s: %w", httpBootConfig.Status.State, err)
146+
if err := r.patchHTTPBootReadyConditionFromHTTPState(ctx, httpBootConfig, config); err != nil {
147+
return ctrl.Result{}, fmt.Errorf("failed to patch %s condition from HTTPBootConfig state %s: %w", HTTPBootReadyConditionType, httpBootConfig.Status.State, err)
140148
}
141-
log.V(1).Info("Patched server boot config state")
149+
log.V(1).Info("Patched server boot config condition", "condition", HTTPBootReadyConditionType)
142150

143151
log.V(1).Info("Reconciled ServerBootConfiguration")
144152
return ctrl.Result{}, nil
145153
}
146154

147-
func (r *ServerBootConfigurationHTTPReconciler) patchConfigStateFromHTTPState(ctx context.Context, httpBootConfig *bootv1alpha1.HTTPBootConfig, cfg *metalv1alpha1.ServerBootConfiguration) error {
155+
func (r *ServerBootConfigurationHTTPReconciler) patchHTTPBootReadyConditionFromHTTPState(ctx context.Context, httpBootConfig *bootv1alpha1.HTTPBootConfig, cfg *metalv1alpha1.ServerBootConfiguration) error {
148156
key := types.NamespacedName{Name: cfg.Name, Namespace: cfg.Namespace}
149-
var cur metalv1alpha1.ServerBootConfiguration
150-
if err := r.Get(ctx, key, &cur); err != nil {
151-
return err
152-
}
153-
base := cur.DeepCopy()
154-
155-
switch httpBootConfig.Status.State {
156-
case bootv1alpha1.HTTPBootConfigStateReady:
157-
cur.Status.State = metalv1alpha1.ServerBootConfigurationStateReady
158-
// Remove ImageValidation condition when transitioning to Ready
159-
apimeta.RemoveStatusCondition(&cur.Status.Conditions, "ImageValidation")
160-
case bootv1alpha1.HTTPBootConfigStateError:
161-
cur.Status.State = metalv1alpha1.ServerBootConfigurationStateError
162-
}
157+
return retry.RetryOnConflict(retry.DefaultRetry, func() error {
158+
var cur metalv1alpha1.ServerBootConfiguration
159+
if err := r.Get(ctx, key, &cur); err != nil {
160+
return err
161+
}
162+
base := cur.DeepCopy()
163163

164-
for _, c := range httpBootConfig.Status.Conditions {
165-
apimeta.SetStatusCondition(&cur.Status.Conditions, c)
166-
}
164+
cond := metav1.Condition{
165+
Type: HTTPBootReadyConditionType,
166+
ObservedGeneration: cur.Generation,
167+
}
168+
switch httpBootConfig.Status.State {
169+
case bootv1alpha1.HTTPBootConfigStateReady:
170+
cond.Status = metav1.ConditionTrue
171+
cond.Reason = "BootConfigReady"
172+
cond.Message = "HTTP boot configuration is ready."
173+
case bootv1alpha1.HTTPBootConfigStateError:
174+
cond.Status = metav1.ConditionFalse
175+
cond.Reason = "BootConfigError"
176+
cond.Message = "HTTPBootConfig reported an error."
177+
default:
178+
cond.Status = metav1.ConditionUnknown
179+
cond.Reason = "BootConfigPending"
180+
cond.Message = "Waiting for HTTPBootConfig to become Ready."
181+
}
167182

168-
return r.Status().Patch(ctx, &cur, client.MergeFrom(base))
183+
apimeta.SetStatusCondition(&cur.Status.Conditions, cond)
184+
return r.Status().Patch(ctx, &cur, client.MergeFrom(base))
185+
})
169186
}
170187

171188
// getSystemUUIDFromServer fetches the UUID from the referenced Server object.

internal/controller/serverbootconfiguration_pxe_controller.go

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
"github.com/ironcore-dev/boot-operator/internal/registry"
3232
apimeta "k8s.io/apimachinery/pkg/api/meta"
3333
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
34+
"k8s.io/client-go/util/retry"
3435
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
3536

3637
"github.com/go-logr/logr"
@@ -107,9 +108,16 @@ func (r *ServerBootConfigurationPXEReconciler) reconcile(ctx context.Context, lo
107108

108109
kernelURL, initrdURL, squashFSURL, err := r.getImageDetailsFromConfig(ctx, bootConfig)
109110
if err != nil {
110-
if patchErr := PatchServerBootConfigWithError(ctx, r.Client,
111-
types.NamespacedName{Name: bootConfig.Name, Namespace: bootConfig.Namespace}, err); patchErr != nil {
112-
return ctrl.Result{}, fmt.Errorf("failed to patch server boot config state: %w (original error: %w)", patchErr, err)
111+
if patchErr := PatchServerBootConfigCondition(ctx, r.Client,
112+
types.NamespacedName{Name: bootConfig.Name, Namespace: bootConfig.Namespace},
113+
metav1.Condition{
114+
Type: IPXEBootReadyConditionType,
115+
Status: metav1.ConditionFalse,
116+
Reason: "ImageDetailsFailed",
117+
Message: err.Error(),
118+
ObservedGeneration: bootConfig.Generation,
119+
}); patchErr != nil {
120+
return ctrl.Result{}, fmt.Errorf("failed to patch %s condition: %w (original error: %w)", IPXEBootReadyConditionType, patchErr, err)
113121
}
114122
return ctrl.Result{}, err
115123
}
@@ -154,32 +162,46 @@ func (r *ServerBootConfigurationPXEReconciler) reconcile(ctx context.Context, lo
154162
return ctrl.Result{}, fmt.Errorf("failed to get IPXE config: %w", err)
155163
}
156164

157-
if err := r.patchConfigStateFromIPXEState(ctx, config, bootConfig); err != nil {
158-
return ctrl.Result{}, fmt.Errorf("failed to patch server boot config state to %s: %w", config.Status.State, err)
165+
if err := r.patchIPXEBootReadyConditionFromIPXEState(ctx, config, bootConfig); err != nil {
166+
return ctrl.Result{}, fmt.Errorf("failed to patch %s condition from IPXEBootConfig state %s: %w", IPXEBootReadyConditionType, config.Status.State, err)
159167
}
160-
log.V(1).Info("Patched server boot config state")
168+
log.V(1).Info("Patched server boot config condition", "condition", IPXEBootReadyConditionType)
161169

162170
log.V(1).Info("Reconciled ServerBootConfiguration")
163171
return ctrl.Result{}, nil
164172
}
165173

166-
func (r *ServerBootConfigurationPXEReconciler) patchConfigStateFromIPXEState(ctx context.Context, config *v1alpha1.IPXEBootConfig, bootConfig *metalv1alpha1.ServerBootConfiguration) error {
167-
bootConfigBase := bootConfig.DeepCopy()
168-
169-
switch config.Status.State {
170-
case v1alpha1.IPXEBootConfigStateReady:
171-
bootConfig.Status.State = metalv1alpha1.ServerBootConfigurationStateReady
172-
// Remove ImageValidation condition when transitioning to Ready
173-
apimeta.RemoveStatusCondition(&bootConfig.Status.Conditions, "ImageValidation")
174-
case v1alpha1.IPXEBootConfigStateError:
175-
bootConfig.Status.State = metalv1alpha1.ServerBootConfigurationStateError
176-
}
174+
func (r *ServerBootConfigurationPXEReconciler) patchIPXEBootReadyConditionFromIPXEState(ctx context.Context, config *v1alpha1.IPXEBootConfig, bootConfig *metalv1alpha1.ServerBootConfiguration) error {
175+
key := types.NamespacedName{Name: bootConfig.Name, Namespace: bootConfig.Namespace}
176+
return retry.RetryOnConflict(retry.DefaultRetry, func() error {
177+
var cur metalv1alpha1.ServerBootConfiguration
178+
if err := r.Get(ctx, key, &cur); err != nil {
179+
return err
180+
}
181+
base := cur.DeepCopy()
177182

178-
for _, c := range config.Status.Conditions {
179-
apimeta.SetStatusCondition(&bootConfig.Status.Conditions, c)
180-
}
183+
cond := metav1.Condition{
184+
Type: IPXEBootReadyConditionType,
185+
ObservedGeneration: cur.Generation,
186+
}
187+
switch config.Status.State {
188+
case v1alpha1.IPXEBootConfigStateReady:
189+
cond.Status = metav1.ConditionTrue
190+
cond.Reason = "BootConfigReady"
191+
cond.Message = "IPXE boot configuration is ready."
192+
case v1alpha1.IPXEBootConfigStateError:
193+
cond.Status = metav1.ConditionFalse
194+
cond.Reason = "BootConfigError"
195+
cond.Message = "IPXEBootConfig reported an error."
196+
default:
197+
cond.Status = metav1.ConditionUnknown
198+
cond.Reason = "BootConfigPending"
199+
cond.Message = "Waiting for IPXEBootConfig to become Ready."
200+
}
181201

182-
return r.Status().Patch(ctx, bootConfig, client.MergeFrom(bootConfigBase))
202+
apimeta.SetStatusCondition(&cur.Status.Conditions, cond)
203+
return r.Status().Patch(ctx, &cur, client.MergeFrom(base))
204+
})
183205
}
184206

185207
func (r *ServerBootConfigurationPXEReconciler) getSystemUUIDFromBootConfig(ctx context.Context, config *metalv1alpha1.ServerBootConfiguration) (string, error) {
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package controller
5+
6+
import (
7+
"context"
8+
9+
apimeta "k8s.io/apimachinery/pkg/api/meta"
10+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
11+
"k8s.io/apimachinery/pkg/runtime"
12+
"k8s.io/client-go/util/retry"
13+
ctrl "sigs.k8s.io/controller-runtime"
14+
"sigs.k8s.io/controller-runtime/pkg/client"
15+
16+
metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1"
17+
)
18+
19+
const (
20+
// Condition types written by the mode-specific converters.
21+
HTTPBootReadyConditionType = "HTTPBootReady"
22+
IPXEBootReadyConditionType = "IPXEBootReady"
23+
)
24+
25+
// ServerBootConfigurationReadinessReconciler aggregates mode-specific readiness conditions and is the
26+
// single writer of ServerBootConfiguration.Status.State.
27+
type ServerBootConfigurationReadinessReconciler struct {
28+
client.Client
29+
Scheme *runtime.Scheme
30+
31+
// RequireHTTPBoot/RequireIPXEBoot are derived from boot-operator CLI controller enablement.
32+
// There is currently no per-SBC spec hint for which boot modes should be considered active.
33+
RequireHTTPBoot bool
34+
RequireIPXEBoot bool
35+
}
36+
37+
//+kubebuilder:rbac:groups=metal.ironcore.dev,resources=serverbootconfigurations,verbs=get;list;watch
38+
//+kubebuilder:rbac:groups=metal.ironcore.dev,resources=serverbootconfigurations/status,verbs=get;update;patch
39+
40+
func (r *ServerBootConfigurationReadinessReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
41+
cfg := &metalv1alpha1.ServerBootConfiguration{}
42+
if err := r.Get(ctx, req.NamespacedName, cfg); err != nil {
43+
return ctrl.Result{}, client.IgnoreNotFound(err)
44+
}
45+
46+
// If no boot modes are required (because their converters are disabled), do not mutate status.
47+
if !r.RequireHTTPBoot && !r.RequireIPXEBoot {
48+
return ctrl.Result{}, nil
49+
}
50+
51+
desired := computeDesiredState(cfg, r.RequireHTTPBoot, r.RequireIPXEBoot)
52+
53+
if cfg.Status.State == desired {
54+
return ctrl.Result{}, nil
55+
}
56+
57+
// Re-fetch immediately before patching so that we use the freshest resourceVersion and do not
58+
// overwrite conditions that HTTP/PXE controllers may have written since our initial Get above.
59+
if err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
60+
var fresh metalv1alpha1.ServerBootConfiguration
61+
if err := r.Get(ctx, req.NamespacedName, &fresh); err != nil {
62+
return err
63+
}
64+
// Recompute desired from the freshest conditions so we never apply a stale decision.
65+
freshDesired := computeDesiredState(&fresh, r.RequireHTTPBoot, r.RequireIPXEBoot)
66+
if fresh.Status.State == freshDesired {
67+
return nil
68+
}
69+
base := fresh.DeepCopy()
70+
fresh.Status.State = freshDesired
71+
return r.Status().Patch(ctx, &fresh, client.MergeFrom(base))
72+
}); err != nil {
73+
return ctrl.Result{}, err
74+
}
75+
76+
return ctrl.Result{}, nil
77+
}
78+
79+
// computeDesiredState derives the ServerBootConfiguration state from the mode-specific conditions.
80+
func computeDesiredState(cfg *metalv1alpha1.ServerBootConfiguration, requireHTTP, requireIPXE bool) metalv1alpha1.ServerBootConfigurationState {
81+
desired := metalv1alpha1.ServerBootConfigurationStatePending
82+
83+
allReady := true
84+
hasError := false
85+
86+
if requireHTTP {
87+
c := apimeta.FindStatusCondition(cfg.Status.Conditions, HTTPBootReadyConditionType)
88+
switch {
89+
case c == nil:
90+
allReady = false
91+
case c.Status == metav1.ConditionFalse:
92+
hasError = true
93+
case c.Status != metav1.ConditionTrue:
94+
allReady = false
95+
}
96+
}
97+
98+
if requireIPXE {
99+
c := apimeta.FindStatusCondition(cfg.Status.Conditions, IPXEBootReadyConditionType)
100+
switch {
101+
case c == nil:
102+
allReady = false
103+
case c.Status == metav1.ConditionFalse:
104+
hasError = true
105+
case c.Status != metav1.ConditionTrue:
106+
allReady = false
107+
}
108+
}
109+
110+
switch {
111+
case hasError:
112+
desired = metalv1alpha1.ServerBootConfigurationStateError
113+
case allReady:
114+
desired = metalv1alpha1.ServerBootConfigurationStateReady
115+
}
116+
117+
return desired
118+
}
119+
120+
func (r *ServerBootConfigurationReadinessReconciler) SetupWithManager(mgr ctrl.Manager) error {
121+
return ctrl.NewControllerManagedBy(mgr).
122+
For(&metalv1alpha1.ServerBootConfiguration{}).
123+
Complete(r)
124+
}

0 commit comments

Comments
 (0)