Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
10 changes: 10 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,16 @@ func main() {
}
}

if err = (&controller.ServerBootConfigurationReadinessReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
RequireHTTPBoot: controllers.Enabled(serverBootConfigControllerHttp),
RequireIPXEBoot: controllers.Enabled(serverBootConfigControllerPxe),
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "ServerBootConfigReadiness")
os.Exit(1)
}

//+kubebuilder:scaffold:builder

if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
Expand Down
27 changes: 27 additions & 0 deletions internal/controller/serverbootconfig_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/util/retry"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
Expand Down Expand Up @@ -143,3 +144,29 @@ func PatchServerBootConfigWithError(

return c.Status().Patch(ctx, &cur, client.MergeFrom(base))
}

// PatchServerBootConfigCondition patches a single condition on the ServerBootConfiguration status.
// Callers should only set condition types they own. Retries on conflict so concurrent condition
// writes from HTTP and PXE controllers do not lose each other's updates.
func PatchServerBootConfigCondition(
ctx context.Context,
c client.Client,
namespacedName types.NamespacedName,
condition metav1.Condition,
) error {
return retry.RetryOnConflict(retry.DefaultRetry, func() error {
var cur metalv1alpha1.ServerBootConfiguration
if fetchErr := c.Get(ctx, namespacedName, &cur); fetchErr != nil {
return fmt.Errorf("failed to fetch ServerBootConfiguration: %w", fetchErr)
}
base := cur.DeepCopy()

// Default to current generation if caller didn't set it.
if condition.ObservedGeneration == 0 {
condition.ObservedGeneration = cur.Generation
}
apimeta.SetStatusCondition(&cur.Status.Conditions, condition)

return c.Status().Patch(ctx, &cur, client.MergeFrom(base))
})
}
67 changes: 42 additions & 25 deletions internal/controller/serverbootconfiguration_http_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
bootv1alpha1 "github.com/ironcore-dev/boot-operator/api/v1alpha1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/util/retry"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
Expand Down Expand Up @@ -90,9 +91,16 @@ func (r *ServerBootConfigurationHTTPReconciler) reconcile(ctx context.Context, l
ukiURL, err := r.constructUKIURL(ctx, config.Spec.Image)
if err != nil {
log.Error(err, "Failed to construct UKI URL")
if patchErr := PatchServerBootConfigWithError(ctx, r.Client,
types.NamespacedName{Name: config.Name, Namespace: config.Namespace}, err); patchErr != nil {
return ctrl.Result{}, fmt.Errorf("failed to patch state to error: %w (original error: %w)", patchErr, err)
if patchErr := PatchServerBootConfigCondition(ctx, r.Client,
types.NamespacedName{Name: config.Name, Namespace: config.Namespace},
metav1.Condition{
Type: HTTPBootReadyConditionType,
Status: metav1.ConditionFalse,
Reason: "UKIURLConstructionFailed",
Message: err.Error(),
ObservedGeneration: config.Generation,
}); patchErr != nil {
return ctrl.Result{}, fmt.Errorf("failed to patch %s condition: %w (original error: %w)", HTTPBootReadyConditionType, patchErr, err)
}
return ctrl.Result{}, err
}
Expand Down Expand Up @@ -135,37 +143,46 @@ func (r *ServerBootConfigurationHTTPReconciler) reconcile(ctx context.Context, l
return ctrl.Result{}, fmt.Errorf("failed to get HTTPBoot config: %w", err)
}

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

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

func (r *ServerBootConfigurationHTTPReconciler) patchConfigStateFromHTTPState(ctx context.Context, httpBootConfig *bootv1alpha1.HTTPBootConfig, cfg *metalv1alpha1.ServerBootConfiguration) error {
func (r *ServerBootConfigurationHTTPReconciler) patchHTTPBootReadyConditionFromHTTPState(ctx context.Context, httpBootConfig *bootv1alpha1.HTTPBootConfig, cfg *metalv1alpha1.ServerBootConfiguration) error {
key := types.NamespacedName{Name: cfg.Name, Namespace: cfg.Namespace}
var cur metalv1alpha1.ServerBootConfiguration
if err := r.Get(ctx, key, &cur); err != nil {
return err
}
base := cur.DeepCopy()

switch httpBootConfig.Status.State {
case bootv1alpha1.HTTPBootConfigStateReady:
cur.Status.State = metalv1alpha1.ServerBootConfigurationStateReady
// Remove ImageValidation condition when transitioning to Ready
apimeta.RemoveStatusCondition(&cur.Status.Conditions, "ImageValidation")
case bootv1alpha1.HTTPBootConfigStateError:
cur.Status.State = metalv1alpha1.ServerBootConfigurationStateError
}
return retry.RetryOnConflict(retry.DefaultRetry, func() error {
var cur metalv1alpha1.ServerBootConfiguration
if err := r.Get(ctx, key, &cur); err != nil {
return err
}
base := cur.DeepCopy()

for _, c := range httpBootConfig.Status.Conditions {
apimeta.SetStatusCondition(&cur.Status.Conditions, c)
}
cond := metav1.Condition{
Type: HTTPBootReadyConditionType,
ObservedGeneration: cur.Generation,
}
switch httpBootConfig.Status.State {
case bootv1alpha1.HTTPBootConfigStateReady:
cond.Status = metav1.ConditionTrue
cond.Reason = "BootConfigReady"
cond.Message = "HTTP boot configuration is ready."
case bootv1alpha1.HTTPBootConfigStateError:
cond.Status = metav1.ConditionFalse
cond.Reason = "BootConfigError"
cond.Message = "HTTPBootConfig reported an error."
default:
cond.Status = metav1.ConditionUnknown
cond.Reason = "BootConfigPending"
cond.Message = "Waiting for HTTPBootConfig to become Ready."
}

return r.Status().Patch(ctx, &cur, client.MergeFrom(base))
apimeta.SetStatusCondition(&cur.Status.Conditions, cond)
return r.Status().Patch(ctx, &cur, client.MergeFrom(base))
})
}

// getSystemUUIDFromServer fetches the UUID from the referenced Server object.
Expand Down
64 changes: 43 additions & 21 deletions internal/controller/serverbootconfiguration_pxe_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"github.com/ironcore-dev/boot-operator/internal/registry"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/util/retry"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"

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

kernelURL, initrdURL, squashFSURL, err := r.getImageDetailsFromConfig(ctx, bootConfig)
if err != nil {
if patchErr := PatchServerBootConfigWithError(ctx, r.Client,
types.NamespacedName{Name: bootConfig.Name, Namespace: bootConfig.Namespace}, err); patchErr != nil {
return ctrl.Result{}, fmt.Errorf("failed to patch server boot config state: %w (original error: %w)", patchErr, err)
if patchErr := PatchServerBootConfigCondition(ctx, r.Client,
types.NamespacedName{Name: bootConfig.Name, Namespace: bootConfig.Namespace},
metav1.Condition{
Type: IPXEBootReadyConditionType,
Status: metav1.ConditionFalse,
Reason: "ImageDetailsFailed",
Message: err.Error(),
ObservedGeneration: bootConfig.Generation,
}); patchErr != nil {
return ctrl.Result{}, fmt.Errorf("failed to patch %s condition: %w (original error: %w)", IPXEBootReadyConditionType, patchErr, err)
}
return ctrl.Result{}, err
}
Expand Down Expand Up @@ -154,32 +162,46 @@ func (r *ServerBootConfigurationPXEReconciler) reconcile(ctx context.Context, lo
return ctrl.Result{}, fmt.Errorf("failed to get IPXE config: %w", err)
}

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

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

func (r *ServerBootConfigurationPXEReconciler) patchConfigStateFromIPXEState(ctx context.Context, config *v1alpha1.IPXEBootConfig, bootConfig *metalv1alpha1.ServerBootConfiguration) error {
bootConfigBase := bootConfig.DeepCopy()

switch config.Status.State {
case v1alpha1.IPXEBootConfigStateReady:
bootConfig.Status.State = metalv1alpha1.ServerBootConfigurationStateReady
// Remove ImageValidation condition when transitioning to Ready
apimeta.RemoveStatusCondition(&bootConfig.Status.Conditions, "ImageValidation")
case v1alpha1.IPXEBootConfigStateError:
bootConfig.Status.State = metalv1alpha1.ServerBootConfigurationStateError
}
func (r *ServerBootConfigurationPXEReconciler) patchIPXEBootReadyConditionFromIPXEState(ctx context.Context, config *v1alpha1.IPXEBootConfig, bootConfig *metalv1alpha1.ServerBootConfiguration) error {
key := types.NamespacedName{Name: bootConfig.Name, Namespace: bootConfig.Namespace}
return retry.RetryOnConflict(retry.DefaultRetry, func() error {
var cur metalv1alpha1.ServerBootConfiguration
if err := r.Get(ctx, key, &cur); err != nil {
return err
}
base := cur.DeepCopy()

for _, c := range config.Status.Conditions {
apimeta.SetStatusCondition(&bootConfig.Status.Conditions, c)
}
cond := metav1.Condition{
Type: IPXEBootReadyConditionType,
ObservedGeneration: cur.Generation,
}
switch config.Status.State {
case v1alpha1.IPXEBootConfigStateReady:
cond.Status = metav1.ConditionTrue
cond.Reason = "BootConfigReady"
cond.Message = "IPXE boot configuration is ready."
case v1alpha1.IPXEBootConfigStateError:
cond.Status = metav1.ConditionFalse
cond.Reason = "BootConfigError"
cond.Message = "IPXEBootConfig reported an error."
default:
cond.Status = metav1.ConditionUnknown
cond.Reason = "BootConfigPending"
cond.Message = "Waiting for IPXEBootConfig to become Ready."
}

return r.Status().Patch(ctx, bootConfig, client.MergeFrom(bootConfigBase))
apimeta.SetStatusCondition(&cur.Status.Conditions, cond)
return r.Status().Patch(ctx, &cur, client.MergeFrom(base))
})
}

func (r *ServerBootConfigurationPXEReconciler) getSystemUUIDFromBootConfig(ctx context.Context, config *metalv1alpha1.ServerBootConfiguration) (string, error) {
Expand Down
124 changes: 124 additions & 0 deletions internal/controller/serverbootconfiguration_readiness_controller.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
// SPDX-License-Identifier: Apache-2.0
Comment on lines +1 to +2
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use the repository-standard SPDX header text.

This new file uses 2026 in the copyright line, but the repository standard here is 2024.

🪪 Proposed fix
-// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors
 // SPDX-License-Identifier: Apache-2.0

As per coding guidelines, **/*.go: All Go files require an SPDX header: // SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors and // SPDX-License-Identifier: Apache-2.0

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors
// SPDX-License-Identifier: Apache-2.0
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/controller/serverbootconfiguration_readiness_controller.go` around
lines 1 - 2, The SPDX header in the new file uses year 2026 instead of the
repo-standard 2024; update the top two lines so they match the repository
requirement by changing "// SPDX-FileCopyrightText: 2026 SAP SE or an SAP
affiliate company and IronCore contributors" to use 2024 and keep the existing
"// SPDX-License-Identifier: Apache-2.0" line unchanged so the file header
matches the repository standard.


package controller

import (
"context"

apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/util/retry"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"

metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1"
)

const (
// Condition types written by the mode-specific converters.
HTTPBootReadyConditionType = "HTTPBootReady"
IPXEBootReadyConditionType = "IPXEBootReady"
)

// ServerBootConfigurationReadinessReconciler aggregates mode-specific readiness conditions and is the
// single writer of ServerBootConfiguration.Status.State.
type ServerBootConfigurationReadinessReconciler struct {
client.Client
Scheme *runtime.Scheme

// RequireHTTPBoot/RequireIPXEBoot are derived from boot-operator CLI controller enablement.
// There is currently no per-SBC spec hint for which boot modes should be considered active.
RequireHTTPBoot bool
RequireIPXEBoot bool
}

//+kubebuilder:rbac:groups=metal.ironcore.dev,resources=serverbootconfigurations,verbs=get;list;watch
//+kubebuilder:rbac:groups=metal.ironcore.dev,resources=serverbootconfigurations/status,verbs=get;update;patch

func (r *ServerBootConfigurationReadinessReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
cfg := &metalv1alpha1.ServerBootConfiguration{}
if err := r.Get(ctx, req.NamespacedName, cfg); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}

// If no boot modes are required (because their converters are disabled), do not mutate status.
if !r.RequireHTTPBoot && !r.RequireIPXEBoot {
return ctrl.Result{}, nil
}

desired := computeDesiredState(cfg, r.RequireHTTPBoot, r.RequireIPXEBoot)

if cfg.Status.State == desired {
return ctrl.Result{}, nil
}

// Re-fetch immediately before patching so that we use the freshest resourceVersion and do not
// overwrite conditions that HTTP/PXE controllers may have written since our initial Get above.
if err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
var fresh metalv1alpha1.ServerBootConfiguration
if err := r.Get(ctx, req.NamespacedName, &fresh); err != nil {
return err
}
// Recompute desired from the freshest conditions so we never apply a stale decision.
freshDesired := computeDesiredState(&fresh, r.RequireHTTPBoot, r.RequireIPXEBoot)
if fresh.Status.State == freshDesired {
return nil
}
base := fresh.DeepCopy()
fresh.Status.State = freshDesired
return r.Status().Patch(ctx, &fresh, client.MergeFrom(base))
}); err != nil {
return ctrl.Result{}, err
}

return ctrl.Result{}, nil
}

// computeDesiredState derives the ServerBootConfiguration state from the mode-specific conditions.
func computeDesiredState(cfg *metalv1alpha1.ServerBootConfiguration, requireHTTP, requireIPXE bool) metalv1alpha1.ServerBootConfigurationState {
desired := metalv1alpha1.ServerBootConfigurationStatePending

allReady := true
hasError := false

if requireHTTP {
c := apimeta.FindStatusCondition(cfg.Status.Conditions, HTTPBootReadyConditionType)
switch {
case c == nil:
allReady = false
case c.Status == metav1.ConditionFalse:
hasError = true
case c.Status != metav1.ConditionTrue:
allReady = false
}
}

if requireIPXE {
c := apimeta.FindStatusCondition(cfg.Status.Conditions, IPXEBootReadyConditionType)
switch {
case c == nil:
allReady = false
case c.Status == metav1.ConditionFalse:
hasError = true
case c.Status != metav1.ConditionTrue:
allReady = false
}
}

switch {
case hasError:
desired = metalv1alpha1.ServerBootConfigurationStateError
case allReady:
desired = metalv1alpha1.ServerBootConfigurationStateReady
}

return desired
}

func (r *ServerBootConfigurationReadinessReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&metalv1alpha1.ServerBootConfiguration{}).
Complete(r)
}
Loading
Loading