Skip to content

Commit 97f67dd

Browse files
committed
Support dual-mode to run both httpboot and ipxeboot without race-condition
1 parent ebd31b0 commit 97f67dd

6 files changed

Lines changed: 212 additions & 32 deletions

cmd/main.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const (
5151
serverBootConfigControllerPxe = "serverbootconfigpxe"
5252
httpBootConfigController = "httpbootconfig"
5353
serverBootConfigControllerHttp = "serverbootconfighttp"
54+
serverBootConfigReadiness = "serverbootconfigreadiness"
5455
)
5556

5657
func init() {
@@ -108,6 +109,7 @@ func main() {
108109
serverBootConfigControllerPxe,
109110
serverBootConfigControllerHttp,
110111
httpBootConfigController,
112+
serverBootConfigReadiness,
111113
)
112114

113115
flag.Var(controllers, "controllers",
@@ -284,6 +286,18 @@ func main() {
284286
}
285287
}
286288

289+
if controllers.Enabled(serverBootConfigReadiness) {
290+
if err = (&controller.ServerBootConfigurationReadinessReconciler{
291+
Client: mgr.GetClient(),
292+
Scheme: mgr.GetScheme(),
293+
RequireHTTPBoot: controllers.Enabled(serverBootConfigControllerHttp),
294+
RequireIPXEBoot: controllers.Enabled(serverBootConfigControllerPxe),
295+
}).SetupWithManager(mgr); err != nil {
296+
setupLog.Error(err, "unable to create controller", "controller", "ServerBootConfigReadiness")
297+
os.Exit(1)
298+
}
299+
}
300+
287301
//+kubebuilder:scaffold:builder
288302

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

internal/controller/serverbootconfig_helpers.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,3 +245,26 @@ func PatchServerBootConfigWithError(
245245

246246
return c.Status().Patch(ctx, &cur, client.MergeFrom(base))
247247
}
248+
249+
// PatchServerBootConfigCondition patches a single condition on the ServerBootConfiguration status.
250+
// Callers should only set condition types they own.
251+
func PatchServerBootConfigCondition(
252+
ctx context.Context,
253+
c client.Client,
254+
namespacedName types.NamespacedName,
255+
condition metav1.Condition,
256+
) error {
257+
var cur metalv1alpha1.ServerBootConfiguration
258+
if fetchErr := c.Get(ctx, namespacedName, &cur); fetchErr != nil {
259+
return fmt.Errorf("failed to fetch ServerBootConfiguration: %w", fetchErr)
260+
}
261+
base := cur.DeepCopy()
262+
263+
// Default to current generation if caller didn't set it.
264+
if condition.ObservedGeneration == 0 {
265+
condition.ObservedGeneration = cur.Generation
266+
}
267+
apimeta.SetStatusCondition(&cur.Status.Conditions, condition)
268+
269+
return c.Status().Patch(ctx, &cur, client.MergeFrom(base))
270+
}

internal/controller/serverbootconfiguration_http_controller.go

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,16 @@ func (r *ServerBootConfigurationHTTPReconciler) reconcile(ctx context.Context, l
8989
ukiURL, err := r.constructUKIURL(ctx, config.Spec.Image)
9090
if err != nil {
9191
log.Error(err, "Failed to construct UKI URL")
92-
if patchErr := PatchServerBootConfigWithError(ctx, r.Client,
93-
types.NamespacedName{Name: config.Name, Namespace: config.Namespace}, err); patchErr != nil {
94-
return ctrl.Result{}, fmt.Errorf("failed to patch state to error: %w (original error: %w)", patchErr, err)
92+
if patchErr := PatchServerBootConfigCondition(ctx, r.Client,
93+
types.NamespacedName{Name: config.Name, Namespace: config.Namespace},
94+
metav1.Condition{
95+
Type: HTTPBootReadyConditionType,
96+
Status: metav1.ConditionFalse,
97+
Reason: "UKIURLConstructionFailed",
98+
Message: err.Error(),
99+
ObservedGeneration: config.Generation,
100+
}); patchErr != nil {
101+
return ctrl.Result{}, fmt.Errorf("failed to patch %s condition: %w (original error: %w)", HTTPBootReadyConditionType, patchErr, err)
95102
}
96103
return ctrl.Result{}, err
97104
}
@@ -134,36 +141,43 @@ func (r *ServerBootConfigurationHTTPReconciler) reconcile(ctx context.Context, l
134141
return ctrl.Result{}, fmt.Errorf("failed to get HTTPBoot config: %w", err)
135142
}
136143

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

142149
log.V(1).Info("Reconciled ServerBootConfiguration")
143150
return ctrl.Result{}, nil
144151
}
145152

146-
func (r *ServerBootConfigurationHTTPReconciler) patchConfigStateFromHTTPState(ctx context.Context, httpBootConfig *bootv1alpha1.HTTPBootConfig, cfg *metalv1alpha1.ServerBootConfiguration) error {
153+
func (r *ServerBootConfigurationHTTPReconciler) patchHTTPBootReadyConditionFromHTTPState(ctx context.Context, httpBootConfig *bootv1alpha1.HTTPBootConfig, cfg *metalv1alpha1.ServerBootConfiguration) error {
147154
key := types.NamespacedName{Name: cfg.Name, Namespace: cfg.Namespace}
148155
var cur metalv1alpha1.ServerBootConfiguration
149156
if err := r.Get(ctx, key, &cur); err != nil {
150157
return err
151158
}
152159
base := cur.DeepCopy()
153160

161+
cond := metav1.Condition{
162+
Type: HTTPBootReadyConditionType,
163+
ObservedGeneration: cur.Generation,
164+
}
154165
switch httpBootConfig.Status.State {
155166
case bootv1alpha1.HTTPBootConfigStateReady:
156-
cur.Status.State = metalv1alpha1.ServerBootConfigurationStateReady
157-
// Remove ImageValidation condition when transitioning to Ready
158-
apimeta.RemoveStatusCondition(&cur.Status.Conditions, "ImageValidation")
167+
cond.Status = metav1.ConditionTrue
168+
cond.Reason = "BootConfigReady"
169+
cond.Message = "HTTP boot configuration is ready."
159170
case bootv1alpha1.HTTPBootConfigStateError:
160-
cur.Status.State = metalv1alpha1.ServerBootConfigurationStateError
161-
}
162-
163-
for _, c := range httpBootConfig.Status.Conditions {
164-
apimeta.SetStatusCondition(&cur.Status.Conditions, c)
171+
cond.Status = metav1.ConditionFalse
172+
cond.Reason = "BootConfigError"
173+
cond.Message = "HTTPBootConfig reported an error."
174+
default:
175+
cond.Status = metav1.ConditionUnknown
176+
cond.Reason = "BootConfigPending"
177+
cond.Message = "Waiting for HTTPBootConfig to become Ready."
165178
}
166179

180+
apimeta.SetStatusCondition(&cur.Status.Conditions, cond)
167181
return r.Status().Patch(ctx, &cur, client.MergeFrom(base))
168182
}
169183

internal/controller/serverbootconfiguration_pxe_controller.go

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,16 @@ func (r *ServerBootConfigurationPXEReconciler) reconcile(ctx context.Context, lo
106106

107107
kernelURL, initrdURL, squashFSURL, err := r.getImageDetailsFromConfig(ctx, bootConfig)
108108
if err != nil {
109-
if patchErr := PatchServerBootConfigWithError(ctx, r.Client,
110-
types.NamespacedName{Name: bootConfig.Name, Namespace: bootConfig.Namespace}, err); patchErr != nil {
111-
return ctrl.Result{}, fmt.Errorf("failed to patch server boot config state: %w (original error: %w)", patchErr, err)
109+
if patchErr := PatchServerBootConfigCondition(ctx, r.Client,
110+
types.NamespacedName{Name: bootConfig.Name, Namespace: bootConfig.Namespace},
111+
metav1.Condition{
112+
Type: IPXEBootReadyConditionType,
113+
Status: metav1.ConditionFalse,
114+
Reason: "ImageDetailsFailed",
115+
Message: err.Error(),
116+
ObservedGeneration: bootConfig.Generation,
117+
}); patchErr != nil {
118+
return ctrl.Result{}, fmt.Errorf("failed to patch %s condition: %w (original error: %w)", IPXEBootReadyConditionType, patchErr, err)
112119
}
113120
return ctrl.Result{}, err
114121
}
@@ -153,32 +160,44 @@ func (r *ServerBootConfigurationPXEReconciler) reconcile(ctx context.Context, lo
153160
return ctrl.Result{}, fmt.Errorf("failed to get IPXE config: %w", err)
154161
}
155162

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

161168
log.V(1).Info("Reconciled ServerBootConfiguration")
162169
return ctrl.Result{}, nil
163170
}
164171

165-
func (r *ServerBootConfigurationPXEReconciler) patchConfigStateFromIPXEState(ctx context.Context, config *v1alpha1.IPXEBootConfig, bootConfig *metalv1alpha1.ServerBootConfiguration) error {
166-
bootConfigBase := bootConfig.DeepCopy()
172+
func (r *ServerBootConfigurationPXEReconciler) patchIPXEBootReadyConditionFromIPXEState(ctx context.Context, config *v1alpha1.IPXEBootConfig, bootConfig *metalv1alpha1.ServerBootConfiguration) error {
173+
key := types.NamespacedName{Name: bootConfig.Name, Namespace: bootConfig.Namespace}
174+
var cur metalv1alpha1.ServerBootConfiguration
175+
if err := r.Get(ctx, key, &cur); err != nil {
176+
return err
177+
}
178+
base := cur.DeepCopy()
167179

180+
cond := metav1.Condition{
181+
Type: IPXEBootReadyConditionType,
182+
ObservedGeneration: cur.Generation,
183+
}
168184
switch config.Status.State {
169185
case v1alpha1.IPXEBootConfigStateReady:
170-
bootConfig.Status.State = metalv1alpha1.ServerBootConfigurationStateReady
171-
// Remove ImageValidation condition when transitioning to Ready
172-
apimeta.RemoveStatusCondition(&bootConfig.Status.Conditions, "ImageValidation")
186+
cond.Status = metav1.ConditionTrue
187+
cond.Reason = "BootConfigReady"
188+
cond.Message = "IPXE boot configuration is ready."
173189
case v1alpha1.IPXEBootConfigStateError:
174-
bootConfig.Status.State = metalv1alpha1.ServerBootConfigurationStateError
175-
}
176-
177-
for _, c := range config.Status.Conditions {
178-
apimeta.SetStatusCondition(&bootConfig.Status.Conditions, c)
190+
cond.Status = metav1.ConditionFalse
191+
cond.Reason = "BootConfigError"
192+
cond.Message = "IPXEBootConfig reported an error."
193+
default:
194+
cond.Status = metav1.ConditionUnknown
195+
cond.Reason = "BootConfigPending"
196+
cond.Message = "Waiting for IPXEBootConfig to become Ready."
179197
}
180198

181-
return r.Status().Patch(ctx, bootConfig, client.MergeFrom(bootConfigBase))
199+
apimeta.SetStatusCondition(&cur.Status.Conditions, cond)
200+
return r.Status().Patch(ctx, &cur, client.MergeFrom(base))
182201
}
183202

184203
func (r *ServerBootConfigurationPXEReconciler) getSystemUUIDFromBootConfig(ctx context.Context, config *metalv1alpha1.ServerBootConfiguration) (string, error) {
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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+
ctrl "sigs.k8s.io/controller-runtime"
13+
"sigs.k8s.io/controller-runtime/pkg/client"
14+
15+
metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1"
16+
)
17+
18+
const (
19+
// Condition types written by the mode-specific converters.
20+
HTTPBootReadyConditionType = "HTTPBootReady"
21+
IPXEBootReadyConditionType = "IPXEBootReady"
22+
)
23+
24+
// ServerBootConfigurationReadinessReconciler aggregates mode-specific readiness conditions and is the
25+
// single writer of ServerBootConfiguration.Status.State.
26+
type ServerBootConfigurationReadinessReconciler struct {
27+
client.Client
28+
Scheme *runtime.Scheme
29+
30+
// RequireHTTPBoot/RequireIPXEBoot are derived from boot-operator CLI controller enablement.
31+
// There is currently no per-SBC spec hint for which boot modes should be considered active.
32+
RequireHTTPBoot bool
33+
RequireIPXEBoot bool
34+
}
35+
36+
//+kubebuilder:rbac:groups=metal.ironcore.dev,resources=serverbootconfigurations,verbs=get;list;watch
37+
//+kubebuilder:rbac:groups=metal.ironcore.dev,resources=serverbootconfigurations/status,verbs=get;update;patch
38+
39+
func (r *ServerBootConfigurationReadinessReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
40+
cfg := &metalv1alpha1.ServerBootConfiguration{}
41+
if err := r.Get(ctx, req.NamespacedName, cfg); err != nil {
42+
return ctrl.Result{}, client.IgnoreNotFound(err)
43+
}
44+
45+
// If no boot modes are required (because their converters are disabled), do not mutate status.
46+
if !r.RequireHTTPBoot && !r.RequireIPXEBoot {
47+
return ctrl.Result{}, nil
48+
}
49+
50+
desired := metalv1alpha1.ServerBootConfigurationStatePending
51+
52+
allReady := true
53+
hasError := false
54+
55+
if r.RequireHTTPBoot {
56+
c := apimeta.FindStatusCondition(cfg.Status.Conditions, HTTPBootReadyConditionType)
57+
switch {
58+
case c == nil:
59+
allReady = false
60+
case c.Status == metav1.ConditionFalse:
61+
hasError = true
62+
case c.Status != metav1.ConditionTrue:
63+
allReady = false
64+
}
65+
}
66+
67+
if r.RequireIPXEBoot {
68+
c := apimeta.FindStatusCondition(cfg.Status.Conditions, IPXEBootReadyConditionType)
69+
switch {
70+
case c == nil:
71+
allReady = false
72+
case c.Status == metav1.ConditionFalse:
73+
hasError = true
74+
case c.Status != metav1.ConditionTrue:
75+
allReady = false
76+
}
77+
}
78+
79+
switch {
80+
case hasError:
81+
desired = metalv1alpha1.ServerBootConfigurationStateError
82+
case allReady:
83+
desired = metalv1alpha1.ServerBootConfigurationStateReady
84+
}
85+
86+
if cfg.Status.State == desired {
87+
return ctrl.Result{}, nil
88+
}
89+
90+
base := cfg.DeepCopy()
91+
cfg.Status.State = desired
92+
if err := r.Status().Patch(ctx, cfg, client.MergeFrom(base)); err != nil {
93+
return ctrl.Result{}, err
94+
}
95+
96+
return ctrl.Result{}, nil
97+
}
98+
99+
func (r *ServerBootConfigurationReadinessReconciler) SetupWithManager(mgr ctrl.Manager) error {
100+
return ctrl.NewControllerManagedBy(mgr).
101+
For(&metalv1alpha1.ServerBootConfiguration{}).
102+
Complete(r)
103+
}

internal/controller/suite_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,13 @@ func SetupTest() *corev1.Namespace {
166166
RegistryValidator: registryValidator,
167167
}).SetupWithManager(k8sManager)).To(Succeed())
168168

169+
Expect((&ServerBootConfigurationReadinessReconciler{
170+
Client: k8sManager.GetClient(),
171+
Scheme: k8sManager.GetScheme(),
172+
RequireHTTPBoot: true,
173+
RequireIPXEBoot: true,
174+
}).SetupWithManager(k8sManager)).To(Succeed())
175+
169176
go func() {
170177
defer GinkgoRecover()
171178
Expect(k8sManager.Start(mgrCtx)).To(Succeed(), "failed to start manager")

0 commit comments

Comments
 (0)