From 79c5b2f59363db4e667082fba88567591cf5e69a Mon Sep 17 00:00:00 2001 From: Roman Sysoev Date: Wed, 6 May 2026 17:37:53 +0300 Subject: [PATCH] test: add phase watcher and VirtualDiskPhaseTransitions test - Add EventPhaseWatcher for tracking resource phase transitions - Add VerifyPhaseTransitions helper for phase sequence verification - Extract DataExport, IsNFS, needPublishOption to util.go - Add VirtualDiskPhaseTransitions test for export flow Signed-off-by: Roman Sysoev --- test/e2e/blockdevice/data_exports.go | 51 +-- test/e2e/blockdevice/util.go | 84 +++++ test/e2e/blockdevice/vd_phase_transitions.go | 81 +++++ test/e2e/internal/util/event_watcher.go | 318 +++++++++++++++++++ 4 files changed, 485 insertions(+), 49 deletions(-) create mode 100644 test/e2e/blockdevice/util.go create mode 100644 test/e2e/blockdevice/vd_phase_transitions.go create mode 100644 test/e2e/internal/util/event_watcher.go diff --git a/test/e2e/blockdevice/data_exports.go b/test/e2e/blockdevice/data_exports.go index acf58fc293..9969a75569 100644 --- a/test/e2e/blockdevice/data_exports.go +++ b/test/e2e/blockdevice/data_exports.go @@ -28,8 +28,6 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - corev1 "k8s.io/api/core/v1" - k8serrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" @@ -40,7 +38,6 @@ import ( vmbuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vm" vmopbuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vmop" "github.com/deckhouse/virtualization/api/core/v1alpha2" - "github.com/deckhouse/virtualization/test/e2e/internal/d8" "github.com/deckhouse/virtualization/test/e2e/internal/framework" "github.com/deckhouse/virtualization/test/e2e/internal/label" "github.com/deckhouse/virtualization/test/e2e/internal/object" @@ -155,11 +152,11 @@ var _ = Describe("DataExports", label.Slow(), Label(precheck.PrecheckSVDM, prech }) By("Exporting VirtualDisk to local file", func() { - exportData(f, "vd", vdData.Name, exportedDiskFile) + DataExport(f, "vd", vdData.Name, exportedDiskFile) }) By("Exporting VirtualDiskSnapshot to local file", func() { - exportData(f, "vds", vdSnapshot.Name, exportedSnapshotFile) + DataExport(f, "vds", vdSnapshot.Name, exportedSnapshotFile) }) By("Deleting the original data disk", func() { @@ -236,50 +233,6 @@ var _ = Describe("DataExports", label.Slow(), Label(precheck.PrecheckSVDM, prech }) }) -func IsNFS() bool { - sc := framework.GetConfig().StorageClass.TemplateStorageClass - if sc == nil { - return false - } - return sc.Provisioner == framework.NFS -} - -func needPublishOption(f *framework.Framework) bool { - hostname, err := os.Hostname() - Expect(err).NotTo(HaveOccurred(), "Failed to get hostname") - var node corev1.Node - err = f.Clients.GenericClient().Get( - context.Background(), - types.NamespacedName{Name: hostname}, - &node, - ) - if k8serrors.IsNotFound(err) { - return true - } - Expect(err).NotTo(HaveOccurred(), "Failed to get node %s", hostname) - return false -} - -func exportData(f *framework.Framework, resourceType, name, outputFile string) { - opts := d8.DataExportOptions{ - Namespace: f.Namespace().Name, - OutputFile: outputFile, - Publish: needPublishOption(f), - Timeout: framework.LongTimeout, - Cleanup: true, - } - if IsNFS() { - opts.SourcePath = diskImageExportFile - } - err := f.D8Virtualization().DataExportDownload(resourceType, name, opts) - Expect(err).NotTo(HaveOccurred()) - - DeferCleanup(func() { - err := os.Remove(outputFile) - Expect(err == nil || errors.Is(err, os.ErrNotExist)).To(BeTrue(), "Failed to remove exported file %s: %v", outputFile, err) - }) -} - func createUploadDisk(f *framework.Framework, name string) *v1alpha2.VirtualDisk { vd := vdbuilder.New( vdbuilder.WithName(name), diff --git a/test/e2e/blockdevice/util.go b/test/e2e/blockdevice/util.go new file mode 100644 index 0000000000..dc0215c5c5 --- /dev/null +++ b/test/e2e/blockdevice/util.go @@ -0,0 +1,84 @@ +/* +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 blockdevice + +import ( + "context" + "errors" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + + "github.com/deckhouse/virtualization/test/e2e/internal/d8" + "github.com/deckhouse/virtualization/test/e2e/internal/framework" +) + +// IsNFS returns true if the storage class is NFS. +func IsNFS() bool { + sc := framework.GetConfig().StorageClass.TemplateStorageClass + if sc == nil { + return false + } + return sc.Provisioner == framework.NFS +} + +// needPublishOption returns true if publish option should be used for export. +func needPublishOption(f *framework.Framework) bool { + hostname, err := os.Hostname() + Expect(err).NotTo(HaveOccurred(), "Failed to get hostname") + var node corev1.Node + err = f.Clients.GenericClient().Get( + context.Background(), + types.NamespacedName{Name: hostname}, + &node, + ) + if k8serrors.IsNotFound(err) { + return true + } + Expect(err).NotTo(HaveOccurred(), "Failed to get node %s", hostname) + return false +} + +// DataExport exports a resource (VirtualDisk, VirtualDiskSnapshot, etc.) to a local file. +// Automatically cleans up the exported file after test. +// +// resourceType: "vd" for VirtualDisk, "vds" for VirtualDiskSnapshot, "vi" for VirtualImage, etc. +// Use needPublishOption and IsNFS from data_exports.go for configuration. +func DataExport(f *framework.Framework, resourceType, name, outputFile string) { + opts := d8.DataExportOptions{ + Namespace: f.Namespace().Name, + OutputFile: outputFile, + Publish: needPublishOption(f), + Timeout: framework.LongTimeout, + Cleanup: true, + } + if IsNFS() { + opts.SourcePath = diskImageExportFile + } + err := f.D8Virtualization().DataExportDownload(resourceType, name, opts) + Expect(err).NotTo(HaveOccurred()) + + DeferCleanup(func() { + err := os.Remove(outputFile) + Expect(err == nil || errors.Is(err, os.ErrNotExist)).To(BeTrue(), + "Failed to remove exported file %s: %v", outputFile, err) + }) +} diff --git a/test/e2e/blockdevice/vd_phase_transitions.go b/test/e2e/blockdevice/vd_phase_transitions.go new file mode 100644 index 0000000000..725dc1e199 --- /dev/null +++ b/test/e2e/blockdevice/vd_phase_transitions.go @@ -0,0 +1,81 @@ +/* +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 blockdevice + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + vdbuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vd" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/test/e2e/internal/framework" + "github.com/deckhouse/virtualization/test/e2e/internal/label" + "github.com/deckhouse/virtualization/test/e2e/internal/object" + "github.com/deckhouse/virtualization/test/e2e/internal/precheck" + "github.com/deckhouse/virtualization/test/e2e/internal/util" +) + +var _ = Describe("VirtualDiskPhaseTransitions", label.Slow(), Label(precheck.PrecheckImmediateStorageClass), func() { + var f *framework.Framework + + BeforeEach(func() { + f = framework.NewFramework("vd-phase-transitions") + f.Before() + DeferCleanup(f.After) + }) + + It("tracks phase transitions during export", func() { + var vd *v1alpha2.VirtualDisk + + // Get immediate storage class for the test to ensure disk becomes Ready immediately + sc := framework.GetConfig().StorageClass.ImmediateStorageClass + Expect(sc).NotTo(BeNil(), "immediate storage class is required for this test") + + By("Creating VirtualDisk from CVI", func() { + vd = object.NewVDFromCVI("vd-test", f.Namespace().Name, object.PrecreatedCVIAlpineBIOS, + vdbuilder.WithPersistentVolumeClaim(&sc.Name, nil)) + + err := f.CreateWithDeferredDeletion(context.Background(), vd) + Expect(err).NotTo(HaveOccurred()) + }) + + By("Waiting for VirtualDisk to become Ready", func() { + util.UntilObjectPhase(string(v1alpha2.DiskReady), framework.LongTimeout, vd) + }) + + By("Starting event-based phase watcher", func() { + watcher := util.WatchPhases(context.Background(), vd) + Expect(watcher).NotTo(BeNil(), "failed to create event watcher for VirtualDisk") + + // Register defer early to ensure verification runs even if test fails later + defer util.VerifyPhaseTransitions(watcher, + string(v1alpha2.DiskReady), + string(v1alpha2.DiskExporting), + string(v1alpha2.DiskReady)) + + By("Exporting VirtualDisk", func() { + DataExport(f, "vd", vd.Name, "exported-disk-phases.img") + }) + + By("Waiting for VirtualDisk to return to Ready after export", func() { + util.UntilObjectPhase(string(v1alpha2.DiskReady), framework.LongTimeout, vd) + }) + }) + }) +}) diff --git a/test/e2e/internal/util/event_watcher.go b/test/e2e/internal/util/event_watcher.go new file mode 100644 index 0000000000..b73c96fbb3 --- /dev/null +++ b/test/e2e/internal/util/event_watcher.go @@ -0,0 +1,318 @@ +/* +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 util + +import ( + "context" + "fmt" + "sync" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/watch" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/test/e2e/internal/framework" +) + +// PhaseTransition represents a single phase change observed during tracking. +type PhaseTransition struct { + Phase string + At time.Time +} + +// EventPhaseWatcher watches for phase changes using Kubernetes Watch API. +type EventPhaseWatcher struct { + objectKey types.NamespacedName + transitions []PhaseTransition + stopCh chan struct{} + mu sync.Mutex + gvr schema.GroupVersionResource +} + +// WatchPhases starts watching phase changes for the given object. +// Returns nil if GVR cannot be determined for the object's kind. +func WatchPhases(ctx context.Context, obj client.Object) *EventPhaseWatcher { + gvk := obj.GetObjectKind().GroupVersionKind() + // If GVK is not set on the object, try to get it from the scheme + if gvk.Empty() { + gvks, _, err := framework.GetClients().GenericClient().Scheme().ObjectKinds(obj) + if err != nil || len(gvks) == 0 { + return nil + } + gvk = gvks[0] + } + + gvr, err := kindToResource(gvk) + if err != nil { + return nil + } + + ew := &EventPhaseWatcher{ + objectKey: types.NamespacedName{Name: obj.GetName(), Namespace: obj.GetNamespace()}, + stopCh: make(chan struct{}), + gvr: gvr, + } + + go ew.run(ctx) + return ew +} + +// kindToResource converts GVK to GVR using API constants. +func kindToResource(gvk schema.GroupVersionKind) (schema.GroupVersionResource, error) { + switch gvk.Kind { + case v1alpha2.VirtualDiskKind: + return schema.GroupVersionResource{ + Group: v1alpha2.SchemeGroupVersion.Group, + Version: v1alpha2.SchemeGroupVersion.Version, + Resource: v1alpha2.VirtualDiskResource, + }, nil + case v1alpha2.VirtualImageKind: + return schema.GroupVersionResource{ + Group: v1alpha2.SchemeGroupVersion.Group, + Version: v1alpha2.SchemeGroupVersion.Version, + Resource: v1alpha2.VirtualImageResource, + }, nil + case v1alpha2.ClusterVirtualImageKind: + return schema.GroupVersionResource{ + Group: v1alpha2.SchemeGroupVersion.Group, + Version: v1alpha2.SchemeGroupVersion.Version, + Resource: v1alpha2.ClusterVirtualImageResource, + }, nil + case v1alpha2.VirtualDiskSnapshotKind: + return schema.GroupVersionResource{ + Group: v1alpha2.SchemeGroupVersion.Group, + Version: v1alpha2.SchemeGroupVersion.Version, + Resource: v1alpha2.VirtualDiskSnapshotResource, + }, nil + case v1alpha2.VirtualMachineKind: + return schema.GroupVersionResource{ + Group: v1alpha2.SchemeGroupVersion.Group, + Version: v1alpha2.SchemeGroupVersion.Version, + Resource: v1alpha2.VirtualMachineResource, + }, nil + case v1alpha2.VirtualMachineBlockDeviceAttachmentKind: + return schema.GroupVersionResource{ + Group: v1alpha2.SchemeGroupVersion.Group, + Version: v1alpha2.SchemeGroupVersion.Version, + Resource: v1alpha2.VirtualMachineBlockDeviceAttachmentResource, + }, nil + case v1alpha2.VirtualMachineSnapshotKind: + return schema.GroupVersionResource{ + Group: v1alpha2.SchemeGroupVersion.Group, + Version: v1alpha2.SchemeGroupVersion.Version, + Resource: v1alpha2.VirtualMachineSnapshotResource, + }, nil + // KubeVirt + case "VirtualMachineInstance": + return schema.GroupVersionResource{ + Group: "kubevirt.io", + Version: "v1", + Resource: "virtualmachineinstances", + }, nil + } + + return schema.GroupVersionResource{}, fmt.Errorf("GVR not found for %s", gvk.Kind) +} + +func (ew *EventPhaseWatcher) run(ctx context.Context) { + defer GinkgoRecover() + + dynClient := framework.GetClients().DynamicClient() + + watcher, err := dynClient.Resource(ew.gvr). + Namespace(ew.objectKey.Namespace). + Watch(ctx, metav1.ListOptions{ + FieldSelector: fmt.Sprintf("metadata.name=%s", ew.objectKey.Name), + }) + if err != nil { + return + } + defer watcher.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ew.stopCh: + return + case event, ok := <-watcher.ResultChan(): + if !ok { + return + } + ew.handleEvent(event) + } + } +} + +func (ew *EventPhaseWatcher) handleEvent(event watch.Event) { + obj, ok := event.Object.(*unstructured.Unstructured) + if !ok { + return + } + + if event.Type == watch.Deleted { + return + } + + phase, found, err := unstructured.NestedString(obj.Object, "status", "phase") + if err != nil || !found || phase == "" { + return + } + + name := obj.GetName() + if ew.objectKey.Name != "" && name != ew.objectKey.Name { + return + } + + ew.mu.Lock() + defer ew.mu.Unlock() + + if len(ew.transitions) > 0 && ew.transitions[len(ew.transitions)-1].Phase == phase { + return + } + + ew.transitions = append(ew.transitions, PhaseTransition{ + Phase: phase, + At: time.Now(), + }) +} + +// Stop stops the watcher and returns all observed phase transitions +func (ew *EventPhaseWatcher) Stop() []PhaseTransition { + select { + case <-ew.stopCh: + default: + close(ew.stopCh) + } + ew.mu.Lock() + defer ew.mu.Unlock() + transitions := make([]PhaseTransition, len(ew.transitions)) + copy(transitions, ew.transitions) + return transitions +} + +// GetPhases returns all observed phases in order +func (ew *EventPhaseWatcher) GetPhases() []string { + ew.mu.Lock() + defer ew.mu.Unlock() + phases := make([]string, len(ew.transitions)) + for i, t := range ew.transitions { + phases[i] = t.Phase + } + return phases +} + +// GetTransitions returns all phase transitions with timestamps +func (ew *EventPhaseWatcher) GetTransitions() []PhaseTransition { + ew.mu.Lock() + defer ew.mu.Unlock() + transitions := make([]PhaseTransition, len(ew.transitions)) + copy(transitions, ew.transitions) + return transitions +} + +// ContainsPhase checks if the given phase was ever observed +func (ew *EventPhaseWatcher) ContainsPhase(phase string) bool { + phases := ew.GetPhases() + for _, p := range phases { + if p == phase { + return true + } + } + return false +} + +// ContainsAnyOfPhases checks if any of the given phases were observed +func (ew *EventPhaseWatcher) ContainsAnyOfPhases(phasesToCheck ...string) bool { + phases := ew.GetPhases() + for _, p := range phases { + for _, check := range phasesToCheck { + if p == check { + return true + } + } + } + return false +} + +// HasSequence checks if the given sequence appears as a subsequence +func (ew *EventPhaseWatcher) HasSequence(sequence ...string) bool { + if len(sequence) == 0 { + return true + } + + phases := ew.GetPhases() + if len(phases) < len(sequence) { + return false + } + + for i := 0; i <= len(phases)-len(sequence); i++ { + found := true + for j := 0; j < len(sequence); j++ { + if phases[i+j] != sequence[j] { + found = false + break + } + } + if found { + return true + } + } + return false +} + +// HasConsecutiveSequence checks if the observed phases exactly match the sequence +func (ew *EventPhaseWatcher) HasConsecutiveSequence(sequence ...string) bool { + phases := ew.GetPhases() + if len(phases) != len(sequence) { + return false + } + for i, p := range phases { + if p != sequence[i] { + return false + } + } + return true +} + +// VerifyPhaseTransitions verifies that the watcher observed the expected phase sequence. +// Logs all observed transitions and fails the test if sequence is not found. +func VerifyPhaseTransitions(watcher *EventPhaseWatcher, expectedSequence ...string) { + if watcher == nil { + Fail("EventPhaseWatcher is nil") + } + + transitions := watcher.Stop() + + if len(transitions) > 0 { + By("Observed phase transitions", func() { + for i, t := range transitions { + GinkgoWriter.Printf("Transition %d: %s at %v\n", i+1, t.Phase, t.At) + } + }) + } + + Expect(watcher.HasSequence(expectedSequence...)).To(BeTrue(), + "Expected sequence: %v, got: %v", expectedSequence, watcher.GetPhases()) +}