Skip to content

Commit f539eef

Browse files
MTV-4498 | Add checking for related storage class space for conversio… (#4865)
…nTempStorageSize Check CSIStorageCapacity when conversionTempStorageClass and conversionTempStorageSize are set. Block migration (critical condition) when the storage class reports insufficient capacity; add advisory warning when capacity cannot be verified. Register storage/v1 in controller scheme for CSIStorageCapacity list. RESOLVES: MTV-4498 https://issues.redhat.com/browse/MTV-4498 Signed-off-by: Gwen Casey <gcasey@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 67e32f2 commit f539eef

File tree

3 files changed

+121
-5
lines changed

3 files changed

+121
-5
lines changed

cmd/forklift-controller/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import (
3333
template "github.com/openshift/api/template/v1"
3434
"github.com/pkg/profile"
3535
"github.com/prometheus/client_golang/prometheus/promhttp"
36+
storagev1 "k8s.io/api/storage/v1"
3637
_ "k8s.io/client-go/plugin/pkg/client/auth/gcp"
3738
cnv "kubevirt.io/api/core/v1"
3839
export "kubevirt.io/api/export/v1alpha1"
@@ -113,6 +114,10 @@ func main() {
113114
log.Error(err, "unable to add K8s APIs to scheme")
114115
os.Exit(1)
115116
}
117+
if err := storagev1.AddToScheme(mgr.GetScheme()); err != nil {
118+
log.Error(err, "unable to add storage APIs to scheme")
119+
os.Exit(1)
120+
}
116121
if err := net.AddToScheme(mgr.GetScheme()); err != nil {
117122
log.Error(err, "unable to add CNI APIs to scheme")
118123
os.Exit(1)

pkg/controller/plan/validation.go

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"github.com/kubev2v/forklift/pkg/templateutil"
3131
batchv1 "k8s.io/api/batch/v1"
3232
core "k8s.io/api/core/v1"
33+
storagev1 "k8s.io/api/storage/v1"
3334
k8serr "k8s.io/apimachinery/pkg/api/errors"
3435
"k8s.io/apimachinery/pkg/api/resource"
3536
meta "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -1953,7 +1954,7 @@ func (r *Reconciler) validateConversionTempStorage(plan *api.Plan) error {
19531954
}
19541955

19551956
// Validate that storageSize is a valid Kubernetes resource quantity
1956-
_, err := resource.ParseQuantity(storageSize)
1957+
requestedQty, err := resource.ParseQuantity(storageSize)
19571958
if err != nil {
19581959
conversionTempStorageSizeInvalid := libcnd.Condition{
19591960
Type: NotValid,
@@ -1967,6 +1968,72 @@ func (r *Reconciler) validateConversionTempStorage(plan *api.Plan) error {
19671968
return nil
19681969
}
19691970

1971+
// Check CSIStorageCapacity when available: block migration if storage class
1972+
// reports insufficient capacity for the requested conversion temp volume size.
1973+
if err := r.validateConversionTempStorageCapacity(plan, storageClass, requestedQty); err != nil {
1974+
return err
1975+
}
1976+
1977+
return nil
1978+
}
1979+
1980+
// validateConversionTempStorageCapacity checks CSIStorageCapacity for the given
1981+
// storage class. If any entry reports capacity sufficient for the requested size
1982+
// (MaximumVolumeSize or Capacity >= requested), the check passes. If entries exist
1983+
// but none have sufficient capacity, a blocking condition is set. If no entries
1984+
// exist for the storage class, an advisory warning is set.
1985+
func (r *Reconciler) validateConversionTempStorageCapacity(plan *api.Plan, storageClassName string, requested resource.Quantity) error {
1986+
ctx := context.Background()
1987+
list := &storagev1.CSIStorageCapacityList{}
1988+
if err := r.Client.List(ctx, list, client.InNamespace(core.NamespaceAll)); err != nil {
1989+
r.Log.Info("Could not list CSIStorageCapacity (capacity check skipped)", "error", err.Error(), "storageClass", storageClassName)
1990+
plan.Status.SetCondition(libcnd.Condition{
1991+
Type: NotValid,
1992+
Status: True,
1993+
Category: api.CategoryAdvisory,
1994+
Message: fmt.Sprintf("Storage capacity for ConversionTempStorageClass %q could not be verified. Ensure sufficient space is available.", storageClassName),
1995+
Items: []string{},
1996+
})
1997+
return nil
1998+
}
1999+
2000+
var matching []storagev1.CSIStorageCapacity
2001+
for i := range list.Items {
2002+
if list.Items[i].StorageClassName == storageClassName {
2003+
matching = append(matching, list.Items[i])
2004+
}
2005+
}
2006+
2007+
if len(matching) == 0 {
2008+
plan.Status.SetCondition(libcnd.Condition{
2009+
Type: NotValid,
2010+
Status: True,
2011+
Category: api.CategoryAdvisory,
2012+
Message: fmt.Sprintf("No capacity information found for ConversionTempStorageClass %q. Ensure sufficient space is available.", storageClassName),
2013+
Items: []string{},
2014+
})
2015+
return nil
2016+
}
2017+
2018+
for _, cap := range matching {
2019+
// Prefer MaximumVolumeSize (largest single volume); fall back to Capacity (available space).
2020+
if cap.MaximumVolumeSize != nil && cap.MaximumVolumeSize.Cmp(requested) >= 0 {
2021+
return nil
2022+
}
2023+
if cap.MaximumVolumeSize == nil && cap.Capacity != nil && cap.Capacity.Cmp(requested) >= 0 {
2024+
return nil
2025+
}
2026+
}
2027+
2028+
// Have capacity info but no entry can satisfy the requested size
2029+
plan.Status.SetCondition(libcnd.Condition{
2030+
Type: NotValid,
2031+
Status: True,
2032+
Category: api.CategoryCritical,
2033+
Message: fmt.Sprintf("Insufficient space in storage class %q for conversion temp storage (requested %s). Migration may fail.", storageClassName, requested.String()),
2034+
Items: []string{},
2035+
})
2036+
r.Log.Info("Conversion temp storage capacity insufficient", "storageClass", storageClassName, "requested", requested.String(), "plan", plan.Name, "namespace", plan.Namespace)
19702037
return nil
19712038
}
19722039

pkg/controller/plan/validation_test.go

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import (
1515
ginkgo "github.com/onsi/ginkgo/v2"
1616
"github.com/onsi/gomega"
1717
core "k8s.io/api/core/v1"
18+
storagev1 "k8s.io/api/storage/v1"
19+
"k8s.io/apimachinery/pkg/api/resource"
1820
meta "k8s.io/apimachinery/pkg/apis/meta/v1"
1921
"k8s.io/apimachinery/pkg/runtime"
2022
"k8s.io/apimachinery/pkg/version"
@@ -472,7 +474,12 @@ var _ = ginkgo.Describe("Plan Validations", func() {
472474
source.Status.Conditions.SetCondition(libcnd.Condition{Type: libcnd.Ready, Status: libcnd.True})
473475
destination.Status.Conditions.SetCondition(libcnd.Condition{Type: libcnd.Ready, Status: libcnd.True})
474476

475-
reconciler = createFakeReconciler(secret, plan, source, destination)
477+
csiCap := &storagev1.CSIStorageCapacity{
478+
ObjectMeta: meta.ObjectMeta{Name: "fast-ssd-cap", Namespace: "kube-system"},
479+
StorageClassName: "fast-ssd",
480+
Capacity: ptr.To(resource.MustParse("100Gi")),
481+
}
482+
reconciler = createFakeReconciler(secret, plan, source, destination, csiCap)
476483
err := reconciler.validateConversionTempStorage(plan)
477484

478485
gomega.Expect(err).NotTo(gomega.HaveOccurred())
@@ -554,25 +561,61 @@ var _ = ginkgo.Describe("Plan Validations", func() {
554561
gomega.Expect(condition.Message).To(gomega.ContainSubstring("is not a valid Kubernetes resource quantity"))
555562
})
556563

557-
ginkgo.It("should pass with valid size formats", func() {
564+
ginkgo.It("should pass with valid size formats when CSIStorageCapacity has sufficient capacity", func() {
558565
secret := createSecret(sourceSecretName, sourceNamespace, false)
559566
source := createProvider(sourceName, sourceNamespace, "https://source", api.OpenShift, &core.ObjectReference{Name: sourceSecretName, Namespace: sourceNamespace})
560567
destination := createProvider(destName, destNamespace, "", api.OpenShift, &core.ObjectReference{})
561568
source.Status.Conditions.SetCondition(libcnd.Condition{Type: libcnd.Ready, Status: libcnd.True})
562569
destination.Status.Conditions.SetCondition(libcnd.Condition{Type: libcnd.Ready, Status: libcnd.True})
563570

571+
// Seed CSIStorageCapacity so capacity check passes (2Ti >= all requested sizes)
572+
csiCap := &storagev1.CSIStorageCapacity{
573+
ObjectMeta: meta.ObjectMeta{Name: "fast-ssd-cap", Namespace: "kube-system"},
574+
StorageClassName: "fast-ssd",
575+
Capacity: ptr.To(resource.MustParse("2Ti")),
576+
}
577+
564578
validSizes := []string{"50Gi", "1Ti", "100Mi", "500G", "2T"}
565579
for _, size := range validSizes {
566580
plan := createPlan(testPlanName, testNamespace, source, destination)
567581
plan.Spec.ConversionTempStorageClass = "fast-ssd"
568582
plan.Spec.ConversionTempStorageSize = size
569583

570-
reconciler = createFakeReconciler(secret, plan, source, destination)
584+
reconciler = createFakeReconciler(secret, plan, source, destination, csiCap)
571585
err := reconciler.validateConversionTempStorage(plan)
572586

573587
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Size %s should be valid", size)
574-
gomega.Expect(plan.Status.HasCondition(NotValid)).To(gomega.BeFalse(), "Size %s should not cause validation error", size)
588+
gomega.Expect(plan.Status.HasBlockerCondition()).To(gomega.BeFalse(), "Size %s should not cause blocking validation error", size)
589+
}
590+
})
591+
592+
ginkgo.It("should block when CSIStorageCapacity reports insufficient capacity", func() {
593+
secret := createSecret(sourceSecretName, sourceNamespace, false)
594+
source := createProvider(sourceName, sourceNamespace, "https://source", api.OpenShift, &core.ObjectReference{Name: sourceSecretName, Namespace: sourceNamespace})
595+
destination := createProvider(destName, destNamespace, "", api.OpenShift, &core.ObjectReference{})
596+
plan := createPlan(testPlanName, testNamespace, source, destination)
597+
plan.Spec.ConversionTempStorageClass = "ocs-storagecluster-ceph-rbd"
598+
plan.Spec.ConversionTempStorageSize = "1Ti"
599+
source.Status.Conditions.SetCondition(libcnd.Condition{Type: libcnd.Ready, Status: libcnd.True})
600+
destination.Status.Conditions.SetCondition(libcnd.Condition{Type: libcnd.Ready, Status: libcnd.True})
601+
602+
// Only 70Gi available - not enough for 1Ti
603+
csiCap := &storagev1.CSIStorageCapacity{
604+
ObjectMeta: meta.ObjectMeta{Name: "ceph-cap", Namespace: "openshift-storage"},
605+
StorageClassName: "ocs-storagecluster-ceph-rbd",
606+
Capacity: ptr.To(resource.MustParse("70Gi")),
575607
}
608+
609+
reconciler = createFakeReconciler(secret, plan, source, destination, csiCap)
610+
err := reconciler.validateConversionTempStorage(plan)
611+
612+
gomega.Expect(err).NotTo(gomega.HaveOccurred())
613+
gomega.Expect(plan.Status.HasBlockerCondition()).To(gomega.BeTrue())
614+
cnd := plan.Status.FindCondition(NotValid)
615+
gomega.Expect(cnd).NotTo(gomega.BeNil())
616+
gomega.Expect(cnd.Category).To(gomega.Equal(api.CategoryCritical))
617+
gomega.Expect(cnd.Message).To(gomega.ContainSubstring("Insufficient space"))
618+
gomega.Expect(cnd.Message).To(gomega.ContainSubstring("1Ti"))
576619
})
577620
})
578621
})
@@ -741,6 +784,7 @@ func createFakeReconciler(objects ...runtime.Object) *Reconciler {
741784
scheme := runtime.NewScheme()
742785
_ = core.AddToScheme(scheme)
743786
_ = k8snet.AddToScheme(scheme)
787+
_ = storagev1.AddToScheme(scheme)
744788
api.SchemeBuilder.AddToScheme(scheme)
745789

746790
client := fakeClient.NewClientBuilder().

0 commit comments

Comments
 (0)