Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 5 additions & 0 deletions api/v1alpha1/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ const (
ForceOnServerPower = "force-on-server"
// GracefulRestartBMC indicates to gracefully restart the baremetal server's BMC's power.
GracefulRestartBMC = "graceful-restart-bmc"

// ForceSSHResetBMC is set internally by the BMCReconciler on the OperationAnnotation when a
// Redfish reset fails with a 5xx error. On the next reconcile the controller detects this
// annotation and performs the SSH reset directly.
ForceSSHResetBMC = "force-ssh-reset-bmc"
)

var AnnotationToRedfishMapping = map[string]schemas.ResetType{
Expand Down
4 changes: 4 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ func main() { // nolint: gocyclo
bmcFailureResetDelay time.Duration
bmcResetResyncInterval time.Duration
bmcResetWaitingInterval time.Duration
sshResetTimeout time.Duration
serverMaxConcurrentReconciles int
serverClaimMaxConcurrentReconciles int
dnsRecordTemplatePath string
Expand Down Expand Up @@ -133,6 +134,8 @@ func main() { // nolint: gocyclo
"Defines the interval at which the bmc is polled when bmc reset is in-progress.")
flag.DurationVar(&bmcResetWaitingInterval, "bmc-reset-waiting-interval", 2*time.Minute,
"Defines the duration which the bmc waits before reconciling again when bmc has been reset.")
flag.DurationVar(&sshResetTimeout, "ssh-reset-timeout", 2*time.Minute,
"Timeout for SSH reset operations.")
flag.DurationVar(&maintenanceResyncInterval, "maintenance-resync-interval", 2*time.Minute,
"Defines the interval at which the CRD performing maintenance is polled during server maintenance task.")
flag.StringVar(&discoveryIgnitionPath, "discovery-ignition-path", "/etc/metal-operator/ignition-template.yaml",
Expand Down Expand Up @@ -436,6 +439,7 @@ func main() { // nolint: gocyclo
EventURL: eventURL,
DNSRecordTemplate: dnsRecordTemplate,
Conditions: conditionutils.NewAccessor(conditionutils.AccessorOptions{}),
SSHResetTimeout: sshResetTimeout,
BMCOptions: bmc.Options{
BasicAuth: true,
},
Expand Down
144 changes: 128 additions & 16 deletions internal/controller/bmc_controller.go
Comment thread
stefanhipfel marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ type BMCReconciler struct {
// DNSRecordTemplatePath is the path to the file containing the DNSRecord template.
DNSRecordTemplate string
Conditions *conditionutils.Accessor

// SSHResetTimeout defines the timeout for SSH reset operations (dial + command execution).
SSHResetTimeout time.Duration
}

// +kubebuilder:rbac:groups=metal.ironcore.dev,resources=endpoints,verbs=get;list;watch
Expand Down Expand Up @@ -122,6 +125,21 @@ func (r *BMCReconciler) reconcile(ctx context.Context, bmcObj *metalv1alpha1.BMC
log.V(1).Info("Skipped BMC reconciliation")
return ctrl.Result{}, nil
}
// SSH reset takes priority — process it even during the waitForBMCReset window so the
// reset actually runs instead of being deferred until the window expires.
if r.hasSSHResetAnnotation(bmcObj) {
log.V(1).Info("SSH reset annotation detected on unresponsive BMC", "BMC", bmcObj.Name)
bmcBase := bmcObj.DeepCopy()
metautils.DeleteAnnotation(bmcObj, metalv1alpha1.OperationAnnotation)
if err := r.Patch(ctx, bmcObj, client.MergeFrom(bmcBase)); err != nil {
return ctrl.Result{}, fmt.Errorf("failed to remove SSH reset annotation: %w", err)
}
if err := r.resetBMCViaSSH(ctx, bmcObj.Name); err != nil {
return ctrl.Result{}, fmt.Errorf("failed to reset BMC via SSH: %w", err)
Comment thread
afritzler marked this conversation as resolved.
}
log.V(1).Info("BMC SSH reset completed", "BMC", bmcObj.Name)
return ctrl.Result{RequeueAfter: r.BMCClientRetryInterval}, nil
}
if r.waitForBMCReset(bmcObj, r.BMCResetWaitTime) {
log.V(1).Info("Skipped BMC reconciliation while waiting for BMC reset to complete")
if err := r.patchBMCStatePending(ctx, bmcObj); err != nil {
Expand All @@ -135,14 +153,28 @@ func (r *BMCReconciler) reconcile(ctx context.Context, bmcObj *metalv1alpha1.BMC
if err != nil {
if r.shouldResetBMC(bmcObj) {
log.V(1).Info("BMC needs reset, resetting", "BMC", bmcObj.Name)
if err := r.resetBMC(ctx, bmcObj, bmcClient, ReasonAutoReset, bmcAutoResetMessage); err != nil {
if err := r.resetBMC(ctx, bmcObj, bmcClient, nil, ReasonAutoReset, bmcAutoResetMessage); err != nil {
return ctrl.Result{}, fmt.Errorf("failed to reset BMC: %w", err)
}
log.V(1).Info("BMC reset initiated", "BMC", bmcObj.Name)
return ctrl.Result{
RequeueAfter: r.BMCClientRetryInterval,
}, nil
}
Comment on lines 154 to 163

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The automatic reset path never reaches the SSH fallback.

Line 156 passes nil as clientErr. In this branch bmcClient is also nil, because GetBMCClientFromBMC returned an error on line 152. Inside resetBMC, the bmcClient == nil branch only schedules the ForceSSHResetBMC annotation when clientErr is a 5xx *schemas.Error. With clientErr == nil, that check is skipped, so this path always falls through to lines 539-542 and returns "no client connection".

The automatic path is the unattended case that BMCFailureResetDelay exists for, and it fires exactly when Redfish is unresponsive. It should classify the connection error the same way the user-annotation path does on line 173.

The outer err is still in scope at line 156. Go evaluates the call arguments before the new err binding in the if statement takes effect, so passing err directly is safe here.

🐛 Proposed fix: pass the connection error into resetBMC
 		if r.shouldResetBMC(bmcObj) {
 			log.V(1).Info("BMC needs reset, resetting", "BMC", bmcObj.Name)
-			if err := r.resetBMC(ctx, bmcObj, bmcClient, nil, ReasonAutoReset, bmcAutoResetMessage); err != nil {
+			connErr := err // preserve before the inner scope shadows it
+			if err := r.resetBMC(ctx, bmcObj, bmcClient, connErr, ReasonAutoReset, bmcAutoResetMessage); err != nil {
 				return ctrl.Result{}, fmt.Errorf("failed to reset BMC: %w", err)
 			}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controller/bmc_controller.go` around lines 154 - 163, Pass the
existing BMC client connection error from the automatic reset branch into
resetBMC instead of nil, using the err returned by GetBMCClientFromBMC. Keep the
remaining reset behavior unchanged so resetBMC can classify the failure and
schedule the ForceSSHResetBMC fallback.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@stefanhipfel can you please check if this is a valid finding?

// User-requested reset annotation while BMC is offline — attempt Redfish (will 5xx → schedule SSH).
if r.hasGracefulRestartAnnotation(bmcObj) {
log.V(1).Info("Reset annotation detected on unresponsive BMC, attempting reset", "BMC", bmcObj.Name)
connErr := err // preserve before inner scopes shadow it
bmcBase := bmcObj.DeepCopy()
metautils.DeleteAnnotation(bmcObj, metalv1alpha1.OperationAnnotation)
if err := r.Patch(ctx, bmcObj, client.MergeFrom(bmcBase)); err != nil {
return ctrl.Result{}, fmt.Errorf("failed to remove operation annotation: %w", err)
}
if err := r.resetBMC(ctx, bmcObj, bmcClient, connErr, ReasonUserReset, bmcUserResetMessage); err != nil {
return ctrl.Result{}, fmt.Errorf("failed to reset BMC: %w", err)
}
return ctrl.Result{RequeueAfter: r.BMCClientRetryInterval}, nil
}
return ctrl.Result{RequeueAfter: r.BMCClientRetryInterval}, r.updateReadyConditionOnBMCFailure(ctx, bmcObj, err)
}
defer bmcClient.Logout()
Expand All @@ -159,7 +191,7 @@ func (r *BMCReconciler) reconcile(ctx context.Context, bmcObj *metalv1alpha1.BMC
if err := r.updateConditions(ctx, bmcObj, true, ConditionReady, corev1.ConditionTrue, ReasonConnected, "BMC is connected"); err != nil {
return ctrl.Result{}, fmt.Errorf("failed to set BMC connected condition: %w", err)
}
if err := r.updateConditions(ctx, bmcObj, false, ConditionReset, corev1.ConditionFalse, "ResetComplete", "BMC reset is complete"); err != nil {
if err := r.updateConditions(ctx, bmcObj, false, ConditionReset, corev1.ConditionFalse, ReasonResetComplete, "BMC reset is complete"); err != nil {
return ctrl.Result{}, fmt.Errorf("failed to set BMC reset complete condition: %w", err)
}

Expand Down Expand Up @@ -362,7 +394,7 @@ func (r *BMCReconciler) handleAnnotationOperations(ctx context.Context, bmcObj *
switch value {
case schemas.GracefulRestartResetType:
log.V(1).Info("Handling operation", "Operation", operation, "RedfishResetType", value)
if err := r.resetBMC(ctx, bmcObj, bmcClient, ReasonUserReset, bmcUserResetMessage); err != nil {
if err := r.resetBMC(ctx, bmcObj, bmcClient, nil, ReasonUserReset, bmcUserResetMessage); err != nil {
return false, fmt.Errorf("failed to reset BMC: %w", err)
}
log.Info("Handled operation", "Operation", operation)
Expand Down Expand Up @@ -436,7 +468,8 @@ func (r *BMCReconciler) handlePreviousBMCResetAnnotations(ctx context.Context, b
return false, nil
}
if condition.Status == metav1.ConditionTrue {
if operation, ok := bmcObj.GetAnnotations()[metalv1alpha1.OperationAnnotation]; ok && operation == metalv1alpha1.GracefulRestartBMC {
if operation, ok := bmcObj.GetAnnotations()[metalv1alpha1.OperationAnnotation]; ok &&
(operation == metalv1alpha1.GracefulRestartBMC || operation == metalv1alpha1.ForceSSHResetBMC) {
bmcBase := bmcObj.DeepCopy()
metautils.DeleteAnnotation(bmcObj, metalv1alpha1.OperationAnnotation)
if err := r.Patch(ctx, bmcObj, client.MergeFrom(bmcBase)); err != nil {
Expand Down Expand Up @@ -483,15 +516,26 @@ func (r *BMCReconciler) patchBMCStatePending(ctx context.Context, bmcObj *metalv
return nil
}

func (r *BMCReconciler) resetBMC(ctx context.Context, bmcObj *metalv1alpha1.BMC, bmcClient bmc.BMC, reason, message string) error {
func (r *BMCReconciler) resetBMC(ctx context.Context, bmcObj *metalv1alpha1.BMC, bmcClient bmc.BMC, clientErr error, reason, message string) error {
log := ctrl.LoggerFrom(ctx)
if err := r.updateConditions(ctx, bmcObj, true, ConditionReset, corev1.ConditionTrue, reason, message); err != nil {
return fmt.Errorf("failed to set BMC resetting condition: %w", err)
}
if bmcClient == nil {
// No client connection at all (e.g. auto-reset reached with a nil
// client from GetBMCClientFromBMC). No Redfish call possible — surface
// it instead of logging a nil error.
// No Redfish client available. If the connection error was a 5xx (e.g. 503
// from the BMC during gofish connect), schedule an SSH retry.
if clientErr != nil {
if httpErr, ok := errors.AsType[*schemas.Error](clientErr); ok &&
httpErr.HTTPReturnedStatusCode >= 500 && httpErr.HTTPReturnedStatusCode < 600 {
bmcBase := bmcObj.DeepCopy()
metautils.SetAnnotation(bmcObj, metalv1alpha1.OperationAnnotation, metalv1alpha1.ForceSSHResetBMC)
if patchErr := r.Patch(ctx, bmcObj, client.MergeFrom(bmcBase)); patchErr != nil {
return fmt.Errorf("failed to set SSH reset annotation: %w", patchErr)
}
log.Info("Scheduled SSH-based BMC reset due to connection 5xx error", "BMC", bmcObj.Name)
return r.patchBMCStatePending(ctx, bmcObj)
}
}
return errors.Join(
r.patchBMCStatePending(ctx, bmcObj),
fmt.Errorf("could not reset BMC %s: no client connection", bmcObj.Name),
Expand All @@ -500,17 +544,85 @@ func (r *BMCReconciler) resetBMC(ctx context.Context, bmcObj *metalv1alpha1.BMC,
if err := bmcClient.ResetManager(ctx, bmcObj.Spec.BMCUUID, schemas.GracefulRestartResetType); err == nil {
log.Info("Successfully reset BMC via Redfish", "BMC", bmcObj.Name)
return r.patchBMCStatePending(ctx, bmcObj)
} else if httpErr, ok := errors.AsType[*schemas.Error](err); ok {
// only retryable on 5xx; anything else is a permanent failure for this attempt
if httpErr.HTTPReturnedStatusCode < 500 || httpErr.HTTPReturnedStatusCode >= 600 {
return errors.Join(r.patchBMCStatePending(ctx, bmcObj), fmt.Errorf("could not reset BMC: %w", err))
}
// 5xx — schedule SSH reset via annotation so the next reconcile handles it
// in-cluster state drives the retry; no separate goroutine needed.
bmcBase := bmcObj.DeepCopy()
metautils.SetAnnotation(bmcObj, metalv1alpha1.OperationAnnotation, metalv1alpha1.ForceSSHResetBMC)
if patchErr := r.Patch(ctx, bmcObj, client.MergeFrom(bmcBase)); patchErr != nil {
return fmt.Errorf("failed to set SSH reset annotation: %w", patchErr)
}
log.Info("Scheduled SSH-based BMC reset due to Redfish 5xx error", "BMC", bmcObj.Name)
return r.patchBMCStatePending(ctx, bmcObj)
} else {
if httpErr, ok := errors.AsType[*schemas.Error](err); ok {
// only retryable on 5xx; anything else is a permanent failure for this attempt
if httpErr.HTTPReturnedStatusCode < 500 || httpErr.HTTPReturnedStatusCode >= 600 {
return errors.Join(r.patchBMCStatePending(ctx, bmcObj), fmt.Errorf("could not reset BMC: %w", err))
}
} else {
return fmt.Errorf("could not reset BMC, unknown error: %w", err)
return fmt.Errorf("could not reset BMC, unknown error: %w", err)
}
}

func (r *BMCReconciler) hasSSHResetAnnotation(bmcObj *metalv1alpha1.BMC) bool {
operation, ok := bmcObj.GetAnnotations()[metalv1alpha1.OperationAnnotation]
return ok && operation == metalv1alpha1.ForceSSHResetBMC
}

func (r *BMCReconciler) hasGracefulRestartAnnotation(bmcObj *metalv1alpha1.BMC) bool {
operation, ok := bmcObj.GetAnnotations()[metalv1alpha1.OperationAnnotation]
return ok && operation == metalv1alpha1.GracefulRestartBMC
}

func (r *BMCReconciler) resetBMCViaSSH(ctx context.Context, bmcName string) error {
log := ctrl.LoggerFrom(ctx).WithValues("BMC", bmcName)
log.V(1).Info("Starting SSH-based BMC reset")

currentBMC := &metalv1alpha1.BMC{}
if err := r.Get(ctx, client.ObjectKey{Name: bmcName}, currentBMC); err != nil {
return fmt.Errorf("failed to fetch BMC object for SSH reset: %w", err)
}
address, err := bmcutils.GetBMCAddressForBMC(ctx, r.Client, currentBMC)
if err != nil {
_ = r.updateConditions(ctx, currentBMC, true, ConditionReset, corev1.ConditionFalse, ReasonConnectionFailed, fmt.Sprintf("Failed to get BMC address: %v", err))
return fmt.Errorf("failed to get BMC address for SSH reset: %w", err)
}
manufacturer := currentBMC.Status.Manufacturer
if manufacturer == "" {
log.V(1).Info("BMC manufacturer not available, attempting to get manufacturer from Server")
serverList := &metalv1alpha1.ServerList{}
if err := r.List(ctx, serverList, client.MatchingFields{bmcRefField: currentBMC.Name}); err != nil {
log.Error(err, "Failed to list Servers for BMC to get manufacturer fallback")
} else if len(serverList.Items) > 0 && serverList.Items[0].Status.Manufacturer != "" {
manufacturer = serverList.Items[0].Status.Manufacturer
log.V(1).Info("Using manufacturer from Server as fallback", "manufacturer", manufacturer, "server", serverList.Items[0].Name)
}
}
log.V(1).Info("BMC reset returned a retryable 5xx, leaving reset condition set", "BMC", bmcObj.Name)
if manufacturer == "" {
_ = r.updateConditions(ctx, currentBMC, true, ConditionReset, corev1.ConditionFalse, ReasonInternalError, "BMC manufacturer not available")
return fmt.Errorf("BMC manufacturer not available for SSH reset")
}
username, password, err := bmcutils.GetBMCCredentialsForBMCSecretName(ctx, r.Client, currentBMC.Spec.BMCSecretRef.Name)
if err != nil {
_ = r.updateConditions(ctx, currentBMC, true, ConditionReset, corev1.ConditionFalse, ReasonAuthenticationFailed, fmt.Sprintf("Failed to get credentials: %v", err))
return fmt.Errorf("failed to get BMC credentials for SSH reset: %w", err)
}
if err := bmcutils.SSHResetBMCFunc(ctx, address, manufacturer, username, password, r.SSHResetTimeout); err != nil {
_ = r.updateConditions(ctx, currentBMC, true, ConditionReset, corev1.ConditionFalse, ReasonInternalError, fmt.Sprintf("SSH reset failed: %v", err))
return fmt.Errorf("SSH reset failed: %w", err)
}
log.Info("Successfully reset BMC via SSH")

// ConditionReset is not cleared here — it is cleared when the BMC reconnects
// successfully via handlePreviousBMCResetAnnotations.
if err := r.Get(ctx, client.ObjectKey{Name: bmcName}, currentBMC); err != nil {
return fmt.Errorf("failed to re-fetch BMC after SSH reset: %w", err)
}
bmcBase := currentBMC.DeepCopy()
now := metav1.Now()
currentBMC.Status.LastResetTime = &now
if err := r.Status().Patch(ctx, currentBMC, client.MergeFrom(bmcBase)); err != nil {
return fmt.Errorf("failed to patch LastResetTime after SSH reset: %w", err)
}
return nil
}

Expand Down
Loading
Loading