Files
Artem Torubarov 0c27a95097 osd: cleanup destroyed osd deployment if replacement failed
If osd-replacement fails during provisioning of the new device by the
osd-prepare job (can be the case if the new device is faulty), then
ceph-volume will run its internal rollback logic, which purges the
reserved osd id and its CRUSH position. After the faulty device is
replaced with a working one, rook will reprovision it correctly and
reuse the metadata device slot, but as a new OSD, so data will likely
be rebalanced. Ceph hands out the lowest free id, so the new OSD often
takes the same number back, and then the create path recreates the
deployment by itself. Otherwise, and until a working device arrives,
the downscaled deployment of the destroyed osd is left behind. This
commit handles that case by deleting downscaled ready-for-swap osd
deployments without a corresponding OSD in the osd dump.

Signed-off-by: Artem Torubarov <artem.torubarov@sap.com>
2026-08-04 11:28:21 +02:00

195 lines
7.7 KiB
Go

/*
Copyright 2026 The Rook Authors. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package osd
import (
"fmt"
"github.com/pkg/errors"
cephv1 "github.com/rook/rook/pkg/apis/ceph.rook.io/v1"
cephclient "github.com/rook/rook/pkg/daemon/ceph/client"
"github.com/rook/rook/pkg/operator/k8sutil"
"github.com/rook/rook/pkg/util/log"
appsv1 "k8s.io/api/apps/v1"
kerrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// processOSDReplacements acts on OSD deployments involved in a replacement: it validates new requests and
// hands them to the OSD health monitor by labelling them, and it cleans up after a replacement that can no
// longer complete.
func (c *Cluster) processOSDReplacements() error {
deployments, err := c.getOSDDeployments()
if err != nil {
return errors.Wrap(err, "failed to list OSD deployments to check for replacement requests")
}
var osdTree *cephclient.OsdTree
var osdDump *cephclient.OSDDump
for i := range deployments.Items {
d := &deployments.Items[i]
// A deployment waiting for its disk to be swapped needs no validation, but it does need cleaning
// up if its OSD is gone from the osdmap
if isWaitingForDiskSwap(d) {
if osdDump == nil {
dump, err := cephclient.GetOSDDump(c.context, c.clusterInfo)
if err != nil {
// Without the dump the OSD's existence cannot be established. Skip the check rather
// than risk deleting the deployment of a replacement still in progress.
log.NamespacedWarning(c.clusterInfo.Namespace, logger,
"failed to get osd dump to check for aborted replacements; will retry on the next reconcile. %v", err)
continue
}
osdDump = dump
}
missingFromOSDDump := c.isMissingFromOSDDump(d, osdDump)
// deployment is "waiting for swap" AND osd is missing from OSD dump can mean only that replacement has failed and OSD id was deleted during
// ceph volume rollback. Cleanup dangling downscaled OSD deployment in this case
if missingFromOSDDump {
err := k8sutil.DeleteDeployment(c.clusterInfo.Context, c.context.Clientset, d.Namespace, d.Name)
if err != nil {
log.NamespacedError(c.clusterInfo.Namespace, logger, "cannot cleanup dangling downscaled OSD deployment %q after failed osd-replacement: %v", d.Name, err)
continue
}
log.NamespacedInfo(c.clusterInfo.Namespace, logger, "removed dangling downscaled OSD deployment %q after failed osd-replacement", d.Name)
}
continue
}
// Already marked: validated, and the goroutine owns it now.
if d.Annotations[cephv1.ReplaceInProgressOSDAnnotationKey] == "true" {
continue
}
replaceValue, requested := d.Annotations[cephv1.ReplaceOSDAnnotationKey]
if !requested {
// skip: dont have replace annotation
continue
}
// Fetch the osd tree only once a replacement needs validating; it carries the "destroyed"
// status that `ceph osd dump` does not.
if osdTree == nil {
tree, err := cephclient.HostTree(c.context, c.clusterInfo)
if err != nil {
return errors.Wrap(err, "failed to get osd tree to validate replacement requests")
}
osdTree = &tree
}
if err := c.validateReplaceOSD(d, replaceValue, osdTree); err != nil {
// Skip the OSD; without the label it keeps reconciling normally.
log.NamespacedWarning(c.clusterInfo.Namespace, logger,
"skipping OSD replacement request on deployment %q: %v", d.Name, err)
continue
}
// Hand the OSD over to the health goroutine: the skip-reconcile label fences the updater off the
// deployment, and the in-progress annotation records that validation passed. Both are written in
// the same update, so the goroutine never sees one without the other.
k8sutil.AddLabelToDeployment(cephv1.SkipReconcileLabelKey, "true", d)
k8sutil.AddAnnotationToDeployment(cephv1.ReplaceInProgressOSDAnnotationKey, "true", d)
_, err := c.context.Clientset.AppsV1().Deployments(c.clusterInfo.Namespace).Update(c.clusterInfo.Context, d, metav1.UpdateOptions{})
if err != nil {
log.NamespacedWarning(c.clusterInfo.Namespace, logger,
"failed to set %q label and %q annotation on OSD deployment %q for replacement: %v",
cephv1.SkipReconcileLabelKey, cephv1.ReplaceInProgressOSDAnnotationKey, d.Name, err)
continue
}
log.NamespacedInfo(c.clusterInfo.Namespace, logger,
"validated OSD replacement request on deployment %q and set the %q label and %q annotation; OSD health monitor will drive teardown",
d.Name, cephv1.SkipReconcileLabelKey, cephv1.ReplaceInProgressOSDAnnotationKey)
}
return nil
}
// isWaitingForDiskSwap returns true if the deployment has the cephv1.ReadyForSwapOSDAnnotationKey annotation,
// meaning that the deployment represents destroyed OSD state awaiting physical disk swap to finish osd-replacement process.
func isWaitingForDiskSwap(d *appsv1.Deployment) bool {
_, readyForSwap := d.Annotations[cephv1.ReadyForSwapOSDAnnotationKey]
return readyForSwap
}
// isMissingFromOSDDump returns true only if osd dump does not contain osd ID from given Deployment.
// returns false in case of any error or empty OSD Dump
func (c *Cluster) isMissingFromOSDDump(d *appsv1.Deployment, osdDump *cephclient.OSDDump) bool {
osdID, err := GetOSDID(d)
if err != nil {
log.NamespacedError(c.clusterInfo.Namespace, logger, "unable to get osd ID from Deployment: %v", err)
return false
}
if osdDump == nil || len(osdDump.OSDs) == 0 {
return false
}
return !osdDump.Exists(int64(osdID))
}
// validateReplaceOSD returns an error on the first failed validation check for a replacement request.
func (c *Cluster) validateReplaceOSD(d *appsv1.Deployment, replaceValue string, osdTree *cephclient.OsdTree) error {
// The annotation value must match the deployment's own OSD id, guarding against a copy-paste typo.
osdID, err := GetOSDID(d)
if err != nil {
return errors.Wrapf(err, "failed to read %q label", OsdIdLabelKey)
}
expected := fmt.Sprintf(cephv1.ReplaceOSDAnnotationValueFmt, osdID)
if replaceValue != expected {
return errors.Errorf("annotation %q value %q does not match the deployment's OSD id (expected %q)",
cephv1.ReplaceOSDAnnotationKey, replaceValue, expected)
}
// Host-based only: PVC-backed OSDs are out of scope.
if _, isPVC := d.Labels[OSDOverPVCLabelKey]; isPVC {
return errors.Errorf("OSD %d is PVC-backed (label %q present); replacement supports host-based OSDs only",
osdID, OSDOverPVCLabelKey)
}
// Target OSD must exist in the osd tree. An already-destroyed slot is accepted so the goroutine
// can resume idempotently from its destroyed phase.
found := false
for _, node := range osdTree.Nodes {
if node.Type != "osd" || node.ID != osdID {
continue
}
found = true
break // osd ids are unique in the tree
}
if !found {
return errors.Errorf("OSD %d does not exist in the osd tree", osdID)
}
return nil
}
func (c *Cluster) replacementReadyForSwap(osdID int) (bool, error) {
name := fmt.Sprintf(osdAppNameFmt, osdID)
d, err := c.context.Clientset.AppsV1().Deployments(c.clusterInfo.Namespace).Get(c.clusterInfo.Context, name, metav1.GetOptions{})
if err != nil {
if kerrors.IsNotFound(err) {
return false, nil
}
return false, errors.Wrapf(err, "failed to get OSD deployment %q", name)
}
if _, readyForSwap := d.Annotations[cephv1.ReadyForSwapOSDAnnotationKey]; !readyForSwap {
return false, nil
}
return true, nil
}