diff --git a/images/virtualization-artifact/pkg/controller/cvi/internal/source/object_ref_vi_on_pvc_test.go b/images/virtualization-artifact/pkg/controller/cvi/internal/source/object_ref_vi_on_pvc_test.go new file mode 100644 index 0000000000..6762ef7e5d --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/cvi/internal/source/object_ref_vi_on_pvc_test.go @@ -0,0 +1,104 @@ +/* +Copyright 2026 Flant JSC + +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 source + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" + "github.com/deckhouse/virtualization-controller/pkg/dvcr" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/cvicondition" +) + +var _ = Describe("ObjectRef VirtualImage on PVC", func() { + It("copies size from VirtualImage when provisioning pod is completed", func() { + pod := &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodSucceeded, + }, + } + + importer := &ImporterMock{ + GetPodFunc: func(_ context.Context, _ supplements.Generator) (*corev1.Pod, error) { + return pod, nil + }, + } + stat := &StatMock{ + CheckPodFunc: func(_ *corev1.Pod) error { + return nil + }, + GetDVCRImageNameFunc: func(_ *corev1.Pod) string { + return "registry.example.com/image:test" + }, + } + recorder := &eventrecord.EventRecorderLoggerMock{ + EventFunc: func(_ client.Object, _, _, _ string) {}, + } + + syncer := NewObjectRefVirtualImageOnPvc(recorder, importer, &dvcr.Settings{}, stat) + + vi := &v1alpha2.VirtualImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vi", + Namespace: "test-ns", + }, + Status: v1alpha2.VirtualImageStatus{ + Size: v1alpha2.ImageStatusSize{ + Stored: "12Gi", + StoredBytes: "12884901888", + Unpacked: "10Gi", + UnpackedBytes: "10737418240", + }, + CDROM: true, + Format: "raw", + Target: v1alpha2.VirtualImageStatusTarget{ + PersistentVolumeClaim: "vi-pvc", + }, + }, + } + + cvi := &v1alpha2.ClusterVirtualImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cvi", + Generation: 1, + UID: "11111111-1111-1111-1111-111111111111", + }, + } + + cb := conditions.NewConditionBuilder(cvicondition.ReadyType).Generation(cvi.Generation) + + res, err := syncer.Sync(context.Background(), cvi, vi, cb) + + Expect(err).NotTo(HaveOccurred()) + Expect(res.RequeueAfter).To(Equal(time.Second)) + Expect(cvi.Status.Phase).To(Equal(v1alpha2.ImageReady)) + Expect(cvi.Status.Size).To(Equal(vi.Status.Size)) + Expect(cvi.Status.CDROM).To(Equal(vi.Status.CDROM)) + Expect(cvi.Status.Format).To(Equal(vi.Status.Format)) + Expect(cvi.Status.Target.RegistryURL).To(Equal("registry.example.com/image:test")) + }) +}) diff --git a/images/virtualization-artifact/pkg/controller/cvi/internal/source/sources_test.go b/images/virtualization-artifact/pkg/controller/cvi/internal/source/sources_test.go new file mode 100644 index 0000000000..dfaae41a6a --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/cvi/internal/source/sources_test.go @@ -0,0 +1,29 @@ +/* +Copyright 2026 Flant JSC + +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 source + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestSources(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "CVI Sources") +} diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vdsnapshot_pvc_test.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vdsnapshot_pvc_test.go index 9368c05dfe..0a6cf97dbb 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vdsnapshot_pvc_test.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vdsnapshot_pvc_test.go @@ -19,12 +19,14 @@ package source import ( "context" "log/slog" + "strconv" vsv1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" storagev1 "k8s.io/api/storage/v1" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -167,9 +169,17 @@ var _ = Describe("ObjectRef VirtualImageSnapshot PersistentVolumeClaim", func() }, Spec: corev1.PersistentVolumeClaimSpec{ StorageClassName: &sc.Name, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("10Gi"), + }, + }, }, Status: corev1.PersistentVolumeClaimStatus{ Phase: corev1.ClaimBound, + Capacity: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("12Gi"), + }, }, } }) @@ -222,8 +232,15 @@ var _ = Describe("ObjectRef VirtualImageSnapshot PersistentVolumeClaim", func() Expect(err).ToNot(HaveOccurred()) Expect(res.IsZero()).To(BeTrue()) + storedSize := resource.MustParse("12Gi") + unpackedSize := resource.MustParse("12Gi") + ExpectCondition(vi, metav1.ConditionTrue, vicondition.Ready, false) Expect(vi.Status.Phase).To(Equal(v1alpha2.ImageReady)) + Expect(vi.Status.Size.Stored).To(Equal("12Gi")) + Expect(vi.Status.Size.StoredBytes).To(Equal(strconv.FormatInt(storedSize.Value(), 10))) + Expect(vi.Status.Size.Unpacked).To(Equal("12Gi")) + Expect(vi.Status.Size.UnpackedBytes).To(Equal(strconv.FormatInt(unpackedSize.Value(), 10))) }) }) diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/source_additional_test.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/source_additional_test.go new file mode 100644 index 0000000000..48ef06ecc4 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/source_additional_test.go @@ -0,0 +1,485 @@ +/* +Copyright 2026 Flant JSC + +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 source + +import ( + "context" + "errors" + + vsv1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + netv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/controller" + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/importer" + "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" + "github.com/deckhouse/virtualization-controller/pkg/dvcr" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vdcondition" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vicondition" +) + +var _ = Describe("Source validations and helpers", func() { + newScheme := func() *runtime.Scheme { + scheme := runtime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + Expect(corev1.AddToScheme(scheme)).To(Succeed()) + Expect(netv1.AddToScheme(scheme)).To(Succeed()) + Expect(vsv1.AddToScheme(scheme)).To(Succeed()) + return scheme + } + + newRecorder := func() *eventrecord.EventRecorderLoggerMock { + return &eventrecord.EventRecorderLoggerMock{EventFunc: func(client.Object, string, string, string) {}} + } + + newVI := func() *v1alpha2.VirtualImage { + return &v1alpha2.VirtualImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vi", + Namespace: "default", + UID: "vi-uid", + }, + Spec: v1alpha2.VirtualImageSpec{ + DataSource: v1alpha2.VirtualImageDataSource{ + Type: v1alpha2.DataSourceTypeObjectRef, + }, + }, + } + } + + DescribeTable( + "builds typed source errors", + func(factory func(string) error, expected string) { + Expect(factory("test")).To(MatchError(expected)) + }, + Entry("image not ready", NewImageNotReadyError, "VirtualImage test not ready"), + Entry("cluster image not ready", NewClusterImageNotReadyError, "ClusterVirtualImage test not ready"), + Entry("virtual disk not ready", NewVirtualDiskNotReadyError, "VirtualDisk test not ready"), + Entry("virtual disk not ready for use", NewVirtualDiskNotReadyForUseError, "the VirtualDisk test not ready for use"), + Entry("virtual disk attached", NewVirtualDiskAttachedToVirtualMachineError, "the VirtualDisk test attached to VirtualMachine"), + Entry("virtual disk snapshot not ready", NewVirtualDiskSnapshotNotReadyError, "VirtualDiskSnapshot test not ready"), + ) + + Describe("validateVirtualDiskSnapshot", func() { + var ( + ctx context.Context + scheme *runtime.Scheme + vi *v1alpha2.VirtualImage + ) + + BeforeEach(func() { + ctx = context.Background() + scheme = newScheme() + vi = newVI() + vi.Spec.DataSource.ObjectRef = &v1alpha2.VirtualImageObjectRef{ + Kind: v1alpha2.VirtualImageObjectRefKindVirtualDiskSnapshot, + Name: "snap", + } + }) + + It("returns error when object ref is missing", func() { + vi.Spec.DataSource.ObjectRef = nil + client := fake.NewClientBuilder().WithScheme(scheme).Build() + + Expect(validateVirtualDiskSnapshot(ctx, vi, client)).To(MatchError("object ref missed for data source")) + }) + + It("returns not ready when snapshot is absent", func() { + client := fake.NewClientBuilder().WithScheme(scheme).Build() + + Expect(validateVirtualDiskSnapshot(ctx, vi, client)).To(MatchError("VirtualDiskSnapshot snap not ready")) + }) + + It("returns not ready when volume snapshot is not ready to use", func() { + vdSnapshot := &v1alpha2.VirtualDiskSnapshot{ + ObjectMeta: metav1.ObjectMeta{Name: "snap", Namespace: vi.Namespace}, + Status: v1alpha2.VirtualDiskSnapshotStatus{ + Phase: v1alpha2.VirtualDiskSnapshotPhaseReady, + VolumeSnapshotName: "vs", + }, + } + vs := &vsv1.VolumeSnapshot{ + ObjectMeta: metav1.ObjectMeta{Name: "vs", Namespace: vi.Namespace}, + Status: &vsv1.VolumeSnapshotStatus{ReadyToUse: ptr.To(false)}, + } + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vdSnapshot, vs).Build() + + Expect(validateVirtualDiskSnapshot(ctx, vi, client)).To(MatchError("VirtualDiskSnapshot snap not ready")) + }) + + It("succeeds for ready snapshot and ready volume snapshot", func() { + vdSnapshot := &v1alpha2.VirtualDiskSnapshot{ + ObjectMeta: metav1.ObjectMeta{Name: "snap", Namespace: vi.Namespace}, + Status: v1alpha2.VirtualDiskSnapshotStatus{ + Phase: v1alpha2.VirtualDiskSnapshotPhaseReady, + VolumeSnapshotName: "vs", + }, + } + vs := &vsv1.VolumeSnapshot{ + ObjectMeta: metav1.ObjectMeta{Name: "vs", Namespace: vi.Namespace}, + Status: &vsv1.VolumeSnapshotStatus{ReadyToUse: ptr.To(true)}, + } + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vdSnapshot, vs).Build() + syncerCR := NewObjectRefVirtualDiskSnapshotCR(nil, nil, nil, client, nil, newRecorder()) + syncerPVC := NewObjectRefVirtualDiskSnapshotPVC(nil, nil, nil, nil, client, nil, newRecorder()) + + Expect(syncerCR.Validate(ctx, vi)).To(Succeed()) + Expect(syncerPVC.Validate(ctx, vi)).To(Succeed()) + }) + }) + + Describe("ObjectRefVirtualDisk", func() { + var ( + ctx context.Context + scheme *runtime.Scheme + settings *dvcr.Settings + vi *v1alpha2.VirtualImage + ) + + BeforeEach(func() { + ctx = context.Background() + scheme = newScheme() + settings = &dvcr.Settings{AuthSecret: "dvcr-auth", RegistryURL: "registry.example"} + vi = newVI() + vi.Spec.DataSource.ObjectRef = &v1alpha2.VirtualImageObjectRef{ + Kind: v1alpha2.VirtualImageObjectRefKindVirtualDisk, + Name: "vd", + } + }) + + It("constructs syncer", func() { + syncer := NewObjectRefVirtualDisk(newRecorder(), nil, fake.NewClientBuilder().WithScheme(scheme).Build(), nil, settings, nil) + Expect(syncer).ToNot(BeNil()) + }) + + It("returns error for non virtual disk source", func() { + vi.Spec.DataSource.ObjectRef.Kind = v1alpha2.VirtualImageObjectRefKindVirtualImage + syncer := NewObjectRefVirtualDisk(newRecorder(), nil, fake.NewClientBuilder().WithScheme(scheme).Build(), nil, settings, nil) + + Expect(syncer.Validate(ctx, vi)).To(MatchError("not a VirtualDisk data source")) + }) + + It("returns not ready when virtual disk is absent", func() { + syncer := NewObjectRefVirtualDisk(newRecorder(), nil, fake.NewClientBuilder().WithScheme(scheme).Build(), nil, settings, nil) + + Expect(syncer.Validate(ctx, vi)).To(MatchError("VirtualDisk vd not ready")) + }) + + It("returns not ready for use when in-use condition is stale", func() { + vd := &v1alpha2.VirtualDisk{ + ObjectMeta: metav1.ObjectMeta{Name: "vd", Namespace: vi.Namespace, Generation: 2}, + Status: v1alpha2.VirtualDiskStatus{ + Phase: v1alpha2.DiskReady, + Conditions: []metav1.Condition{{ + Type: vdcondition.InUseType.String(), + Status: metav1.ConditionTrue, + Reason: vdcondition.UsedForImageCreation.String(), + ObservedGeneration: 1, + }}, + }, + } + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vd).Build() + syncer := NewObjectRefVirtualDisk(newRecorder(), nil, client, nil, settings, nil) + + Expect(syncer.Validate(ctx, vi)).To(MatchError("the VirtualDisk vd not ready for use")) + }) + + It("allows virtual disk used for image creation", func() { + vd := &v1alpha2.VirtualDisk{ + ObjectMeta: metav1.ObjectMeta{Name: "vd", Namespace: vi.Namespace, Generation: 2}, + Status: v1alpha2.VirtualDiskStatus{ + Phase: v1alpha2.DiskReady, + Conditions: []metav1.Condition{{ + Type: vdcondition.InUseType.String(), + Status: metav1.ConditionTrue, + Reason: vdcondition.UsedForImageCreation.String(), + ObservedGeneration: 2, + }}, + }, + } + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vd).Build() + syncer := NewObjectRefVirtualDisk(newRecorder(), nil, client, nil, settings, nil) + + Expect(syncer.Validate(ctx, vi)).To(Succeed()) + }) + + It("returns attached to virtual machine error", func() { + vd := &v1alpha2.VirtualDisk{ + ObjectMeta: metav1.ObjectMeta{Name: "vd", Namespace: vi.Namespace, Generation: 1}, + Status: v1alpha2.VirtualDiskStatus{ + Phase: v1alpha2.DiskReady, + Conditions: []metav1.Condition{{ + Type: vdcondition.InUseType.String(), + Status: metav1.ConditionTrue, + Reason: vdcondition.AttachedToVirtualMachine.String(), + ObservedGeneration: 1, + }}, + }, + } + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vd).Build() + syncer := NewObjectRefVirtualDisk(newRecorder(), nil, client, nil, settings, nil) + + Expect(syncer.Validate(ctx, vi)).To(MatchError("the VirtualDisk vd attached to VirtualMachine")) + }) + + It("skips in-use checks for ready image", func() { + vi.Status.Phase = v1alpha2.ImageReady + vd := &v1alpha2.VirtualDisk{ + ObjectMeta: metav1.ObjectMeta{Name: "vd", Namespace: vi.Namespace}, + Status: v1alpha2.VirtualDiskStatus{Phase: v1alpha2.DiskReady}, + } + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vd).Build() + syncer := NewObjectRefVirtualDisk(newRecorder(), nil, client, nil, settings, nil) + + Expect(syncer.Validate(ctx, vi)).To(Succeed()) + }) + + It("builds importer settings for filesystem and block pvc", func() { + supgen := supplements.NewGenerator(annotations.VIShortName, vi.Name, vi.Namespace, vi.UID) + syncer := NewObjectRefVirtualDisk(newRecorder(), nil, fake.NewClientBuilder().WithScheme(scheme).Build(), nil, settings, nil) + + fsSettings := syncer.getEnvSettings(vi, supgen, ptr.To(corev1.PersistentVolumeFilesystem)) + blockSettings := syncer.getEnvSettings(vi, supgen, ptr.To(corev1.PersistentVolumeBlock)) + + Expect(fsSettings.Source).To(Equal(importer.SourceFilesystem)) + Expect(blockSettings.Source).To(Equal(importer.SourceBlockDevice)) + Expect(blockSettings.DestinationEndpoint).To(ContainSubstring("registry.example/vi/default/vi:vi-uid")) + }) + }) + + Describe("ObjectRefDataSource", func() { + var ( + ctx context.Context + scheme *runtime.Scheme + settings *dvcr.Settings + vi *v1alpha2.VirtualImage + ) + + BeforeEach(func() { + ctx = context.Background() + scheme = newScheme() + settings = &dvcr.Settings{AuthSecret: "dvcr-auth", RegistryURL: "registry.example"} + vi = newVI() + }) + + It("constructs data source with nested syncers", func() { + ds := NewObjectRefDataSource(newRecorder(), nil, nil, nil, settings, fake.NewClientBuilder().WithScheme(scheme).Build(), nil) + Expect(ds).ToNot(BeNil()) + Expect(ds.vdSyncer).ToNot(BeNil()) + Expect(ds.vdSnapshotCRSyncer).ToNot(BeNil()) + Expect(ds.vdSnapshotPVCSyncer).ToNot(BeNil()) + }) + + It("returns error when object ref is nil", func() { + ds := NewObjectRefDataSource(newRecorder(), nil, nil, nil, settings, fake.NewClientBuilder().WithScheme(scheme).Build(), nil) + Expect(ds.Validate(ctx, vi)).To(MatchError("nil object ref: ObjectRef")) + }) + + It("validates kubernetes virtual image references by phase", func() { + vi.Spec.DataSource.ObjectRef = &v1alpha2.VirtualImageObjectRef{Kind: v1alpha2.VirtualImageObjectRefKindVirtualImage, Name: "ref"} + ref := &v1alpha2.VirtualImage{ + ObjectMeta: metav1.ObjectMeta{Name: "ref", Namespace: vi.Namespace}, + Spec: v1alpha2.VirtualImageSpec{Storage: v1alpha2.StorageKubernetes}, + Status: v1alpha2.VirtualImageStatus{Phase: v1alpha2.ImageReady}, + } + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(ref).Build() + ds := NewObjectRefDataSource(newRecorder(), nil, nil, nil, settings, client, nil) + + Expect(ds.Validate(ctx, vi)).To(Succeed()) + ref.Status.Phase = v1alpha2.ImagePending + client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(ref).Build() + ds = NewObjectRefDataSource(newRecorder(), nil, nil, nil, settings, client, nil) + Expect(ds.Validate(ctx, vi)).To(MatchError("VirtualImage ref not ready")) + }) + + It("validates cluster virtual image references through dvcr state", func() { + vi.Spec.DataSource.ObjectRef = &v1alpha2.VirtualImageObjectRef{Kind: v1alpha2.VirtualImageObjectRefKindClusterVirtualImage, Name: "cvi"} + cvi := &v1alpha2.ClusterVirtualImage{ + ObjectMeta: metav1.ObjectMeta{Name: "cvi"}, + Status: v1alpha2.ClusterVirtualImageStatus{Phase: v1alpha2.ImageReady}, + } + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cvi).Build() + ds := NewObjectRefDataSource(newRecorder(), nil, nil, nil, settings, client, nil) + + Expect(ds.Validate(ctx, vi)).To(Succeed()) + cvi.Status.Phase = v1alpha2.ImagePending + client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(cvi).Build() + ds = NewObjectRefDataSource(newRecorder(), nil, nil, nil, settings, client, nil) + Expect(ds.Validate(ctx, vi)).To(MatchError("ClusterVirtualImage cvi not ready")) + }) + + It("delegates virtual disk validation", func() { + vi.Spec.DataSource.ObjectRef = &v1alpha2.VirtualImageObjectRef{Kind: v1alpha2.VirtualImageObjectRefKindVirtualDisk, Name: "vd"} + vd := &v1alpha2.VirtualDisk{ + ObjectMeta: metav1.ObjectMeta{Name: "vd", Namespace: vi.Namespace, Generation: 1}, + Status: v1alpha2.VirtualDiskStatus{ + Phase: v1alpha2.DiskReady, + Conditions: []metav1.Condition{{ + Type: vdcondition.InUseType.String(), + Status: metav1.ConditionTrue, + Reason: vdcondition.UsedForImageCreation.String(), + ObservedGeneration: 1, + }}, + }, + } + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vd).Build() + ds := NewObjectRefDataSource(newRecorder(), nil, nil, nil, settings, client, nil) + + Expect(ds.Validate(ctx, vi)).To(Succeed()) + }) + + It("returns error for unexpected kind", func() { + vi.Spec.DataSource.ObjectRef = &v1alpha2.VirtualImageObjectRef{Kind: "SomethingElse", Name: "bad"} + ds := NewObjectRefDataSource(newRecorder(), nil, nil, nil, settings, fake.NewClientBuilder().WithScheme(scheme).Build(), nil) + + Expect(ds.Validate(ctx, vi)).To(MatchError("unexpected object ref kind: SomethingElse")) + }) + + It("builds settings and sources from ready dvcr source", func() { + vi.Spec.DataSource.ObjectRef = &v1alpha2.VirtualImageObjectRef{Kind: v1alpha2.VirtualImageObjectRefKindClusterVirtualImage, Name: "cvi"} + cvi := &v1alpha2.ClusterVirtualImage{ + ObjectMeta: metav1.ObjectMeta{Name: "cvi", UID: "cvi-uid"}, + Status: v1alpha2.ClusterVirtualImageStatus{ + Phase: v1alpha2.ImageReady, + Format: "qcow2", + Size: v1alpha2.ImageStatusSize{UnpackedBytes: "5Gi"}, + Target: v1alpha2.ClusterVirtualImageStatusTarget{RegistryURL: "registry.example/source"}, + }, + } + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cvi).Build() + ds := NewObjectRefDataSource(newRecorder(), nil, nil, nil, settings, client, nil) + supgen := supplements.NewGenerator(annotations.VIShortName, vi.Name, vi.Namespace, vi.UID) + dvcrSource, err := controller.NewDVCRDataSourcesForVMI(ctx, vi.Spec.DataSource, vi, client) + Expect(err).ToNot(HaveOccurred()) + + envSettings, err := ds.getEnvSettings(vi, supgen, dvcrSource) + Expect(err).ToNot(HaveOccurred()) + Expect(envSettings.Source).To(Equal(importer.SourceDVCR)) + Expect(envSettings.Endpoint).To(Equal("registry.example/source")) + + pvcSize, err := ds.getPVCSize(dvcrSource) + Expect(err).ToNot(HaveOccurred()) + Expect(pvcSize).To(Equal(resource.MustParse("5Gi"))) + + source, err := ds.getSource(supgen, dvcrSource) + Expect(err).ToNot(HaveOccurred()) + Expect(*source.Registry.URL).To(Equal("docker://registry.example/source")) + }) + + It("rejects not ready dvcr source in helper methods", func() { + ds := NewObjectRefDataSource(newRecorder(), nil, nil, nil, settings, fake.NewClientBuilder().WithScheme(scheme).Build(), nil) + notReady := controller.DVCRDataSource{} + supgen := supplements.NewGenerator(annotations.VIShortName, vi.Name, vi.Namespace, vi.UID) + + _, err := ds.getEnvSettings(vi, supgen, notReady) + Expect(err).To(MatchError("dvcr data source is not ready")) + _, err = ds.getPVCSize(notReady) + Expect(err).To(MatchError("dvcr data source is not ready")) + _, err = ds.getSource(supgen, notReady) + Expect(err).To(MatchError("dvcr data source is not ready")) + }) + }) + + Describe("Generic datasource helpers", func() { + var ( + ctx context.Context + vi *v1alpha2.VirtualImage + settings *dvcr.Settings + ) + + BeforeEach(func() { + ctx = context.Background() + vi = newVI() + vi.Spec.DataSource.ContainerImage = &v1alpha2.VirtualImageContainerImage{ + Image: "docker.io/library/alpine:latest", + ImagePullSecret: v1alpha2.ImagePullSecretName{Name: "pull-secret"}, + CABundle: []byte("ca-data"), + } + vi.Spec.DataSource.HTTP = &v1alpha2.DataSourceHTTP{URL: "https://example.com/image.qcow2"} + settings = &dvcr.Settings{AuthSecret: "dvcr-auth", RegistryURL: "registry.example"} + }) + + It("covers registry helpers", func() { + scheme := newScheme() + recorder := newRecorder() + registry := NewRegistryDataSource(recorder, &StatMock{GetSizeFunc: func(*corev1.Pod) v1alpha2.ImageStatusSize { + return v1alpha2.ImageStatusSize{UnpackedBytes: "2Gi"} + }}, &ImporterMock{}, settings, fake.NewClientBuilder().WithScheme(scheme).Build(), nil) + supgen := supplements.NewGenerator(annotations.VIShortName, vi.Name, vi.Namespace, vi.UID) + + Expect(registry.Validate(ctx, vi)).To(MatchError(ErrSecretNotFound)) + secretClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "pull-secret", Namespace: vi.Namespace}}).Build() + registry.client = secretClient + Expect(registry.Validate(ctx, vi)).To(Succeed()) + Expect(registry.getEnvSettings(vi, supgen).Source).To(Equal(importer.SourceRegistry)) + q, err := registry.getPVCSize(&corev1.Pod{}) + Expect(err).ToNot(HaveOccurred()) + Expect(q).To(Equal(resource.MustParse("2Gi"))) + Expect(*registry.getSource(supgen, "registry.example/image").Registry.URL).To(Equal("docker://registry.example/image")) + }) + + It("covers http helpers", func() { + httpDS := NewHTTPDataSource(newRecorder(), &StatMock{GetSizeFunc: func(*corev1.Pod) v1alpha2.ImageStatusSize { + return v1alpha2.ImageStatusSize{UnpackedBytes: "3Gi"} + }}, &ImporterMock{}, settings, nil) + supgen := supplements.NewGenerator(annotations.VIShortName, vi.Name, vi.Namespace, vi.UID) + + Expect(httpDS.Validate(ctx, vi)).To(Succeed()) + Expect(httpDS.getEnvSettings(vi, supgen).Source).To(Equal(importer.SourceHTTP)) + q, err := httpDS.getPVCSize(&corev1.Pod{}) + Expect(err).ToNot(HaveOccurred()) + Expect(q).To(Equal(resource.MustParse("3Gi"))) + Expect(*httpDS.getSource(supgen, "registry.example/image").Registry.URL).To(Equal("docker://registry.example/image")) + }) + + It("covers upload helpers", func() { + upload := NewUploadDataSource(newRecorder(), &StatMock{GetSizeFunc: func(*corev1.Pod) v1alpha2.ImageStatusSize { + return v1alpha2.ImageStatusSize{UnpackedBytes: "4Gi"} + }}, &UploaderMock{}, settings, nil, fake.NewClientBuilder().WithScheme(newScheme()).Build()) + supgen := supplements.NewGenerator(annotations.VIShortName, vi.Name, vi.Namespace, vi.UID) + + Expect(upload.Validate(ctx, vi)).To(Succeed()) + Expect(upload.getEnvSettings(vi, supgen)).ToNot(BeNil()) + q, err := upload.getPVCSize(&corev1.Pod{}) + Expect(err).ToNot(HaveOccurred()) + Expect(q).To(Equal(resource.MustParse("4Gi"))) + Expect(*upload.getSource(supgen, "registry.example/image").Registry.URL).To(Equal("docker://registry.example/image")) + }) + }) + + Describe("Failure helpers", func() { + It("sets failed phase condition", func() { + cb := conditions.NewConditionBuilder(vicondition.ReadyType) + phase := v1alpha2.ImagePhase("") + setPhaseConditionToFailed(cb, &phase, errors.New("plain error")) + Expect(phase).To(Equal(v1alpha2.ImageFailed)) + Expect(cb.Condition().Message).To(Equal("Plain error")) + }) + }) +}) diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/sources_test.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/sources_test.go new file mode 100644 index 0000000000..76686dc8dd --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/sources_test.go @@ -0,0 +1,331 @@ +/* +Copyright 2026 Flant JSC + +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 source + +import ( + "context" + "errors" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" + "github.com/deckhouse/virtualization-controller/pkg/controller/service/volumemode" + "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vicondition" +) + +type sourcesHandlerStub struct { + cleanupResult bool + cleanupErr error + cleanupCalls int +} + +func (s *sourcesHandlerStub) StoreToDVCR(context.Context, *v1alpha2.VirtualImage) (reconcile.Result, error) { + return reconcile.Result{}, nil +} + +func (s *sourcesHandlerStub) StoreToPVC(context.Context, *v1alpha2.VirtualImage) (reconcile.Result, error) { + return reconcile.Result{}, nil +} + +func (s *sourcesHandlerStub) CleanUp(context.Context, *v1alpha2.VirtualImage) (bool, error) { + s.cleanupCalls++ + return s.cleanupResult, s.cleanupErr +} + +func (s *sourcesHandlerStub) Validate(context.Context, *v1alpha2.VirtualImage) error { + return nil +} + +type sourcesCleanerStub struct { + cleanupResult bool + cleanupErr error + cleanupSupplementsResult reconcile.Result + cleanupSupplementsErr error + cleanupCalls int + cleanupSupplementsCalls int +} + +func (s *sourcesCleanerStub) CleanUp(context.Context, *v1alpha2.VirtualImage) (bool, error) { + s.cleanupCalls++ + return s.cleanupResult, s.cleanupErr +} + +func (s *sourcesCleanerStub) CleanUpSupplements(context.Context, *v1alpha2.VirtualImage) (reconcile.Result, error) { + s.cleanupSupplementsCalls++ + return s.cleanupSupplementsResult, s.cleanupSupplementsErr +} + +type sourcesImportCheckerStub struct { + err error +} + +func (s sourcesImportCheckerStub) CheckImportProcess(context.Context, *cdiv1.DataVolume, *corev1.PersistentVolumeClaim) error { + return s.err +} + +var _ = Describe("Sources helpers", func() { + newVI := func() *v1alpha2.VirtualImage { + return &v1alpha2.VirtualImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vi", + Namespace: "default", + UID: "vi-uid", + Annotations: map[string]string{}, + }, + } + } + + Describe("Sources map operations", func() { + It("stores handlers, resolves them and detects changes", func() { + sources := NewSources() + handler := &sourcesHandlerStub{} + vi := newVI() + vi.Generation = 2 + vi.Status.ObservedGeneration = 1 + + sources.Set(v1alpha2.DataSourceTypeObjectRef, handler) + stored, ok := sources.For(v1alpha2.DataSourceTypeObjectRef) + Expect(ok).To(BeTrue()) + Expect(stored).To(BeIdenticalTo(handler)) + Expect(sources.Changed(context.Background(), vi)).To(BeTrue()) + + vi.Status.ObservedGeneration = 2 + Expect(sources.Changed(context.Background(), vi)).To(BeFalse()) + }) + + It("aggregates cleanup results from all handlers", func() { + sources := NewSources() + first := &sourcesHandlerStub{cleanupResult: false} + second := &sourcesHandlerStub{cleanupResult: true} + sources.Set(v1alpha2.DataSourceTypeHTTP, first) + sources.Set(v1alpha2.DataSourceTypeObjectRef, second) + + requeue, err := sources.CleanUp(context.Background(), newVI()) + Expect(err).ToNot(HaveOccurred()) + Expect(requeue).To(BeTrue()) + Expect(first.cleanupCalls).To(Equal(1)) + Expect(second.cleanupCalls).To(Equal(1)) + }) + + It("returns cleanup error immediately", func() { + sources := NewSources() + broken := &sourcesHandlerStub{cleanupErr: errors.New("cleanup failed")} + sources.Set(v1alpha2.DataSourceTypeHTTP, broken) + + requeue, err := sources.CleanUp(context.Background(), newVI()) + Expect(err).To(MatchError("cleanup failed")) + Expect(requeue).To(BeFalse()) + Expect(broken.cleanupCalls).To(Equal(1)) + }) + }) + + Describe("cleanup wrappers", func() { + It("runs cleanup only when subresources should be deleted", func() { + vi := newVI() + cleaner := &sourcesCleanerStub{cleanupResult: true} + + shouldRequeue, err := CleanUp(context.Background(), vi, cleaner) + Expect(err).ToNot(HaveOccurred()) + Expect(shouldRequeue).To(BeTrue()) + Expect(cleaner.cleanupCalls).To(Equal(1)) + }) + + It("skips cleanup when retain annotation is set", func() { + vi := newVI() + vi.Annotations[annotations.AnnPodRetainAfterCompletion] = "true" + cleaner := &sourcesCleanerStub{cleanupResult: true} + + shouldRequeue, err := CleanUp(context.Background(), vi, cleaner) + Expect(err).ToNot(HaveOccurred()) + Expect(shouldRequeue).To(BeFalse()) + Expect(cleaner.cleanupCalls).To(BeZero()) + }) + + It("runs supplements cleanup only when subresources should be deleted", func() { + vi := newVI() + cleaner := &sourcesCleanerStub{cleanupSupplementsResult: reconcile.Result{RequeueAfter: time.Second}} + + result, err := CleanUpSupplements(context.Background(), vi, cleaner) + Expect(err).ToNot(HaveOccurred()) + Expect(result.RequeueAfter).To(Equal(time.Second)) + Expect(cleaner.cleanupSupplementsCalls).To(Equal(1)) + }) + + It("skips supplements cleanup when retain annotation is set", func() { + vi := newVI() + vi.Annotations[annotations.AnnPodRetainAfterCompletion] = "true" + cleaner := &sourcesCleanerStub{cleanupSupplementsResult: reconcile.Result{RequeueAfter: time.Second}} + + result, err := CleanUpSupplements(context.Background(), vi, cleaner) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + Expect(cleaner.cleanupSupplementsCalls).To(BeZero()) + }) + }) + + It("detects finished image provisioning by ready reason", func() { + Expect(IsImageProvisioningFinished(metav1.Condition{Reason: vicondition.Ready.String()})).To(BeTrue()) + Expect(IsImageProvisioningFinished(metav1.Condition{Reason: vicondition.Provisioning.String()})).To(BeFalse()) + }) + + DescribeTable( + "setPhaseConditionForFinishedImage", + func( + pvc *corev1.PersistentVolumeClaim, + expectedPhase v1alpha2.ImagePhase, + expectedStatus metav1.ConditionStatus, + expectedReason string, + expectedMessage string, + ) { + cb := conditions.NewConditionBuilder(vicondition.ReadyType) + phase := v1alpha2.ImagePhase("") + supgen := supplements.NewGenerator("vi", "image", "default", "uid") + + setPhaseConditionForFinishedImage(pvc, cb, &phase, supgen) + + Expect(phase).To(Equal(expectedPhase)) + Expect(cb.Condition().Status).To(Equal(expectedStatus)) + Expect(cb.Condition().Reason).To(Equal(expectedReason)) + Expect(cb.Condition().Message).To(Equal(expectedMessage)) + }, + Entry("marks pvc lost when pvc is missing", nil, v1alpha2.ImagePVCLost, metav1.ConditionFalse, vicondition.PVCLost.String(), "PVC default/d8v-vi-image-uid not found."), + Entry("marks image ready when pvc exists", &corev1.PersistentVolumeClaim{}, v1alpha2.ImageReady, metav1.ConditionTrue, vicondition.Ready.String(), ""), + ) + + DescribeTable( + "setPhaseConditionForPVCProvisioningImage", + func( + dv *cdiv1.DataVolume, + checkerErr error, + expectedPhase v1alpha2.ImagePhase, + expectedStatus metav1.ConditionStatus, + expectedReason string, + expectedMessage string, + expectedErr error, + ) { + vi := newVI() + cb := conditions.NewConditionBuilder(vicondition.ReadyType) + + err := setPhaseConditionForPVCProvisioningImage(context.Background(), dv, vi, nil, cb, sourcesImportCheckerStub{err: checkerErr}) + if expectedErr == nil { + Expect(err).ToNot(HaveOccurred()) + } else { + Expect(err).To(MatchError(expectedErr)) + } + + Expect(vi.Status.Phase).To(Equal(expectedPhase)) + Expect(cb.Condition().Status).To(Equal(expectedStatus)) + Expect(cb.Condition().Reason).To(Equal(expectedReason)) + Expect(cb.Condition().Message).To(Equal(expectedMessage)) + }, + Entry("waits for pvc importer creation when dv is absent", nil, nil, v1alpha2.ImageProvisioning, metav1.ConditionFalse, vicondition.Provisioning.String(), "Waiting for the pvc importer to be created", nil), + Entry("reports provisioning in progress", &cdiv1.DataVolume{}, nil, v1alpha2.ImageProvisioning, metav1.ConditionFalse, vicondition.Provisioning.String(), "Import is in the process of provisioning to PVC.", nil), + Entry("handles data volume not running", &cdiv1.DataVolume{}, service.ErrDataVolumeNotRunning, v1alpha2.ImageProvisioning, metav1.ConditionFalse, vicondition.ProvisioningFailed.String(), "Pvc importer is not running", nil), + Entry("handles missing default storage class", &cdiv1.DataVolume{}, service.ErrDefaultStorageClassNotFound, v1alpha2.ImagePending, metav1.ConditionFalse, vicondition.ProvisioningFailed.String(), "Default StorageClass not found in the cluster: please provide a StorageClass name or set a default StorageClass.", nil), + Entry("returns unexpected error", &cdiv1.DataVolume{}, errors.New("boom"), v1alpha2.ImagePhase(""), metav1.ConditionUnknown, conditions.ReasonUnknown.String(), "", errors.New("boom")), + ) + + DescribeTable( + "setPhaseConditionFromPodError", + func( + inputErr error, + expectedErr error, + expectedReason string, + expectedMessage string, + ) { + vi := newVI() + cb := conditions.NewConditionBuilder(vicondition.ReadyType) + + err := setPhaseConditionFromPodError(cb, vi, inputErr) + if expectedErr == nil { + Expect(err).ToNot(HaveOccurred()) + } else { + Expect(err).To(MatchError(expectedErr)) + } + + Expect(vi.Status.Phase).To(Equal(v1alpha2.ImageFailed)) + Expect(cb.Condition().Reason).To(Equal(expectedReason)) + Expect(cb.Condition().Message).To(Equal(expectedMessage)) + }, + Entry("not initialized", service.ErrNotInitialized, nil, vicondition.ProvisioningNotStarted.String(), "Not initialized."), + Entry("not scheduled", service.ErrNotScheduled, nil, vicondition.ProvisioningNotStarted.String(), "Not scheduled."), + Entry("provisioning failed", service.ErrProvisioningFailed, nil, vicondition.ProvisioningFailed.String(), "Provisioning failed."), + Entry("unknown error", errors.New("boom"), errors.New("boom"), conditions.ReasonUnknown.String(), ""), + ) + + DescribeTable( + "setPhaseConditionFromStorageError", + func( + inputErr error, + expectedHandled bool, + expectedErr error, + expectedPhase v1alpha2.ImagePhase, + expectedReason string, + expectedMessage string, + ) { + vi := newVI() + cb := conditions.NewConditionBuilder(vicondition.ReadyType) + + handled, err := setPhaseConditionFromStorageError(inputErr, vi, cb) + Expect(handled).To(Equal(expectedHandled)) + if expectedErr == nil { + Expect(err).ToNot(HaveOccurred()) + } else { + Expect(err).To(MatchError(expectedErr)) + } + + Expect(vi.Status.Phase).To(Equal(expectedPhase)) + Expect(cb.Condition().Reason).To(Equal(expectedReason)) + Expect(cb.Condition().Message).To(Equal(expectedMessage)) + }, + Entry("no error", nil, false, nil, v1alpha2.ImagePhase(""), conditions.ReasonUnknown.String(), ""), + Entry("storage profile missing", volumemode.ErrStorageProfileNotFound, true, nil, v1alpha2.ImageFailed, vicondition.ProvisioningFailed.String(), "StorageProfile not found in the cluster: Please check a StorageClass name in the cluster or set a default StorageClass."), + Entry("default storage class missing", service.ErrDefaultStorageClassNotFound, true, nil, v1alpha2.ImagePending, vicondition.ProvisioningFailed.String(), "Default StorageClass not found in the cluster: please provide a StorageClass name or set a default StorageClass."), + Entry("unexpected error", errors.New("boom"), false, errors.New("boom"), v1alpha2.ImagePhase(""), conditions.ReasonUnknown.String(), ""), + ) + + DescribeTable( + "setQuotaExceededPhaseCondition", + func( + creationTimestamp metav1.Time, + expectedMessage string, + expectedRequeueAfter time.Duration, + ) { + cb := conditions.NewConditionBuilder(vicondition.ReadyType) + phase := v1alpha2.ImagePhase("") + + result := setQuotaExceededPhaseCondition(cb, &phase, errors.New("exceeded quota: test"), creationTimestamp) + Expect(phase).To(Equal(v1alpha2.ImageFailed)) + Expect(cb.Condition().Status).To(Equal(metav1.ConditionFalse)) + Expect(cb.Condition().Reason).To(Equal(vicondition.ProvisioningFailed.String())) + Expect(cb.Condition().Message).To(Equal(expectedMessage)) + Expect(result.RequeueAfter).To(Equal(expectedRequeueAfter)) + }, + Entry("keeps failed state for fresh object", metav1.NewTime(time.Now()), "Quota exceeded: exceeded quota: test; Please configure quotas or try recreating the resource later.", time.Duration(0)), + Entry("requeues old object", metav1.NewTime(time.Now().Add(-31*time.Minute)), "Quota exceeded: exceeded quota: test; Retry in 1 minute.", time.Minute), + ) +}) diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/step/create_bounder_pod_step_test.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/create_bounder_pod_step_test.go new file mode 100644 index 0000000000..fe1552ab81 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/create_bounder_pod_step_test.go @@ -0,0 +1,214 @@ +/* +Copyright 2026 Flant JSC + +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 step + +import ( + "context" + "errors" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" + "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vicondition" +) + +type createBounderPodStepBounderStub struct { + startErr error + startCalls int +} + +func (s *createBounderPodStepBounderStub) Start(_ context.Context, _ *metav1.OwnerReference, _ supplements.Generator, _ ...service.Option) error { + s.startCalls++ + return s.startErr +} + +var _ = Describe("CreateBounderPodStep", func() { + DescribeTable("Take", + func( + pvc *corev1.PersistentVolumeClaim, + storageClasses []client.Object, + bounderErr error, + creationTimestamp metav1.Time, + expectedErr error, + expectedStartCalls int, + expectedEventCalls int, + expectedPhase v1alpha2.ImagePhase, + expectedReason string, + expectedMessage string, + expectedRequeueAfter time.Duration, + ) { + scheme := runtime.NewScheme() + Expect(storagev1.AddToScheme(scheme)).To(Succeed()) + + fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(storageClasses...).Build() + bounder := &createBounderPodStepBounderStub{startErr: bounderErr} + var recorder *eventrecord.EventRecorderLoggerMock + recorder = &eventrecord.EventRecorderLoggerMock{ + EventFunc: func(client.Object, string, string, string) {}, + WithLoggingFunc: func(logger eventrecord.InfoLogger) eventrecord.EventRecorderLogger { + return recorder + }, + } + cb := conditions.NewConditionBuilder(vicondition.ReadyType) + vi := &v1alpha2.VirtualImage{ + TypeMeta: metav1.TypeMeta{APIVersion: v1alpha2.SchemeGroupVersion.String(), Kind: "VirtualImage"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "vi", + Namespace: "default", + UID: "vi-uid", + CreationTimestamp: creationTimestamp, + }, + } + + result, err := NewCreateBounderPodStep(pvc, bounder, fakeClient, recorder, cb).Take(context.Background(), vi) + if expectedErr == nil { + Expect(err).ToNot(HaveOccurred()) + } else { + Expect(err).To(MatchError(expectedErr)) + } + + Expect(bounder.startCalls).To(Equal(expectedStartCalls)) + Expect(recorder.EventCalls()).To(HaveLen(expectedEventCalls)) + + if result == nil { + Expect(expectedRequeueAfter).To(BeZero()) + } else { + Expect(result.RequeueAfter).To(Equal(expectedRequeueAfter)) + } + + Expect(vi.Status.Phase).To(Equal(expectedPhase)) + Expect(cb.Condition().Reason).To(Equal(expectedReason)) + Expect(cb.Condition().Message).To(Equal(expectedMessage)) + if expectedReason != conditions.ReasonUnknown.String() { + Expect(cb.Condition().Status).To(Equal(metav1.ConditionFalse)) + } + + if expectedEventCalls > 0 { + event := recorder.EventCalls()[0] + Expect(event.Eventtype).To(Equal(corev1.EventTypeWarning)) + Expect(event.Reason).To(Equal(v1alpha2.ReasonDataSourceQuotaExceeded)) + Expect(event.Message).To(Equal("DataSource quota exceed")) + } + }, + Entry("skips when pvc is absent", + nil, + nil, + nil, + metav1.NewTime(time.Now()), + nil, + 0, + 0, + v1alpha2.ImagePhase(""), + conditions.ReasonUnknown.String(), + "", + time.Duration(0), + ), + Entry("skips when pvc has no storage class", + &corev1.PersistentVolumeClaim{}, + nil, + nil, + metav1.NewTime(time.Now()), + nil, + 0, + 0, + v1alpha2.ImagePhase(""), + conditions.ReasonUnknown.String(), + "", + time.Duration(0), + ), + Entry("skips for non wait for first consumer storage class", + newPVCWithStorageClass("immediate"), + []client.Object{newStorageClass("immediate", storagev1.VolumeBindingImmediate)}, + nil, + metav1.NewTime(time.Now()), + nil, + 0, + 0, + v1alpha2.ImagePhase(""), + conditions.ReasonUnknown.String(), + "", + time.Duration(0), + ), + Entry("starts bounder for wait for first consumer storage class", + newPVCWithStorageClass("wffc"), + []client.Object{newStorageClass("wffc", storagev1.VolumeBindingWaitForFirstConsumer)}, + nil, + metav1.NewTime(time.Now()), + nil, + 1, + 0, + v1alpha2.ImagePhase(""), + vicondition.Provisioning.String(), + "Bounder pod has created: waiting to be Bound.", + time.Duration(0), + ), + Entry("handles quota exceeded error", + newPVCWithStorageClass("wffc"), + []client.Object{newStorageClass("wffc", storagev1.VolumeBindingWaitForFirstConsumer)}, + errors.New("exceeded quota: test quota"), + metav1.NewTime(time.Now()), + nil, + 1, + 1, + v1alpha2.ImageFailed, + vicondition.ProvisioningFailed.String(), + "Quota exceeded: exceeded quota: test quota; Please configure quotas or try recreating the resource later.", + time.Duration(0), + ), + Entry("returns unknown bounder error", + newPVCWithStorageClass("wffc"), + []client.Object{newStorageClass("wffc", storagev1.VolumeBindingWaitForFirstConsumer)}, + errors.New("boom"), + metav1.NewTime(time.Now()), + errors.New("boom"), + 1, + 0, + v1alpha2.ImageFailed, + vicondition.ProvisioningFailed.String(), + "Unexpected error: boom", + time.Duration(0), + ), + ) +}) + +func newPVCWithStorageClass(name string) *corev1.PersistentVolumeClaim { + return &corev1.PersistentVolumeClaim{ + Spec: corev1.PersistentVolumeClaimSpec{ + StorageClassName: &name, + }, + } +} + +func newStorageClass(name string, mode storagev1.VolumeBindingMode) *storagev1.StorageClass { + return &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + VolumeBindingMode: ptr.To(mode), + } +} diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/step/create_pod_step_test.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/create_pod_step_test.go new file mode 100644 index 0000000000..a8ffb8bb9c --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/create_pod_step_test.go @@ -0,0 +1,326 @@ +/* +Copyright 2026 Flant JSC + +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 step + +import ( + "context" + "errors" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/common/datasource" + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/importer" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" + "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" + "github.com/deckhouse/virtualization-controller/pkg/dvcr" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vicondition" +) + +type createPodStepImporterStub struct { + startErr error + startCalls int + podSettingsCalls int + settings *importer.Settings + podSettings *importer.PodSettings +} + +func (s *createPodStepImporterStub) GetPodSettingsWithPVC(_ *metav1.OwnerReference, _ supplements.Generator, _, _ string) *importer.PodSettings { + s.podSettingsCalls++ + if s.podSettings != nil { + return s.podSettings + } + + return &importer.PodSettings{} +} + +func (s *createPodStepImporterStub) StartWithPodSetting(_ context.Context, settings *importer.Settings, _ supplements.Generator, _ *datasource.CABundle, podSettings *importer.PodSettings, _ ...service.Option) error { + s.startCalls++ + s.settings = settings + s.podSettings = podSettings + return s.startErr +} + +type createPodStepStatStub struct { + dvcrImageName string +} + +func (s createPodStepStatStub) GetSize(_ *corev1.Pod) v1alpha2.ImageStatusSize { + return v1alpha2.ImageStatusSize{} +} +func (s createPodStepStatStub) GetDVCRImageName(_ *corev1.Pod) string { return s.dvcrImageName } +func (s createPodStepStatStub) GetFormat(_ *corev1.Pod) string { return "" } +func (s createPodStepStatStub) GetCDROM(_ *corev1.Pod) bool { return false } + +var _ = Describe("CreatePodStep", func() { + newCreatePodScheme := func() *runtime.Scheme { + scheme := runtime.NewScheme() + Expect(corev1.AddToScheme(scheme)).To(Succeed()) + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + return scheme + } + + newCreatePodVI := func() *v1alpha2.VirtualImage { + return &v1alpha2.VirtualImage{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v1alpha2.SchemeGroupVersion.String(), + Kind: v1alpha2.VirtualImageKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "vi", + Namespace: "default", + UID: types.UID("vi-uid"), + CreationTimestamp: metav1.NewTime(time.Now()), + }, + Spec: v1alpha2.VirtualImageSpec{ + DataSource: v1alpha2.VirtualImageDataSource{ + ObjectRef: &v1alpha2.VirtualImageObjectRef{Name: "snapshot"}, + }, + }, + } + } + + newCreatePodObjects := func(volumeMode *corev1.PersistentVolumeMode) []client.Object { + return []client.Object{ + &v1alpha2.VirtualDiskSnapshot{ + ObjectMeta: metav1.ObjectMeta{Name: "snapshot", Namespace: "default"}, + Spec: v1alpha2.VirtualDiskSnapshotSpec{VirtualDiskName: "disk"}, + }, + &v1alpha2.VirtualDisk{ + ObjectMeta: metav1.ObjectMeta{Name: "disk", Namespace: "default"}, + Status: v1alpha2.VirtualDiskStatus{Target: v1alpha2.DiskTarget{PersistentVolumeClaim: "disk-pvc"}}, + }, + &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "disk-pvc", Namespace: "default"}, + Spec: corev1.PersistentVolumeClaimSpec{VolumeMode: volumeMode}, + }, + } + } + + newCreatePodRecorder := func() *eventrecord.EventRecorderLoggerMock { + var recorder *eventrecord.EventRecorderLoggerMock + recorder = &eventrecord.EventRecorderLoggerMock{ + EventFunc: noopEvent, + WithLoggingFunc: func(eventrecord.InfoLogger) eventrecord.EventRecorderLogger { + return recorder + }, + } + return recorder + } + + newCreatePodStep := func( + pod *corev1.Pod, + objects []client.Object, + importerStub *createPodStepImporterStub, + recorder *eventrecord.EventRecorderLoggerMock, + stat createPodStepStatStub, + cb *conditions.ConditionBuilder, + settings *dvcr.Settings, + ) *CreatePodStep { + return NewCreatePodStep( + pod, + fake.NewClientBuilder().WithScheme(newCreatePodScheme()).WithObjects(objects...).Build(), + settings, + recorder, + importerStub, + stat, + cb, + ) + } + + It("skips when pod already exists", func() { + vi := newCreatePodVI() + cb := conditions.NewConditionBuilder(vicondition.ReadyType) + importerStub := &createPodStepImporterStub{} + + result, err := newCreatePodStep( + &corev1.Pod{}, + nil, + importerStub, + newCreatePodRecorder(), + createPodStepStatStub{}, + cb, + &dvcr.Settings{}, + ).Take(context.Background(), vi) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeNil()) + Expect(importerStub.podSettingsCalls).To(BeZero()) + Expect(importerStub.startCalls).To(BeZero()) + }) + + DescribeTable( + "returns get errors before importer start", + func(objects []client.Object, expectedErr error) { + vi := newCreatePodVI() + cb := conditions.NewConditionBuilder(vicondition.ReadyType) + importerStub := &createPodStepImporterStub{} + + result, err := newCreatePodStep( + nil, + objects, + importerStub, + newCreatePodRecorder(), + createPodStepStatStub{}, + cb, + &dvcr.Settings{}, + ).Take(context.Background(), vi) + + Expect(err).To(MatchError(expectedErr)) + Expect(result).ToNot(BeNil()) + Expect(*result).To(Equal(reconcile.Result{})) + Expect(importerStub.startCalls).To(BeZero()) + }, + Entry("when virtual disk snapshot is missing", []client.Object{}, apierrors.NewNotFound(v1alpha2.Resource("virtualdisksnapshots"), "snapshot")), + Entry("when virtual disk is missing", []client.Object{ + &v1alpha2.VirtualDiskSnapshot{ObjectMeta: metav1.ObjectMeta{Name: "snapshot", Namespace: "default"}, Spec: v1alpha2.VirtualDiskSnapshotSpec{VirtualDiskName: "disk"}}, + }, apierrors.NewNotFound(v1alpha2.Resource("virtualdisks"), "disk")), + Entry("when pvc is missing", []client.Object{ + &v1alpha2.VirtualDiskSnapshot{ObjectMeta: metav1.ObjectMeta{Name: "snapshot", Namespace: "default"}, Spec: v1alpha2.VirtualDiskSnapshotSpec{VirtualDiskName: "disk"}}, + &v1alpha2.VirtualDisk{ObjectMeta: metav1.ObjectMeta{Name: "disk", Namespace: "default"}, Status: v1alpha2.VirtualDiskStatus{Target: v1alpha2.DiskTarget{PersistentVolumeClaim: "disk-pvc"}}}, + }, apierrors.NewNotFound(corev1.Resource("persistentvolumeclaims"), "disk-pvc")), + ) + + DescribeTable( + "handles importer start errors", + func( + creationTimestamp metav1.Time, + startErr error, + expectedErr error, + expectedMessage string, + expectedEventCalls int, + expectedRequeueAfter time.Duration, + ) { + vi := newCreatePodVI() + vi.CreationTimestamp = creationTimestamp + cb := conditions.NewConditionBuilder(vicondition.ReadyType) + importerStub := &createPodStepImporterStub{startErr: startErr} + recorder := newCreatePodRecorder() + + result, err := newCreatePodStep( + nil, + newCreatePodObjects(nil), + importerStub, + recorder, + createPodStepStatStub{}, + cb, + &dvcr.Settings{}, + ).Take(context.Background(), vi) + + if expectedErr == nil { + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(result.RequeueAfter).To(Equal(expectedRequeueAfter)) + } else { + Expect(err).To(MatchError(expectedErr)) + Expect(result).To(BeNil()) + } + + Expect(importerStub.startCalls).To(Equal(1)) + Expect(vi.Status.Phase).To(Equal(v1alpha2.ImageFailed)) + Expect(cb.Condition().Status).To(Equal(metav1.ConditionFalse)) + Expect(cb.Condition().Reason).To(Equal(vicondition.ProvisioningFailed.String())) + Expect(cb.Condition().Message).To(Equal(expectedMessage)) + Expect(recorder.EventCalls()).To(HaveLen(expectedEventCalls)) + }, + Entry( + "quota exceeded for a fresh object", + metav1.NewTime(time.Now()), + errors.New("exceeded quota: namespace quota"), + nil, + "Quota exceeded: exceeded quota: namespace quota; Please configure quotas or try recreating the resource later.", + 1, + time.Duration(0), + ), + Entry( + "quota exceeded for an old object", + metav1.NewTime(time.Now().Add(-31*time.Minute)), + errors.New("exceeded quota: namespace quota"), + nil, + "Quota exceeded: exceeded quota: namespace quota; Retry in 1 minute.", + 1, + time.Minute, + ), + Entry( + "unknown error", + metav1.NewTime(time.Now()), + errors.New("boom"), + errors.New("boom"), + "Unexpected error: boom", + 0, + time.Duration(0), + ), + ) + + DescribeTable( + "getEnvSettings chooses source type from pvc volume mode", + func(volumeMode *corev1.PersistentVolumeMode, expectedSource string) { + vi := newCreatePodVI() + settings := &dvcr.Settings{RegistryURL: "registry.example.com", AuthSecret: "dvcr-secret", AuthSecretNamespace: "default"} + step := newCreatePodStep(nil, nil, &createPodStepImporterStub{}, newCreatePodRecorder(), createPodStepStatStub{}, conditions.NewConditionBuilder(vicondition.ReadyType), settings) + + envSettings := step.getEnvSettings(vi, supplements.NewGenerator("vi", vi.Name, vi.Namespace, vi.UID), volumeMode) + Expect(envSettings.Source).To(Equal(expectedSource)) + Expect(envSettings.DestinationAuthSecret).To(Equal("dvcr-secret")) + Expect(envSettings.DestinationEndpoint).To(Equal("registry.example.com/vi/default/vi:vi-uid")) + }, + Entry("filesystem by default", nil, importer.SourceFilesystem), + Entry("block device for block volume mode", ptr.To(corev1.PersistentVolumeBlock), importer.SourceBlockDevice), + ) + + It("starts importer and updates image status", func() { + vi := newCreatePodVI() + cb := conditions.NewConditionBuilder(vicondition.ReadyType) + importerStub := &createPodStepImporterStub{} + stat := createPodStepStatStub{dvcrImageName: "registry.example.com/custom:tag"} + + result, err := newCreatePodStep( + nil, + newCreatePodObjects(ptr.To(corev1.PersistentVolumeBlock)), + importerStub, + newCreatePodRecorder(), + stat, + cb, + &dvcr.Settings{RegistryURL: "registry.example.com", AuthSecret: "dvcr-secret", AuthSecretNamespace: "default"}, + ).Take(context.Background(), vi) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeNil()) + Expect(importerStub.podSettingsCalls).To(Equal(1)) + Expect(importerStub.startCalls).To(Equal(1)) + Expect(importerStub.settings).ToNot(BeNil()) + Expect(importerStub.settings.Source).To(Equal(importer.SourceBlockDevice)) + Expect(importerStub.settings.DestinationAuthSecret).To(Equal("dvcr-secret")) + Expect(importerStub.settings.DestinationEndpoint).To(Equal("registry.example.com/vi/default/vi:vi-uid")) + Expect(vi.Status.Progress).To(Equal("0%")) + Expect(vi.Status.Target.RegistryURL).To(Equal("registry.example.com/custom:tag")) + Expect(cb.Condition().Status).To(Equal(metav1.ConditionUnknown)) + }) +}) diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/step/ready_cr_step_test.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/ready_cr_step_test.go new file mode 100644 index 0000000000..6f0123a557 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/ready_cr_step_test.go @@ -0,0 +1,270 @@ +/* +Copyright 2026 Flant JSC + +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 step + +import ( + "context" + "errors" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" + "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vicondition" +) + +type readyContainerRegistryStepCleanerStub struct { + cleanupErr error + calls int +} + +func (s *readyContainerRegistryStepCleanerStub) CleanUpSupplements(context.Context, supplements.Generator) (bool, error) { + s.calls++ + if s.cleanupErr != nil { + return false, s.cleanupErr + } + + return true, nil +} + +type readyContainerRegistryStepStatStub struct { + checkPodErr error + size v1alpha2.ImageStatusSize + dvcrImageName string + format string + cdrom bool +} + +func (s readyContainerRegistryStepStatStub) GetSize(_ *corev1.Pod) v1alpha2.ImageStatusSize { + return s.size +} + +func (s readyContainerRegistryStepStatStub) GetDVCRImageName(_ *corev1.Pod) string { + return s.dvcrImageName +} + +func (s readyContainerRegistryStepStatStub) GetFormat(_ *corev1.Pod) string { + return s.format +} + +func (s readyContainerRegistryStepStatStub) CheckPod(_ *corev1.Pod) error { + return s.checkPodErr +} + +func (s readyContainerRegistryStepStatStub) GetCDROM(_ *corev1.Pod) bool { + return s.cdrom +} + +var _ = Describe("ReadyContainerRegistryStep", func() { + newRecorder := func() *eventrecord.EventRecorderLoggerMock { + var recorder *eventrecord.EventRecorderLoggerMock + recorder = &eventrecord.EventRecorderLoggerMock{ + EventFunc: noopEvent, + WithLoggingFunc: func(eventrecord.InfoLogger) eventrecord.EventRecorderLogger { + return recorder + }, + } + + return recorder + } + + newVI := func() *v1alpha2.VirtualImage { + return &v1alpha2.VirtualImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vi", + Namespace: "default", + UID: types.UID("vi-uid"), + }, + } + } + + It("marks image ready immediately when ready condition is already true", func() { + vi := newVI() + vi.Status.Conditions = []metav1.Condition{{ + Type: vicondition.ReadyType.String(), + Status: metav1.ConditionTrue, + }} + cb := conditions.NewConditionBuilder(vicondition.ReadyType) + importer := &readyContainerRegistryStepCleanerStub{} + diskService := &readyContainerRegistryStepCleanerStub{} + + result, err := NewReadyContainerRegistryStep(nil, diskService, importer, readyContainerRegistryStepStatStub{}, newRecorder(), cb).Take(context.Background(), vi) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(*result).To(Equal(reconcile.Result{})) + Expect(vi.Status.Phase).To(Equal(v1alpha2.ImageReady)) + Expect(cb.Condition().Status).To(Equal(metav1.ConditionTrue)) + Expect(cb.Condition().Reason).To(Equal(vicondition.Ready.String())) + Expect(cb.Condition().Message).To(BeEmpty()) + Expect(importer.calls).To(BeZero()) + Expect(diskService.calls).To(BeZero()) + }) + + It("waits while pod is not complete", func() { + vi := newVI() + cb := conditions.NewConditionBuilder(vicondition.ReadyType) + importer := &readyContainerRegistryStepCleanerStub{} + diskService := &readyContainerRegistryStepCleanerStub{} + pod := &corev1.Pod{Status: corev1.PodStatus{Phase: corev1.PodRunning}} + + result, err := NewReadyContainerRegistryStep(pod, diskService, importer, readyContainerRegistryStepStatStub{}, newRecorder(), cb).Take(context.Background(), vi) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeNil()) + Expect(vi.Status.Phase).To(BeZero()) + Expect(cb.Condition().Status).To(Equal(metav1.ConditionUnknown)) + Expect(cb.Condition().Reason).To(Equal(conditions.ReasonUnknown.String())) + Expect(importer.calls).To(BeZero()) + Expect(diskService.calls).To(BeZero()) + }) + + DescribeTable( + "handles completed pod results", + func( + checkPodErr error, + expectedErr error, + expectedPhase v1alpha2.ImagePhase, + expectedReason string, + expectedMessage string, + ) { + vi := newVI() + cb := conditions.NewConditionBuilder(vicondition.ReadyType) + pod := &corev1.Pod{Status: corev1.PodStatus{Phase: corev1.PodSucceeded}} + + result, err := NewReadyContainerRegistryStep( + pod, + &readyContainerRegistryStepCleanerStub{}, + &readyContainerRegistryStepCleanerStub{}, + readyContainerRegistryStepStatStub{checkPodErr: checkPodErr}, + newRecorder(), + cb, + ).Take(context.Background(), vi) + + if expectedErr == nil { + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(*result).To(Equal(reconcile.Result{})) + } else { + Expect(err).To(MatchError(expectedErr)) + Expect(result).To(BeNil()) + } + + Expect(vi.Status.Phase).To(Equal(expectedPhase)) + Expect(cb.Condition().Reason).To(Equal(expectedReason)) + Expect(cb.Condition().Message).To(Equal(expectedMessage)) + if expectedReason == vicondition.ProvisioningFailed.String() { + Expect(cb.Condition().Status).To(Equal(metav1.ConditionFalse)) + } else { + Expect(cb.Condition().Status).To(Equal(metav1.ConditionUnknown)) + } + }, + Entry("sets provisioning failed condition", fmt.Errorf("%w: importer failed", service.ErrProvisioningFailed), nil, + v1alpha2.ImageFailed, + vicondition.ProvisioningFailed.String(), + "Provisioning failed: importer failed.", + ), + Entry("returns unknown error", errors.New("boom"), errors.New("boom"), + v1alpha2.ImageFailed, + conditions.ReasonUnknown.String(), + "", + ), + ) + + DescribeTable( + "returns cleanup errors", + func( + importerErr error, + diskServiceErr error, + expectedErr string, + expectedImporterCalls int, + expectedDiskServiceCalls int, + ) { + vi := newVI() + cb := conditions.NewConditionBuilder(vicondition.ReadyType) + pod := &corev1.Pod{Status: corev1.PodStatus{Phase: corev1.PodSucceeded}} + importer := &readyContainerRegistryStepCleanerStub{cleanupErr: importerErr} + diskService := &readyContainerRegistryStepCleanerStub{cleanupErr: diskServiceErr} + + result, err := NewReadyContainerRegistryStep( + pod, + diskService, + importer, + readyContainerRegistryStepStatStub{}, + newRecorder(), + cb, + ).Take(context.Background(), vi) + + Expect(err).To(MatchError(expectedErr)) + Expect(result).To(BeNil()) + Expect(importer.calls).To(Equal(expectedImporterCalls)) + Expect(diskService.calls).To(Equal(expectedDiskServiceCalls)) + Expect(cb.Condition().Status).To(Equal(metav1.ConditionUnknown)) + }, + Entry("when importer cleanup fails", errors.New("importer cleanup failed"), nil, + "clean up supplements: importer cleanup failed", 1, 0, + ), + Entry("when disk service cleanup fails", nil, errors.New("disk service cleanup failed"), + "clean up supplements: disk service cleanup failed", 1, 1, + ), + ) + + It("marks image ready and fills status fields after successful cleanup", func() { + vi := newVI() + cb := conditions.NewConditionBuilder(vicondition.ReadyType) + pod := &corev1.Pod{Status: corev1.PodStatus{Phase: corev1.PodSucceeded}} + importer := &readyContainerRegistryStepCleanerStub{} + diskService := &readyContainerRegistryStepCleanerStub{} + recorder := newRecorder() + stat := readyContainerRegistryStepStatStub{ + size: v1alpha2.ImageStatusSize{ + Stored: "10Gi", + Unpacked: "12Gi", + }, + dvcrImageName: "registry.example.com/image:tag", + format: "qcow2", + cdrom: true, + } + + result, err := NewReadyContainerRegistryStep(pod, diskService, importer, stat, recorder, cb).Take(context.Background(), vi) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(*result).To(Equal(reconcile.Result{})) + Expect(importer.calls).To(Equal(1)) + Expect(diskService.calls).To(Equal(1)) + Expect(recorder.EventCalls()).To(HaveLen(1)) + Expect(recorder.EventCalls()[0].Eventtype).To(Equal(corev1.EventTypeNormal)) + Expect(recorder.EventCalls()[0].Reason).To(Equal(v1alpha2.ReasonDataSourceSyncCompleted)) + Expect(recorder.EventCalls()[0].Message).To(Equal("The ObjectRef DataSource import has completed")) + Expect(vi.Status.Phase).To(Equal(v1alpha2.ImageReady)) + Expect(vi.Status.Size).To(Equal(stat.size)) + Expect(vi.Status.CDROM).To(BeTrue()) + Expect(vi.Status.Format).To(Equal("qcow2")) + Expect(vi.Status.Progress).To(Equal("100%")) + Expect(vi.Status.Target.RegistryURL).To(Equal("registry.example.com/image:tag")) + Expect(cb.Condition().Status).To(Equal(metav1.ConditionTrue)) + Expect(cb.Condition().Reason).To(Equal(vicondition.Ready.String())) + Expect(cb.Condition().Message).To(BeEmpty()) + }) +}) diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/step/ready_pvc_step.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/ready_pvc_step.go index 504a81ca70..c85e3f98aa 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/source/step/ready_pvc_step.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/ready_pvc_step.go @@ -119,7 +119,6 @@ func (s ReadyPersistentVolumeClaimStep) Take(ctx context.Context, vi *v1alpha2.V vi.Status.Progress = "100%" res := s.pvc.Status.Capacity[corev1.ResourceStorage] - intQ, ok := res.AsInt64() if !ok { return nil, errors.New("failed to convert quantity to int64") diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/step/ready_pvc_step_test.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/ready_pvc_step_test.go new file mode 100644 index 0000000000..2ed4b131ff --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/ready_pvc_step_test.go @@ -0,0 +1,79 @@ +/* +Copyright 2026 Flant JSC + +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 step + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vicondition" +) + +type noopReadyPersistentVolumeClaimStepBounder struct{} + +func (noopReadyPersistentVolumeClaimStepBounder) CleanUpSupplements(context.Context, supplements.Generator) (bool, error) { + return true, nil +} + +func noopEvent(client.Object, string, string, string) {} + +var _ = Describe("ReadyPersistentVolumeClaimStep", func() { + It("uses pvc capacity for stored and unpacked size", func() { + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "image-pvc"}, + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("10Gi"), + }, + }, + }, + Status: corev1.PersistentVolumeClaimStatus{ + Phase: corev1.ClaimBound, + Capacity: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("12Gi"), + }, + }, + } + + vi := &v1alpha2.VirtualImage{} + var recorder *eventrecord.EventRecorderLoggerMock + recorder = &eventrecord.EventRecorderLoggerMock{ + EventFunc: noopEvent, + WithLoggingFunc: func(logger eventrecord.InfoLogger) eventrecord.EventRecorderLogger { + return recorder + }, + } + cb := conditions.NewConditionBuilder(vicondition.ReadyType) + step := NewReadyPersistentVolumeClaimStep(pvc, noopReadyPersistentVolumeClaimStepBounder{}, recorder, cb) + + _, err := step.Take(context.Background(), vi) + Expect(err).ToNot(HaveOccurred()) + Expect(vi.Status.Size.Stored).To(Equal("12Gi")) + Expect(vi.Status.Size.Unpacked).To(Equal("12Gi")) + }) +}) diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/step/step_suite_test.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/step_suite_test.go new file mode 100644 index 0000000000..0d422e377f --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/step_suite_test.go @@ -0,0 +1,29 @@ +/* +Copyright 2026 Flant JSC + +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 step + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestSteps(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Source Steps") +} diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/step/terminating_step_test.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/terminating_step_test.go new file mode 100644 index 0000000000..a23125f18f --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/terminating_step_test.go @@ -0,0 +1,51 @@ +/* +Copyright 2026 Flant JSC + +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 step + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +var _ = Describe("TerminatingStep", func() { + now := metav1.NewTime(time.Now()) + + DescribeTable("Take", + func(pvc *corev1.PersistentVolumeClaim, expectResult bool) { + result, err := NewTerminatingStep(pvc).Take(context.Background(), &v1alpha2.VirtualImage{}) + Expect(err).ToNot(HaveOccurred()) + + if expectResult { + Expect(result).ToNot(BeNil()) + Expect(result.IsZero()).To(BeFalse()) + return + } + + Expect(result).To(BeNil()) + }, + Entry("returns nil when pvc is absent", nil, false), + Entry("returns nil when pvc is not terminating", &corev1.PersistentVolumeClaim{}, false), + Entry("requeues when pvc is terminating", &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{DeletionTimestamp: &now}}, true), + ) +}) diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/step/wait_for_pod_step_test.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/wait_for_pod_step_test.go new file mode 100644 index 0000000000..ac53665bed --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/wait_for_pod_step_test.go @@ -0,0 +1,177 @@ +/* +Copyright 2026 Flant JSC + +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 step + +import ( + "context" + "errors" + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vicondition" +) + +type waitForPodStepStatStub struct { + checkPodErr error + dvcrImageName string + progress string +} + +func (s waitForPodStepStatStub) GetProgress(_ types.UID, _ *corev1.Pod, _ string, _ ...service.GetProgressOption) string { + return s.progress +} + +func (s waitForPodStepStatStub) GetDVCRImageName(_ *corev1.Pod) string { + return s.dvcrImageName +} + +func (s waitForPodStepStatStub) CheckPod(_ *corev1.Pod) error { + return s.checkPodErr +} + +var _ = Describe("WaitForPodStep", func() { + DescribeTable("Take", + func( + pod *corev1.Pod, + stat waitForPodStepStatStub, + expectedErr error, + expectedResult reconcile.Result, + expectedPhase v1alpha2.ImagePhase, + expectedReason string, + expectedMessage string, + expectedRegistryURL string, + expectedProgress string, + ) { + vi := &v1alpha2.VirtualImage{ + ObjectMeta: metav1.ObjectMeta{UID: types.UID("vi-uid")}, + Status: v1alpha2.VirtualImageStatus{ + Progress: "10%", + }, + } + cb := conditions.NewConditionBuilder(vicondition.ReadyType) + + result, err := NewWaitForPodStep(pod, nil, stat, cb).Take(context.Background(), vi) + if expectedErr == nil { + Expect(err).ToNot(HaveOccurred()) + } else { + Expect(err).To(MatchError(expectedErr)) + } + + Expect(result).ToNot(BeNil()) + Expect(*result).To(Equal(expectedResult)) + Expect(vi.Status.Phase).To(Equal(expectedPhase)) + Expect(vi.Status.Target.RegistryURL).To(Equal(expectedRegistryURL)) + + if expectedProgress == "" { + Expect(vi.Status.Progress).To(Equal("10%")) + } else { + Expect(vi.Status.Progress).To(Equal(expectedProgress)) + } + + Expect(cb.Condition().Status).To(Equal(metav1.ConditionFalse)) + Expect(cb.Condition().Reason).To(Equal(expectedReason)) + Expect(cb.Condition().Message).To(Equal(expectedMessage)) + }, + Entry("waits when pod is absent", + nil, + waitForPodStepStatStub{}, + nil, + reconcile.Result{}, + v1alpha2.ImageProvisioning, + vicondition.Provisioning.String(), + "Waiting for the importer pod to be created by controller.", + "", + "", + ), + Entry("requeues when pvc is not yet bound", + &corev1.Pod{}, + waitForPodStepStatStub{checkPodErr: fmt.Errorf("%w: pod has unbound immediate PersistentVolumeClaims", service.ErrNotInitialized)}, + nil, + reconcile.Result{Requeue: true}, + v1alpha2.ImageProvisioning, + vicondition.Provisioning.String(), + "Waiting for PersistentVolumeClaim to be Bound", + "", + "", + ), + Entry("fails when provisioning did not start", + &corev1.Pod{}, + waitForPodStepStatStub{checkPodErr: fmt.Errorf("%w: waiting for init", service.ErrNotInitialized)}, + nil, + reconcile.Result{}, + v1alpha2.ImageFailed, + vicondition.ProvisioningNotStarted.String(), + "Not initialized: waiting for init.", + "", + "", + ), + Entry("fails when provisioning has failed", + &corev1.Pod{}, + waitForPodStepStatStub{checkPodErr: fmt.Errorf("%w: importer failed", service.ErrProvisioningFailed)}, + nil, + reconcile.Result{}, + v1alpha2.ImageFailed, + vicondition.ProvisioningFailed.String(), + "Provisioning failed: importer failed.", + "", + "", + ), + Entry("returns unknown error", + &corev1.Pod{}, + waitForPodStepStatStub{checkPodErr: errors.New("boom")}, + errors.New("boom"), + reconcile.Result{}, + v1alpha2.ImageFailed, + vicondition.ProvisioningFailed.String(), + "Boom.", + "", + "", + ), + Entry("waits while pod is not running", + &corev1.Pod{Status: corev1.PodStatus{Phase: corev1.PodPending}}, + waitForPodStepStatStub{dvcrImageName: "registry/image:pending"}, + nil, + reconcile.Result{}, + v1alpha2.ImageProvisioning, + vicondition.Provisioning.String(), + "Preparing to start import to DVCR.", + "registry/image:pending", + "", + ), + Entry("updates progress for running pod", + &corev1.Pod{Status: corev1.PodStatus{Phase: corev1.PodRunning}}, + waitForPodStepStatStub{dvcrImageName: "registry/image:running", progress: "45%"}, + nil, + reconcile.Result{RequeueAfter: 2 * time.Second}, + v1alpha2.ImageProvisioning, + vicondition.Provisioning.String(), + "Import is in the process of provisioning to DVCR.", + "registry/image:running", + "45%", + ), + ) +}) diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/step/wait_for_pvc_step_test.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/wait_for_pvc_step_test.go new file mode 100644 index 0000000000..a514e04c5b --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/wait_for_pvc_step_test.go @@ -0,0 +1,89 @@ +/* +Copyright 2026 Flant JSC + +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 step + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vdcondition" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vicondition" +) + +var _ = Describe("WaitForPVCStep", func() { + DescribeTable("Take", + func( + pvc *corev1.PersistentVolumeClaim, + expectedPhase v1alpha2.ImagePhase, + expectedStatus metav1.ConditionStatus, + expectedReason string, + expectedMessage string, + expectResult bool, + ) { + vi := &v1alpha2.VirtualImage{} + cb := conditions.NewConditionBuilder(vicondition.ReadyType) + + result, err := NewWaitForPVCStep(pvc, cb).Take(context.Background(), vi) + Expect(err).ToNot(HaveOccurred()) + + if expectResult { + Expect(result).ToNot(BeNil()) + Expect(*result).To(BeZero()) + } else { + Expect(result).To(BeNil()) + } + + Expect(vi.Status.Phase).To(Equal(expectedPhase)) + Expect(cb.Condition().Status).To(Equal(expectedStatus)) + Expect(cb.Condition().Reason).To(Equal(expectedReason)) + Expect(cb.Condition().Message).To(Equal(expectedMessage)) + }, + Entry("waits when pvc is absent", + nil, + v1alpha2.ImageProvisioning, + metav1.ConditionFalse, + vicondition.Provisioning.String(), + "Waiting for the underlying PersistentVolumeClaim to be created by controller.", + true, + ), + Entry("returns nil for bound pvc", + &corev1.PersistentVolumeClaim{Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}}, + v1alpha2.ImagePhase(""), + metav1.ConditionUnknown, + conditions.ReasonUnknown.String(), + "", + false, + ), + Entry("waits until pvc becomes bound", + &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "image-pvc"}, + Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimPending}, + }, + v1alpha2.ImageProvisioning, + metav1.ConditionFalse, + vdcondition.Provisioning.String(), + "Waiting for the PVC image-pvc to be Bound.", + true, + ), + ) +})