From 6f510fd6f3efb707bcdd2397b5efc69757195281 Mon Sep 17 00:00:00 2001 From: Hardik Dodiya Date: Thu, 2 Apr 2026 11:56:51 +0200 Subject: [PATCH 1/2] Support dual-mode to run both httpboot and ipxeboot without race-condition --- cmd/main.go | 10 + .../controller/serverbootconfig_helpers.go | 27 ++ ...serverbootconfiguration_http_controller.go | 67 ++- .../serverbootconfiguration_pxe_controller.go | 64 ++- ...rbootconfiguration_readiness_controller.go | 124 +++++ ...configuration_readiness_controller_test.go | 429 ++++++++++++++++++ internal/controller/suite_test.go | 7 + 7 files changed, 682 insertions(+), 46 deletions(-) create mode 100644 internal/controller/serverbootconfiguration_readiness_controller.go create mode 100644 internal/controller/serverbootconfiguration_readiness_controller_test.go diff --git a/cmd/main.go b/cmd/main.go index cd0e132..1a45a83 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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 { diff --git a/internal/controller/serverbootconfig_helpers.go b/internal/controller/serverbootconfig_helpers.go index 6a0b00a..201af2f 100644 --- a/internal/controller/serverbootconfig_helpers.go +++ b/internal/controller/serverbootconfig_helpers.go @@ -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" @@ -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)) + }) +} diff --git a/internal/controller/serverbootconfiguration_http_controller.go b/internal/controller/serverbootconfiguration_http_controller.go index 7e227fe..d4e807f 100644 --- a/internal/controller/serverbootconfiguration_http_controller.go +++ b/internal/controller/serverbootconfiguration_http_controller.go @@ -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" @@ -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 } @@ -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. diff --git a/internal/controller/serverbootconfiguration_pxe_controller.go b/internal/controller/serverbootconfiguration_pxe_controller.go index 5bebc00..c3787e6 100644 --- a/internal/controller/serverbootconfiguration_pxe_controller.go +++ b/internal/controller/serverbootconfiguration_pxe_controller.go @@ -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" @@ -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 } @@ -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) { diff --git a/internal/controller/serverbootconfiguration_readiness_controller.go b/internal/controller/serverbootconfiguration_readiness_controller.go new file mode 100644 index 0000000..2aa3830 --- /dev/null +++ b/internal/controller/serverbootconfiguration_readiness_controller.go @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +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) +} diff --git a/internal/controller/serverbootconfiguration_readiness_controller_test.go b/internal/controller/serverbootconfiguration_readiness_controller_test.go new file mode 100644 index 0000000..4502c12 --- /dev/null +++ b/internal/controller/serverbootconfiguration_readiness_controller_test.go @@ -0,0 +1,429 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + + metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/config" + . "sigs.k8s.io/controller-runtime/pkg/envtest/komega" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" +) + +// setupReadinessTest spins up a manager with only the readiness reconciler registered. +// This avoids the HTTP/PXE converters racing with test-controlled condition writes. +func setupReadinessTest(requireHTTP, requireIPXE bool) *corev1.Namespace { + ns := &corev1.Namespace{} + + BeforeEach(func(ctx SpecContext) { + mgrCtx, cancel := context.WithCancel(context.Background()) + DeferCleanup(cancel) + + *ns = corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{GenerateName: "readiness-test-"}, + } + Expect(k8sClient.Create(ctx, ns)).To(Succeed()) + DeferCleanup(k8sClient.Delete, ns) + + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: k8sClient.Scheme(), + Metrics: metricsserver.Options{BindAddress: "0"}, + Controller: config.Controller{ + SkipNameValidation: ptr.To(true), + }, + }) + Expect(err).NotTo(HaveOccurred()) + + Expect((&ServerBootConfigurationReadinessReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + RequireHTTPBoot: requireHTTP, + RequireIPXEBoot: requireIPXE, + }).SetupWithManager(mgr)).To(Succeed()) + + go func() { + defer GinkgoRecover() + Expect(mgr.Start(mgrCtx)).To(Succeed()) + }() + }) + + return ns +} + +// setCondition directly patches a condition on a ServerBootConfiguration status. +func setCondition(ctx context.Context, key types.NamespacedName, cond metav1.Condition) { + GinkgoHelper() + Eventually(func(g Gomega) { + var sbc metalv1alpha1.ServerBootConfiguration + g.Expect(k8sClient.Get(ctx, key, &sbc)).To(Succeed()) + base := sbc.DeepCopy() + if cond.ObservedGeneration == 0 { + cond.ObservedGeneration = sbc.Generation + } + apimeta.SetStatusCondition(&sbc.Status.Conditions, cond) + g.Expect(k8sClient.Status().Patch(ctx, &sbc, client.MergeFrom(base))).To(Succeed()) + }).Should(Succeed()) +} + +// newSBC creates a minimal ServerBootConfiguration for testing. +func newSBC(ns string) *metalv1alpha1.ServerBootConfiguration { + return &metalv1alpha1.ServerBootConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "sbc-", + Namespace: ns, + }, + Spec: metalv1alpha1.ServerBootConfigurationSpec{ + ServerRef: corev1.LocalObjectReference{Name: "test-server"}, + Image: "ghcr.io/ironcore-dev/test:latest", + }, + } +} + +var _ = Describe("ServerBootConfigurationReadinessReconciler", func() { + + Describe("both HTTP and IPXE required", func() { + ns := setupReadinessTest(true, true) + + It("should set state to Ready when both conditions are True", func(ctx SpecContext) { + sbc := newSBC(ns.Name) + Expect(k8sClient.Create(ctx, sbc)).To(Succeed()) + key := types.NamespacedName{Name: sbc.Name, Namespace: sbc.Namespace} + + setCondition(ctx, key, metav1.Condition{ + Type: HTTPBootReadyConditionType, + Status: metav1.ConditionTrue, + Reason: "BootConfigReady", + }) + setCondition(ctx, key, metav1.Condition{ + Type: IPXEBootReadyConditionType, + Status: metav1.ConditionTrue, + Reason: "BootConfigReady", + }) + + Eventually(Object(sbc)).Should( + HaveField("Status.State", metalv1alpha1.ServerBootConfigurationStateReady), + ) + }) + + It("should set state to Error when HTTPBoot condition is False", func(ctx SpecContext) { + sbc := newSBC(ns.Name) + Expect(k8sClient.Create(ctx, sbc)).To(Succeed()) + key := types.NamespacedName{Name: sbc.Name, Namespace: sbc.Namespace} + + setCondition(ctx, key, metav1.Condition{ + Type: HTTPBootReadyConditionType, + Status: metav1.ConditionFalse, + Reason: "BootConfigError", + }) + setCondition(ctx, key, metav1.Condition{ + Type: IPXEBootReadyConditionType, + Status: metav1.ConditionTrue, + Reason: "BootConfigReady", + }) + + Eventually(Object(sbc)).Should( + HaveField("Status.State", metalv1alpha1.ServerBootConfigurationStateError), + ) + }) + + It("should set state to Error when IPXEBoot condition is False", func(ctx SpecContext) { + sbc := newSBC(ns.Name) + Expect(k8sClient.Create(ctx, sbc)).To(Succeed()) + key := types.NamespacedName{Name: sbc.Name, Namespace: sbc.Namespace} + + setCondition(ctx, key, metav1.Condition{ + Type: HTTPBootReadyConditionType, + Status: metav1.ConditionTrue, + Reason: "BootConfigReady", + }) + setCondition(ctx, key, metav1.Condition{ + Type: IPXEBootReadyConditionType, + Status: metav1.ConditionFalse, + Reason: "BootConfigError", + }) + + Eventually(Object(sbc)).Should( + HaveField("Status.State", metalv1alpha1.ServerBootConfigurationStateError), + ) + }) + + It("should set state to Pending when one condition is Unknown", func(ctx SpecContext) { + sbc := newSBC(ns.Name) + Expect(k8sClient.Create(ctx, sbc)).To(Succeed()) + key := types.NamespacedName{Name: sbc.Name, Namespace: sbc.Namespace} + + setCondition(ctx, key, metav1.Condition{ + Type: HTTPBootReadyConditionType, + Status: metav1.ConditionTrue, + Reason: "BootConfigReady", + }) + setCondition(ctx, key, metav1.Condition{ + Type: IPXEBootReadyConditionType, + Status: metav1.ConditionUnknown, + Reason: "BootConfigPending", + }) + + Eventually(Object(sbc)).Should( + HaveField("Status.State", metalv1alpha1.ServerBootConfigurationStatePending), + ) + }) + + It("should set state to Pending when no conditions are set yet", func(ctx SpecContext) { + sbc := newSBC(ns.Name) + Expect(k8sClient.Create(ctx, sbc)).To(Succeed()) + + // The reconciler fires on create; with no conditions it must land on Pending. + Eventually(Object(sbc)).Should( + HaveField("Status.State", metalv1alpha1.ServerBootConfigurationStatePending), + ) + }) + + It("should transition from Error back to Ready when conditions recover", func(ctx SpecContext) { + sbc := newSBC(ns.Name) + Expect(k8sClient.Create(ctx, sbc)).To(Succeed()) + key := types.NamespacedName{Name: sbc.Name, Namespace: sbc.Namespace} + + // First: set error + setCondition(ctx, key, metav1.Condition{ + Type: HTTPBootReadyConditionType, + Status: metav1.ConditionFalse, + Reason: "BootConfigError", + }) + setCondition(ctx, key, metav1.Condition{ + Type: IPXEBootReadyConditionType, + Status: metav1.ConditionTrue, + Reason: "BootConfigReady", + }) + Eventually(Object(sbc)).Should( + HaveField("Status.State", metalv1alpha1.ServerBootConfigurationStateError), + ) + + // Recover + setCondition(ctx, key, metav1.Condition{ + Type: HTTPBootReadyConditionType, + Status: metav1.ConditionTrue, + Reason: "BootConfigReady", + }) + Eventually(Object(sbc)).Should( + HaveField("Status.State", metalv1alpha1.ServerBootConfigurationStateReady), + ) + }) + }) + + Describe("only HTTP required", func() { + ns := setupReadinessTest(true, false) + + It("should set state to Ready when only HTTPBoot condition is True", func(ctx SpecContext) { + sbc := newSBC(ns.Name) + Expect(k8sClient.Create(ctx, sbc)).To(Succeed()) + key := types.NamespacedName{Name: sbc.Name, Namespace: sbc.Namespace} + + setCondition(ctx, key, metav1.Condition{ + Type: HTTPBootReadyConditionType, + Status: metav1.ConditionTrue, + Reason: "BootConfigReady", + }) + + Eventually(Object(sbc)).Should( + HaveField("Status.State", metalv1alpha1.ServerBootConfigurationStateReady), + ) + }) + + It("should set state to Error when HTTPBoot condition is False", func(ctx SpecContext) { + sbc := newSBC(ns.Name) + Expect(k8sClient.Create(ctx, sbc)).To(Succeed()) + key := types.NamespacedName{Name: sbc.Name, Namespace: sbc.Namespace} + + setCondition(ctx, key, metav1.Condition{ + Type: HTTPBootReadyConditionType, + Status: metav1.ConditionFalse, + Reason: "BootConfigError", + }) + + Eventually(Object(sbc)).Should( + HaveField("Status.State", metalv1alpha1.ServerBootConfigurationStateError), + ) + }) + + It("should ignore IPXEBoot condition entirely", func(ctx SpecContext) { + sbc := newSBC(ns.Name) + Expect(k8sClient.Create(ctx, sbc)).To(Succeed()) + key := types.NamespacedName{Name: sbc.Name, Namespace: sbc.Namespace} + + // Set IPXE error; it should be ignored since only HTTP is required. + setCondition(ctx, key, metav1.Condition{ + Type: IPXEBootReadyConditionType, + Status: metav1.ConditionFalse, + Reason: "BootConfigError", + }) + setCondition(ctx, key, metav1.Condition{ + Type: HTTPBootReadyConditionType, + Status: metav1.ConditionTrue, + Reason: "BootConfigReady", + }) + + Eventually(Object(sbc)).Should( + HaveField("Status.State", metalv1alpha1.ServerBootConfigurationStateReady), + ) + }) + }) + + Describe("only IPXE required", func() { + ns := setupReadinessTest(false, true) + + It("should set state to Ready when only IPXEBoot condition is True", func(ctx SpecContext) { + sbc := newSBC(ns.Name) + Expect(k8sClient.Create(ctx, sbc)).To(Succeed()) + key := types.NamespacedName{Name: sbc.Name, Namespace: sbc.Namespace} + + setCondition(ctx, key, metav1.Condition{ + Type: IPXEBootReadyConditionType, + Status: metav1.ConditionTrue, + Reason: "BootConfigReady", + }) + + Eventually(Object(sbc)).Should( + HaveField("Status.State", metalv1alpha1.ServerBootConfigurationStateReady), + ) + }) + + It("should ignore HTTPBoot condition entirely", func(ctx SpecContext) { + sbc := newSBC(ns.Name) + Expect(k8sClient.Create(ctx, sbc)).To(Succeed()) + key := types.NamespacedName{Name: sbc.Name, Namespace: sbc.Namespace} + + // Set HTTP error; it should be ignored since only IPXE is required. + setCondition(ctx, key, metav1.Condition{ + Type: HTTPBootReadyConditionType, + Status: metav1.ConditionFalse, + Reason: "BootConfigError", + }) + setCondition(ctx, key, metav1.Condition{ + Type: IPXEBootReadyConditionType, + Status: metav1.ConditionTrue, + Reason: "BootConfigReady", + }) + + Eventually(Object(sbc)).Should( + HaveField("Status.State", metalv1alpha1.ServerBootConfigurationStateReady), + ) + }) + }) + + Describe("neither HTTP nor IPXE required", func() { + ns := setupReadinessTest(false, false) + + It("should not mutate Status.State", func(ctx SpecContext) { + sbc := newSBC(ns.Name) + Expect(k8sClient.Create(ctx, sbc)).To(Succeed()) + + // Even after conditions are set, state must not change. + key := types.NamespacedName{Name: sbc.Name, Namespace: sbc.Namespace} + setCondition(ctx, key, metav1.Condition{ + Type: HTTPBootReadyConditionType, + Status: metav1.ConditionTrue, + Reason: "BootConfigReady", + }) + setCondition(ctx, key, metav1.Condition{ + Type: IPXEBootReadyConditionType, + Status: metav1.ConditionTrue, + Reason: "BootConfigReady", + }) + + Consistently(Object(sbc)).Should( + HaveField("Status.State", metalv1alpha1.ServerBootConfigurationState("")), + ) + }) + }) +}) + +// computeDesiredStateTests exercises the pure aggregation logic without any controller machinery. +var _ = Describe("computeDesiredState", func() { + makeCondition := func(condType string, status metav1.ConditionStatus) metav1.Condition { + return metav1.Condition{ + Type: condType, + Status: status, + Reason: "Test", + LastTransitionTime: metav1.Now(), + } + } + + makeSBC := func(conditions ...metav1.Condition) *metalv1alpha1.ServerBootConfiguration { + sbc := &metalv1alpha1.ServerBootConfiguration{} + sbc.Status.Conditions = conditions + return sbc + } + + It("returns Pending when no conditions are present", func() { + sbc := makeSBC() + Expect(computeDesiredState(sbc, true, true)).To(Equal(metalv1alpha1.ServerBootConfigurationStatePending)) + }) + + It("returns Ready when both conditions are True", func() { + sbc := makeSBC( + makeCondition(HTTPBootReadyConditionType, metav1.ConditionTrue), + makeCondition(IPXEBootReadyConditionType, metav1.ConditionTrue), + ) + Expect(computeDesiredState(sbc, true, true)).To(Equal(metalv1alpha1.ServerBootConfigurationStateReady)) + }) + + It("returns Error when HTTP condition is False", func() { + sbc := makeSBC( + makeCondition(HTTPBootReadyConditionType, metav1.ConditionFalse), + makeCondition(IPXEBootReadyConditionType, metav1.ConditionTrue), + ) + Expect(computeDesiredState(sbc, true, true)).To(Equal(metalv1alpha1.ServerBootConfigurationStateError)) + }) + + It("returns Error when IPXE condition is False", func() { + sbc := makeSBC( + makeCondition(HTTPBootReadyConditionType, metav1.ConditionTrue), + makeCondition(IPXEBootReadyConditionType, metav1.ConditionFalse), + ) + Expect(computeDesiredState(sbc, true, true)).To(Equal(metalv1alpha1.ServerBootConfigurationStateError)) + }) + + It("returns Error when both conditions are False", func() { + sbc := makeSBC( + makeCondition(HTTPBootReadyConditionType, metav1.ConditionFalse), + makeCondition(IPXEBootReadyConditionType, metav1.ConditionFalse), + ) + Expect(computeDesiredState(sbc, true, true)).To(Equal(metalv1alpha1.ServerBootConfigurationStateError)) + }) + + It("returns Pending when one condition is Unknown", func() { + sbc := makeSBC( + makeCondition(HTTPBootReadyConditionType, metav1.ConditionTrue), + makeCondition(IPXEBootReadyConditionType, metav1.ConditionUnknown), + ) + Expect(computeDesiredState(sbc, true, true)).To(Equal(metalv1alpha1.ServerBootConfigurationStatePending)) + }) + + It("ignores IPXE condition when only HTTP is required", func() { + sbc := makeSBC( + makeCondition(HTTPBootReadyConditionType, metav1.ConditionTrue), + makeCondition(IPXEBootReadyConditionType, metav1.ConditionFalse), + ) + Expect(computeDesiredState(sbc, true, false)).To(Equal(metalv1alpha1.ServerBootConfigurationStateReady)) + }) + + It("ignores HTTP condition when only IPXE is required", func() { + sbc := makeSBC( + makeCondition(HTTPBootReadyConditionType, metav1.ConditionFalse), + makeCondition(IPXEBootReadyConditionType, metav1.ConditionTrue), + ) + Expect(computeDesiredState(sbc, false, true)).To(Equal(metalv1alpha1.ServerBootConfigurationStateReady)) + }) + +}) diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index e89ba00..b36dfc3 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -166,6 +166,13 @@ func SetupTest() *corev1.Namespace { RegistryValidator: registryValidator, }).SetupWithManager(k8sManager)).To(Succeed()) + Expect((&ServerBootConfigurationReadinessReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + RequireHTTPBoot: true, + RequireIPXEBoot: true, + }).SetupWithManager(k8sManager)).To(Succeed()) + go func() { defer GinkgoRecover() Expect(k8sManager.Start(mgrCtx)).To(Succeed(), "failed to start manager") From 36fd51bb15c8a4cccd33114fc9f64a0f6cbf2b58 Mon Sep 17 00:00:00 2001 From: Hardik Dodiya Date: Thu, 2 Apr 2026 13:01:47 +0200 Subject: [PATCH 2/2] Review comments --- api/v1alpha1/httpbootconfig_types.go | 3 +++ api/v1alpha1/ipxebootconfig_types.go | 3 +++ .../boot.ironcore.dev_httpbootconfigs.yaml | 5 ++++ .../boot.ironcore.dev_ipxebootconfigs.yaml | 5 ++++ .../boot.ironcore.dev_httpbootconfigs.yaml | 5 ++++ .../boot.ironcore.dev_ipxebootconfigs.yaml | 5 ++++ docs/api-reference/api.md | 2 ++ .../controller/httpbootconfig_controller.go | 3 ++- .../controller/ipxebootconfig_controller.go | 1 + ...serverbootconfiguration_http_controller.go | 24 ++++++++++++++----- .../serverbootconfiguration_pxe_controller.go | 24 ++++++++++++++----- ...rbootconfiguration_readiness_controller.go | 3 +++ 12 files changed, 70 insertions(+), 13 deletions(-) diff --git a/api/v1alpha1/httpbootconfig_types.go b/api/v1alpha1/httpbootconfig_types.go index 7283d67..41d69b3 100644 --- a/api/v1alpha1/httpbootconfig_types.go +++ b/api/v1alpha1/httpbootconfig_types.go @@ -27,6 +27,9 @@ type HTTPBootConfigSpec struct { type HTTPBootConfigStatus struct { State HTTPBootConfigState `json:"state,omitempty"` + // ObservedGeneration is the generation of the HTTPBootConfig that was last reconciled by the controller. + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // Conditions represent the latest available observations of the IPXEBootConfig's state Conditions []metav1.Condition `json:"conditions,omitempty"` } diff --git a/api/v1alpha1/ipxebootconfig_types.go b/api/v1alpha1/ipxebootconfig_types.go index 035a361..f718512 100644 --- a/api/v1alpha1/ipxebootconfig_types.go +++ b/api/v1alpha1/ipxebootconfig_types.go @@ -59,6 +59,9 @@ type IPXEBootConfigStatus struct { // Important: Run "make" to regenerate code after modifying this file State IPXEBootConfigState `json:"state,omitempty"` + // ObservedGeneration is the generation of the IPXEBootConfig that was last reconciled by the controller. + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // Conditions represent the latest available observations of the IPXEBootConfig's state Conditions []metav1.Condition `json:"conditions,omitempty"` } diff --git a/config/crd/bases/boot.ironcore.dev_httpbootconfigs.yaml b/config/crd/bases/boot.ironcore.dev_httpbootconfigs.yaml index 837b51e..5ccb878 100644 --- a/config/crd/bases/boot.ironcore.dev_httpbootconfigs.yaml +++ b/config/crd/bases/boot.ironcore.dev_httpbootconfigs.yaml @@ -136,6 +136,11 @@ spec: - type type: object type: array + observedGeneration: + description: ObservedGeneration is the generation of the HTTPBootConfig + that was last reconciled by the controller. + format: int64 + type: integer state: type: string type: object diff --git a/config/crd/bases/boot.ironcore.dev_ipxebootconfigs.yaml b/config/crd/bases/boot.ironcore.dev_ipxebootconfigs.yaml index a4246dc..2a78c51 100644 --- a/config/crd/bases/boot.ironcore.dev_ipxebootconfigs.yaml +++ b/config/crd/bases/boot.ironcore.dev_ipxebootconfigs.yaml @@ -165,6 +165,11 @@ spec: - type type: object type: array + observedGeneration: + description: ObservedGeneration is the generation of the IPXEBootConfig + that was last reconciled by the controller. + format: int64 + type: integer state: description: 'Important: Run "make" to regenerate code after modifying this file' diff --git a/dist/chart/templates/crd/boot.ironcore.dev_httpbootconfigs.yaml b/dist/chart/templates/crd/boot.ironcore.dev_httpbootconfigs.yaml index 7c6837a..7dfa8cd 100755 --- a/dist/chart/templates/crd/boot.ironcore.dev_httpbootconfigs.yaml +++ b/dist/chart/templates/crd/boot.ironcore.dev_httpbootconfigs.yaml @@ -142,6 +142,11 @@ spec: - type type: object type: array + observedGeneration: + description: ObservedGeneration is the generation of the HTTPBootConfig + that was last reconciled by the controller. + format: int64 + type: integer state: type: string type: object diff --git a/dist/chart/templates/crd/boot.ironcore.dev_ipxebootconfigs.yaml b/dist/chart/templates/crd/boot.ironcore.dev_ipxebootconfigs.yaml index cef94a9..63060fc 100755 --- a/dist/chart/templates/crd/boot.ironcore.dev_ipxebootconfigs.yaml +++ b/dist/chart/templates/crd/boot.ironcore.dev_ipxebootconfigs.yaml @@ -171,6 +171,11 @@ spec: - type type: object type: array + observedGeneration: + description: ObservedGeneration is the generation of the IPXEBootConfig + that was last reconciled by the controller. + format: int64 + type: integer state: description: 'Important: Run "make" to regenerate code after modifying this file' diff --git a/docs/api-reference/api.md b/docs/api-reference/api.md index 13bf47a..e82d5df 100644 --- a/docs/api-reference/api.md +++ b/docs/api-reference/api.md @@ -86,6 +86,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | | `state` _[HTTPBootConfigState](#httpbootconfigstate)_ | | | | +| `observedGeneration` _integer_ | ObservedGeneration is the generation of the HTTPBootConfig that was last reconciled by the controller. | | | | `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#condition-v1-meta) array_ | Conditions represent the latest available observations of the IPXEBootConfig's state | | | @@ -164,6 +165,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | | `state` _[IPXEBootConfigState](#ipxebootconfigstate)_ | Important: Run "make" to regenerate code after modifying this file | | | +| `observedGeneration` _integer_ | ObservedGeneration is the generation of the IPXEBootConfig that was last reconciled by the controller. | | | | `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#condition-v1-meta) array_ | Conditions represent the latest available observations of the IPXEBootConfig's state | | | diff --git a/internal/controller/httpbootconfig_controller.go b/internal/controller/httpbootconfig_controller.go index 3e62d7a..7b4f5cc 100644 --- a/internal/controller/httpbootconfig_controller.go +++ b/internal/controller/httpbootconfig_controller.go @@ -95,12 +95,13 @@ func (r *HTTPBootConfigReconciler) delete(_ context.Context, log logr.Logger, _ } func (r *HTTPBootConfigReconciler) patchStatus(ctx context.Context, config *bootv1alpha1.HTTPBootConfig, state bootv1alpha1.HTTPBootConfigState) error { - if config.Status.State == state { + if config.Status.State == state && config.Status.ObservedGeneration == config.Generation { return nil } base := config.DeepCopy() config.Status.State = state + config.Status.ObservedGeneration = config.Generation if err := r.Status().Patch(ctx, config, client.MergeFrom(base)); err != nil { return err diff --git a/internal/controller/ipxebootconfig_controller.go b/internal/controller/ipxebootconfig_controller.go index ad2d68b..3a4bd0b 100644 --- a/internal/controller/ipxebootconfig_controller.go +++ b/internal/controller/ipxebootconfig_controller.go @@ -100,6 +100,7 @@ func (r *IPXEBootConfigReconciler) patchStatus( ) error { base := ipxeBootConfig.DeepCopy() ipxeBootConfig.Status.State = state + ipxeBootConfig.Status.ObservedGeneration = ipxeBootConfig.Generation if err := r.Status().Patch(ctx, ipxeBootConfig, client.MergeFrom(base)); err != nil { return fmt.Errorf("error patching ipxeBootConfig: %w", err) diff --git a/internal/controller/serverbootconfiguration_http_controller.go b/internal/controller/serverbootconfiguration_http_controller.go index d4e807f..b263472 100644 --- a/internal/controller/serverbootconfiguration_http_controller.go +++ b/internal/controller/serverbootconfiguration_http_controller.go @@ -161,22 +161,34 @@ func (r *ServerBootConfigurationHTTPReconciler) patchHTTPBootReadyConditionFromH } base := cur.DeepCopy() + if cur.Generation != cfg.Generation { + // The SBC has been updated since this reconcile started; a newer reconcile + // will handle the fresh generation. Avoid stamping stale data on it. + return nil + } + cond := metav1.Condition{ - Type: HTTPBootReadyConditionType, - ObservedGeneration: cur.Generation, + Type: HTTPBootReadyConditionType, + // Use cfg.Generation, not cur.Generation: the condition content was + // derived from cfg's HTTPBootConfig, so it reflects that generation. + ObservedGeneration: cfg.Generation, } - switch httpBootConfig.Status.State { - case bootv1alpha1.HTTPBootConfigStateReady: + switch { + case httpBootConfig.Status.ObservedGeneration < httpBootConfig.Generation: + // Child controller hasn't reconciled the new spec yet; don't write anything. + // The Owns() watch will re-trigger this reconcile once the child updates its status. + return nil + case httpBootConfig.Status.State == bootv1alpha1.HTTPBootConfigStateReady: cond.Status = metav1.ConditionTrue cond.Reason = "BootConfigReady" cond.Message = "HTTP boot configuration is ready." - case bootv1alpha1.HTTPBootConfigStateError: + case httpBootConfig.Status.State == bootv1alpha1.HTTPBootConfigStateError: cond.Status = metav1.ConditionFalse cond.Reason = "BootConfigError" cond.Message = "HTTPBootConfig reported an error." default: cond.Status = metav1.ConditionUnknown - cond.Reason = "BootConfigPending" + cond.Reason = BootConfigPendingReason cond.Message = "Waiting for HTTPBootConfig to become Ready." } diff --git a/internal/controller/serverbootconfiguration_pxe_controller.go b/internal/controller/serverbootconfiguration_pxe_controller.go index c3787e6..7726dcc 100644 --- a/internal/controller/serverbootconfiguration_pxe_controller.go +++ b/internal/controller/serverbootconfiguration_pxe_controller.go @@ -180,22 +180,34 @@ func (r *ServerBootConfigurationPXEReconciler) patchIPXEBootReadyConditionFromIP } base := cur.DeepCopy() + if cur.Generation != bootConfig.Generation { + // The SBC has been updated since this reconcile started; a newer reconcile + // will handle the fresh generation. Avoid stamping stale data on it. + return nil + } + cond := metav1.Condition{ - Type: IPXEBootReadyConditionType, - ObservedGeneration: cur.Generation, + Type: IPXEBootReadyConditionType, + // Use bootConfig.Generation, not cur.Generation: the condition content was + // derived from bootConfig's IPXEBootConfig, so it reflects that generation. + ObservedGeneration: bootConfig.Generation, } - switch config.Status.State { - case v1alpha1.IPXEBootConfigStateReady: + switch { + case config.Status.ObservedGeneration < config.Generation: + // Child controller hasn't reconciled the new spec yet; don't write anything. + // The Owns() watch will re-trigger this reconcile once the child updates its status. + return nil + case config.Status.State == v1alpha1.IPXEBootConfigStateReady: cond.Status = metav1.ConditionTrue cond.Reason = "BootConfigReady" cond.Message = "IPXE boot configuration is ready." - case v1alpha1.IPXEBootConfigStateError: + case config.Status.State == v1alpha1.IPXEBootConfigStateError: cond.Status = metav1.ConditionFalse cond.Reason = "BootConfigError" cond.Message = "IPXEBootConfig reported an error." default: cond.Status = metav1.ConditionUnknown - cond.Reason = "BootConfigPending" + cond.Reason = BootConfigPendingReason cond.Message = "Waiting for IPXEBootConfig to become Ready." } diff --git a/internal/controller/serverbootconfiguration_readiness_controller.go b/internal/controller/serverbootconfiguration_readiness_controller.go index 2aa3830..ac11c86 100644 --- a/internal/controller/serverbootconfiguration_readiness_controller.go +++ b/internal/controller/serverbootconfiguration_readiness_controller.go @@ -20,6 +20,9 @@ const ( // Condition types written by the mode-specific converters. HTTPBootReadyConditionType = "HTTPBootReady" IPXEBootReadyConditionType = "IPXEBootReady" + + // BootConfigPendingReason is the condition reason used while waiting for a child boot config to reconcile. + BootConfigPendingReason = "BootConfigPending" ) // ServerBootConfigurationReadinessReconciler aggregates mode-specific readiness conditions and is the