mirror of
https://github.com/cozystack/cozystack.git
synced 2026-10-30 00:27:04 +09:00
Hidora's customers are leaving VMware now, and a tenant has to be able to import their own machines without a platform administrator. The chart approach could not express that: a tenant can neither create the Secret holding vCenter credentials nor name the VDDK image, so both had to come from somewhere they cannot reach. Two CRDs in a new `forklift.cozystack.io` group, split by lifecycle. A VMImportSource is a long-lived connection -- type, endpoint, credentials, readiness -- and a VMImportTask is the one-shot operation that names machines and produces VMDisks and VMInstances. The outputs carry no owner reference back to the task, so deleting a finished import leaves the machines alone. Konveyor Forklift does the transfer underneath; the controller renders its objects, mirrors its verdicts, and hands each finished volume into a VMDisk without copying it. What a live vCenter migration forced out, and no amount of source reading would have: - A guest copied verbatim keeps the drivers it had under VMware and no virtio, so on a virtio disk it does not fail the import -- it succeeds and then does not boot. Imported disks go on SATA. - VDDK connects straight to the ESXi host at the address vCenter advertises, which is routinely unreachable, and on the first cluster collided with the Service CIDR. `spec.hosts` redirects per host. - Firmware must be carried across or a UEFI guest imports as BIOS and never boots (stacked on #3002). - Deleting a task mid-transfer orphaned its DataVolume; a finalizer now removes the engine's volumes and leaves the outputs. The console gains a Migration section: connections are editable, and the VM field is a picker built from a machine list the controller publishes, since the aggregated API cannot reach the inventory and should not hold a source's credentials to try. Two general mechanisms fall out of that -- an option source may take an argument, and one named in a CRD annotation may reference a sibling field. Design: cozystack/community#62. Docs: cozystack/website#673. Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
886 lines
36 KiB
Go
886 lines
36 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
package migrationcontroller
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
corev1 "k8s.io/api/core/v1"
|
|
storagev1 "k8s.io/api/storage/v1"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
"k8s.io/apimachinery/pkg/api/resource"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"k8s.io/client-go/tools/record"
|
|
clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
|
|
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
|
|
|
migrationv1alpha1 "github.com/cozystack/cozystack/api/migration/v1alpha1"
|
|
)
|
|
|
|
func storageClass(name string, mode storagev1.VolumeBindingMode, isDefault bool) *storagev1.StorageClass {
|
|
sc := &storagev1.StorageClass{
|
|
ObjectMeta: metav1.ObjectMeta{Name: name},
|
|
VolumeBindingMode: &mode,
|
|
}
|
|
if isDefault {
|
|
sc.Annotations = map[string]string{defaultStorageClassAnnotation: "true"}
|
|
}
|
|
return sc
|
|
}
|
|
|
|
func readySource(name, namespace string) *migrationv1alpha1.VMImportSource {
|
|
src := vsphereSource(name, namespace)
|
|
src.Status.Conditions = []metav1.Condition{{
|
|
Type: migrationv1alpha1.SourceReadyCondition,
|
|
Status: metav1.ConditionTrue,
|
|
Reason: migrationv1alpha1.ReasonConnected,
|
|
Message: "ok",
|
|
LastTransitionTime: metav1.Now(),
|
|
}}
|
|
return src
|
|
}
|
|
|
|
func task(name, namespace, source, class string, vms ...migrationv1alpha1.VMImportRequest) *migrationv1alpha1.VMImportTask {
|
|
return &migrationv1alpha1.VMImportTask{
|
|
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, UID: types.UID(name + "-uid")},
|
|
Spec: migrationv1alpha1.VMImportTaskSpec{
|
|
SourceRef: corev1.LocalObjectReference{Name: source},
|
|
VMs: vms,
|
|
StorageClass: class,
|
|
},
|
|
}
|
|
}
|
|
|
|
// TestTaskRejectsWaitForFirstConsumer is the check that turns a hang into a
|
|
// message. A WaitForFirstConsumer class does not fail an import, it deadlocks
|
|
// it: nothing consumes the claim while CDI populates it. Failing at validation
|
|
// costs one edit; not failing costs a support ticket and a stuck transfer.
|
|
func TestTaskRejectsWaitForFirstConsumer(t *testing.T) {
|
|
s := testScheme(t)
|
|
src := readySource("vcenter", "tenant-foo")
|
|
tk := task("import", "tenant-foo", "vcenter", "local",
|
|
migrationv1alpha1.VMImportRequest{ID: "vm-1", Name: "web-01"})
|
|
c := clientfake.NewClientBuilder().WithScheme(s).
|
|
WithObjects(src, tk, storageClass("local", storagev1.VolumeBindingWaitForFirstConsumer, true)).
|
|
WithStatusSubresource(src, tk).Build()
|
|
|
|
r := &VMImportTaskReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
|
|
if _, err := r.Reconcile(context.Background(), reconcile.Request{
|
|
NamespacedName: types.NamespacedName{Namespace: "tenant-foo", Name: "import"},
|
|
}); err != nil {
|
|
t.Fatalf("reconcile: %v", err)
|
|
}
|
|
|
|
got := &migrationv1alpha1.VMImportTask{}
|
|
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-foo", Name: "import"}, got); err != nil {
|
|
t.Fatalf("get task: %v", err)
|
|
}
|
|
if got.Status.Phase != migrationv1alpha1.VMImportTaskPhaseFailed {
|
|
t.Errorf("phase = %q, want Failed", got.Status.Phase)
|
|
}
|
|
if !strings.Contains(got.Status.Message, "WaitForFirstConsumer") {
|
|
t.Errorf("message does not name the binding mode: %q", got.Status.Message)
|
|
}
|
|
// Nothing may have been created: a task that cannot succeed must not leave
|
|
// Forklift objects behind for a tenant to wonder about.
|
|
plan := newObject(planGVK)
|
|
if err := c.Get(context.Background(), types.NamespacedName{
|
|
Namespace: "tenant-foo", Name: planName("import", "vm-1"),
|
|
}, plan); err == nil {
|
|
t.Error("a Plan was created for a task that failed validation")
|
|
}
|
|
}
|
|
|
|
// TestTaskWaitsForUnreadySource asserts a task does not start work while its
|
|
// connection is unusable, and says which connection and why.
|
|
func TestTaskWaitsForUnreadySource(t *testing.T) {
|
|
s := testScheme(t)
|
|
src := vsphereSource("vcenter", "tenant-foo") // no Ready condition
|
|
tk := task("import", "tenant-foo", "vcenter", "replicated",
|
|
migrationv1alpha1.VMImportRequest{ID: "vm-1", Name: "web-01"})
|
|
c := clientfake.NewClientBuilder().WithScheme(s).
|
|
WithObjects(src, tk).WithStatusSubresource(src, tk).Build()
|
|
|
|
r := &VMImportTaskReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
|
|
if _, err := r.Reconcile(context.Background(), reconcile.Request{
|
|
NamespacedName: types.NamespacedName{Namespace: "tenant-foo", Name: "import"},
|
|
}); err != nil {
|
|
t.Fatalf("reconcile: %v", err)
|
|
}
|
|
|
|
got := &migrationv1alpha1.VMImportTask{}
|
|
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-foo", Name: "import"}, got); err != nil {
|
|
t.Fatalf("get task: %v", err)
|
|
}
|
|
if got.Status.Phase != migrationv1alpha1.VMImportTaskPhasePending {
|
|
t.Errorf("phase = %q, want Pending", got.Status.Phase)
|
|
}
|
|
if !strings.Contains(got.Status.Message, "vcenter") {
|
|
t.Errorf("message does not name the source: %q", got.Status.Message)
|
|
}
|
|
}
|
|
|
|
// TestTaskRefusesToOverwriteExistingInstance locks in that the controller never
|
|
// takes over a tenant's object. An import that would clobber a running VM must
|
|
// stop and say so.
|
|
func TestTaskRefusesToOverwriteExistingInstance(t *testing.T) {
|
|
s := testScheme(t)
|
|
src := readySource("vcenter", "tenant-foo")
|
|
tk := task("import", "tenant-foo", "vcenter", "replicated",
|
|
migrationv1alpha1.VMImportRequest{ID: "vm-1", Name: "web-01"})
|
|
occupied := newObject(vmInstanceGVK)
|
|
occupied.SetName("web-01")
|
|
occupied.SetNamespace("tenant-foo")
|
|
|
|
c := clientfake.NewClientBuilder().WithScheme(s).
|
|
WithObjects(src, tk, occupied, storageClass("replicated", storagev1.VolumeBindingImmediate, false)).
|
|
WithStatusSubresource(src, tk).Build()
|
|
|
|
r := &VMImportTaskReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
|
|
if _, err := r.Reconcile(context.Background(), reconcile.Request{
|
|
NamespacedName: types.NamespacedName{Namespace: "tenant-foo", Name: "import"},
|
|
}); err != nil {
|
|
t.Fatalf("reconcile: %v", err)
|
|
}
|
|
|
|
got := &migrationv1alpha1.VMImportTask{}
|
|
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-foo", Name: "import"}, got); err != nil {
|
|
t.Fatalf("get task: %v", err)
|
|
}
|
|
if got.Status.Phase != migrationv1alpha1.VMImportTaskPhaseFailed {
|
|
t.Errorf("phase = %q, want Failed", got.Status.Phase)
|
|
}
|
|
if !strings.Contains(got.Status.Message, "already exists") {
|
|
t.Errorf("message does not explain the collision: %q", got.Status.Message)
|
|
}
|
|
}
|
|
|
|
// TestAggregatePhase covers the fold from per-VM phases to the task's own. The
|
|
// property that matters: one failed VM must not stop its siblings, and the task
|
|
// only reaches a terminal phase when nothing can still progress.
|
|
func TestAggregatePhase(t *testing.T) {
|
|
p := func(phases ...migrationv1alpha1.VMImportTaskPhase) []migrationv1alpha1.VMImportStatus {
|
|
out := make([]migrationv1alpha1.VMImportStatus, 0, len(phases))
|
|
for i, ph := range phases {
|
|
out = append(out, migrationv1alpha1.VMImportStatus{ID: string(rune('a' + i)), Phase: ph})
|
|
}
|
|
return out
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
in []migrationv1alpha1.VMImportStatus
|
|
want migrationv1alpha1.VMImportTaskPhase
|
|
}{
|
|
{"empty", nil, migrationv1alpha1.VMImportTaskPhasePending},
|
|
{"all succeeded", p(migrationv1alpha1.VMImportTaskPhaseSucceeded, migrationv1alpha1.VMImportTaskPhaseSucceeded), migrationv1alpha1.VMImportTaskPhaseSucceeded},
|
|
{"all failed", p(migrationv1alpha1.VMImportTaskPhaseFailed), migrationv1alpha1.VMImportTaskPhaseFailed},
|
|
{"mixed terminal is failed", p(migrationv1alpha1.VMImportTaskPhaseSucceeded, migrationv1alpha1.VMImportTaskPhaseFailed), migrationv1alpha1.VMImportTaskPhaseFailed},
|
|
{
|
|
"one failed while another transfers keeps going",
|
|
p(migrationv1alpha1.VMImportTaskPhaseFailed, migrationv1alpha1.VMImportTaskPhaseTransferring),
|
|
migrationv1alpha1.VMImportTaskPhaseTransferring,
|
|
},
|
|
{
|
|
"furthest-along non-terminal wins",
|
|
p(migrationv1alpha1.VMImportTaskPhaseValidating, migrationv1alpha1.VMImportTaskPhaseCreating),
|
|
migrationv1alpha1.VMImportTaskPhaseCreating,
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := aggregatePhase(tc.in); got != tc.want {
|
|
t.Errorf("aggregatePhase = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAdoptVolumeReplicatesVolumeModeAndAccessModes locks in a constraint that
|
|
// only a live cluster revealed. Pre-binding through volumeName bypasses the
|
|
// volumeMode match check, so a claim that silently defaults to Filesystem
|
|
// against a Block volume *binds anyway* and fails much later at attach time
|
|
// with a message pointing nowhere near the cause. The replacement claim must
|
|
// therefore copy the volume's own modes rather than assume a default.
|
|
func TestAdoptVolumeReplicatesVolumeModeAndAccessModes(t *testing.T) {
|
|
block := corev1.PersistentVolumeBlock
|
|
s := testScheme(t)
|
|
tk := task("import", "tenant-foo", "vcenter", "replicated")
|
|
|
|
pv := &corev1.PersistentVolume{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "pv-1"},
|
|
Spec: corev1.PersistentVolumeSpec{
|
|
AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany},
|
|
VolumeMode: &block,
|
|
StorageClassName: "replicated",
|
|
PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimDelete,
|
|
Capacity: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("20Gi")},
|
|
},
|
|
}
|
|
transferred := &corev1.PersistentVolumeClaim{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "forklift-claim", Namespace: "tenant-foo"},
|
|
Spec: corev1.PersistentVolumeClaimSpec{
|
|
VolumeName: "pv-1",
|
|
Resources: corev1.VolumeResourceRequirements{
|
|
Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("20Gi")},
|
|
},
|
|
},
|
|
}
|
|
c := clientfake.NewClientBuilder().WithScheme(s).WithObjects(pv, transferred, tk).Build()
|
|
r := &VMImportTaskReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
|
|
|
|
if err := r.adoptVolume(context.Background(), tk, "vm-1", "forklift-claim", "web-01-disk-0"); err != nil {
|
|
t.Fatalf("adoptVolume: %v", err)
|
|
}
|
|
|
|
adopted := &corev1.PersistentVolumeClaim{}
|
|
if err := c.Get(context.Background(), types.NamespacedName{
|
|
Namespace: "tenant-foo", Name: "vm-disk-web-01-disk-0",
|
|
}, adopted); err != nil {
|
|
t.Fatalf("replacement claim was not created: %v", err)
|
|
}
|
|
if adopted.Spec.VolumeMode == nil || *adopted.Spec.VolumeMode != corev1.PersistentVolumeBlock {
|
|
t.Errorf("volumeMode = %v, want Block copied from the volume", adopted.Spec.VolumeMode)
|
|
}
|
|
if len(adopted.Spec.AccessModes) != 1 || adopted.Spec.AccessModes[0] != corev1.ReadWriteMany {
|
|
t.Errorf("accessModes = %v, want ReadWriteMany copied from the volume", adopted.Spec.AccessModes)
|
|
}
|
|
if adopted.Spec.VolumeName != "pv-1" {
|
|
t.Errorf("volumeName = %q, want the claim pre-bound to the retained volume", adopted.Spec.VolumeName)
|
|
}
|
|
if adopted.Annotations[populatedForAnnotation] != "vm-disk-web-01-disk-0" {
|
|
t.Errorf("populatedFor = %q, want it to equal the DataVolume name",
|
|
adopted.Annotations[populatedForAnnotation])
|
|
}
|
|
|
|
// Retain is not advice: it is the only thing standing between a routine
|
|
// DataVolume deletion and data loss, because CDI takes a controller owner
|
|
// reference on the claim it adopts.
|
|
gotPV := &corev1.PersistentVolume{}
|
|
if err := c.Get(context.Background(), types.NamespacedName{Name: "pv-1"}, gotPV); err != nil {
|
|
t.Fatalf("get pv: %v", err)
|
|
}
|
|
if gotPV.Spec.PersistentVolumeReclaimPolicy != corev1.PersistentVolumeReclaimRetain {
|
|
t.Errorf("reclaim policy = %q, want Retain", gotPV.Spec.PersistentVolumeReclaimPolicy)
|
|
}
|
|
if gotPV.Spec.ClaimRef == nil || gotPV.Spec.ClaimRef.Name != "vm-disk-web-01-disk-0" {
|
|
t.Fatalf("claimRef was not re-pointed: %+v", gotPV.Spec.ClaimRef)
|
|
}
|
|
if gotPV.Spec.ClaimRef.UID != adopted.UID {
|
|
t.Error("claimRef carries no UID, leaving a window where another claim could bind the volume")
|
|
}
|
|
|
|
// The DataVolume the VMDisk release will adopt must exist and must not
|
|
// re-fetch anything: a blank source is what makes the handoff free.
|
|
dv := newObject(dataVolumeGVK)
|
|
if err := c.Get(context.Background(), types.NamespacedName{
|
|
Namespace: "tenant-foo", Name: "vm-disk-web-01-disk-0",
|
|
}, dv); err != nil {
|
|
t.Fatalf("DataVolume was not created: %v", err)
|
|
}
|
|
if _, found, _ := unstructured.NestedMap(dv.Object, "spec", "source", "blank"); !found {
|
|
t.Error("DataVolume source is not blank; the handoff would trigger a second copy")
|
|
}
|
|
if dv.GetAnnotations()[helmReleaseNameAnnotation] != "vm-disk-web-01-disk-0" {
|
|
t.Error("DataVolume lacks Helm ownership metadata, so the VMDisk release would collide with it")
|
|
}
|
|
|
|
// And the tenant-facing object, with no owner reference to the task: that
|
|
// is what lets a finished task be deleted without losing the disk.
|
|
disk := newObject(vmDiskGVK)
|
|
if err := c.Get(context.Background(), types.NamespacedName{
|
|
Namespace: "tenant-foo", Name: "web-01-disk-0",
|
|
}, disk); err != nil {
|
|
t.Fatalf("VMDisk was not created: %v", err)
|
|
}
|
|
if len(disk.GetOwnerReferences()) != 0 {
|
|
t.Error("VMDisk carries an owner reference; deleting the task would destroy the imported disk")
|
|
}
|
|
}
|
|
|
|
// TestAdoptVolumeRemovesOwningDataVolumeFirst locks in the second live-only
|
|
// finding. Deleting a claim while its DataVolume still exists makes CDI
|
|
// recreate the claim and provision a second volume, re-running the whole
|
|
// import — the exact duplicate copy this design exists to remove.
|
|
func TestAdoptVolumeRemovesOwningDataVolumeFirst(t *testing.T) {
|
|
s := testScheme(t)
|
|
tk := task("import", "tenant-foo", "vcenter", "replicated")
|
|
|
|
pv := &corev1.PersistentVolume{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "pv-1"},
|
|
Spec: corev1.PersistentVolumeSpec{
|
|
StorageClassName: "replicated",
|
|
Capacity: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("10Gi")},
|
|
},
|
|
}
|
|
// The transferred claim as CDI leaves it: owned by its DataVolume.
|
|
controller := true
|
|
transferred := &corev1.PersistentVolumeClaim{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "forklift-claim",
|
|
Namespace: "tenant-foo",
|
|
OwnerReferences: []metav1.OwnerReference{{
|
|
APIVersion: "cdi.kubevirt.io/v1beta1",
|
|
Kind: "DataVolume",
|
|
Name: "forklift-claim",
|
|
UID: "dv-uid",
|
|
Controller: &controller,
|
|
}},
|
|
},
|
|
Spec: corev1.PersistentVolumeClaimSpec{
|
|
VolumeName: "pv-1",
|
|
Resources: corev1.VolumeResourceRequirements{
|
|
Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("10Gi")},
|
|
},
|
|
},
|
|
}
|
|
forkliftDV := newObject(dataVolumeGVK)
|
|
forkliftDV.SetName("forklift-claim")
|
|
forkliftDV.SetNamespace("tenant-foo")
|
|
|
|
c := clientfake.NewClientBuilder().WithScheme(s).
|
|
WithObjects(pv, transferred, forkliftDV, tk).Build()
|
|
r := &VMImportTaskReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
|
|
|
|
if err := r.adoptVolume(context.Background(), tk, "vm-1", "forklift-claim", "web-01-disk-0"); err != nil {
|
|
t.Fatalf("adoptVolume: %v", err)
|
|
}
|
|
|
|
gone := newObject(dataVolumeGVK)
|
|
err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-foo", Name: "forklift-claim"}, gone)
|
|
if err == nil {
|
|
t.Fatal("the transferring DataVolume still exists; CDI would recreate the claim and run a second full copy")
|
|
}
|
|
|
|
oldClaim := &corev1.PersistentVolumeClaim{}
|
|
if err := c.Get(context.Background(), types.NamespacedName{
|
|
Namespace: "tenant-foo", Name: "forklift-claim",
|
|
}, oldClaim); err == nil {
|
|
t.Error("the original claim was left behind")
|
|
}
|
|
}
|
|
|
|
// TestAdoptVolumeIsIdempotent asserts a second pass over an already-adopted
|
|
// disk is a no-op rather than a second swap: the controller is re-entered on
|
|
// every requeue and after every restart.
|
|
func TestAdoptVolumeIsIdempotent(t *testing.T) {
|
|
s := testScheme(t)
|
|
tk := task("import", "tenant-foo", "vcenter", "replicated")
|
|
already := &corev1.PersistentVolumeClaim{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "vm-disk-web-01-disk-0",
|
|
Namespace: "tenant-foo",
|
|
// The markers are what make this a resume rather than a collision:
|
|
// an unmarked claim of the same name belongs to the tenant.
|
|
Labels: map[string]string{
|
|
migrationv1alpha1.OutputTaskUIDLabel: "import-uid",
|
|
migrationv1alpha1.OutputVMIDLabel: "vm-1",
|
|
},
|
|
},
|
|
Spec: corev1.PersistentVolumeClaimSpec{
|
|
VolumeName: "pv-1",
|
|
Resources: corev1.VolumeResourceRequirements{
|
|
Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("20Gi")},
|
|
},
|
|
},
|
|
}
|
|
c := clientfake.NewClientBuilder().WithScheme(s).WithObjects(already, tk).Build()
|
|
r := &VMImportTaskReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
|
|
|
|
// The source claim is deliberately absent: a second pass must not need it.
|
|
if err := r.adoptVolume(context.Background(), tk, "vm-1", "forklift-claim", "web-01-disk-0"); err != nil {
|
|
t.Fatalf("second pass over an adopted disk failed: %v", err)
|
|
}
|
|
disk := newObject(vmDiskGVK)
|
|
if err := c.Get(context.Background(), types.NamespacedName{
|
|
Namespace: "tenant-foo", Name: "web-01-disk-0",
|
|
}, disk); err != nil {
|
|
t.Fatalf("VMDisk was not created on the second pass: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestSourceVMResources asserts the source VM's shape is read off the object
|
|
// Forklift already built, which is why no inventory client is needed.
|
|
func TestSourceVMResources(t *testing.T) {
|
|
vm := newObject(virtualMachineGVK)
|
|
if err := unstructured.SetNestedMap(vm.Object, map[string]interface{}{
|
|
"cores": int64(2),
|
|
"sockets": int64(2),
|
|
}, "spec", "template", "spec", "domain", "cpu"); err != nil {
|
|
t.Fatalf("set cpu: %v", err)
|
|
}
|
|
if err := unstructured.SetNestedField(vm.Object, "8Gi",
|
|
"spec", "template", "spec", "domain", "memory", "guest"); err != nil {
|
|
t.Fatalf("set memory: %v", err)
|
|
}
|
|
|
|
// Cores and sockets stay separate. vm-instance maps them to
|
|
// domain.cpu.cores and domain.cpu.sockets and refuses to render unless both
|
|
// are present, so multiplying them into one number both loses the topology
|
|
// and fails the chart — observed live: "Either instanceType or resources
|
|
// (cpu, sockets, memory) must be specified".
|
|
cores, sockets, memory := sourceVMResources(vm)
|
|
if cores != "2" {
|
|
t.Errorf("cores = %q, want 2", cores)
|
|
}
|
|
if sockets != "2" {
|
|
t.Errorf("sockets = %q, want 2", sockets)
|
|
}
|
|
if memory != "8Gi" {
|
|
t.Errorf("memory = %q, want 8Gi", memory)
|
|
}
|
|
}
|
|
|
|
// A source that reports cores but no socket count is single-socket. Saying so
|
|
// explicitly is what lets the chart render at all.
|
|
func TestSourceVMResourcesDefaultsSingleSocket(t *testing.T) {
|
|
vm := newObject(virtualMachineGVK)
|
|
if err := unstructured.SetNestedField(vm.Object, int64(4),
|
|
"spec", "template", "spec", "domain", "cpu", "cores"); err != nil {
|
|
t.Fatalf("set cores: %v", err)
|
|
}
|
|
cores, sockets, _ := sourceVMResources(vm)
|
|
if cores != "4" || sockets != "1" {
|
|
t.Errorf("cores/sockets = %q/%q, want 4/1", cores, sockets)
|
|
}
|
|
}
|
|
|
|
// TestMigrationProgressReadsTheTransferStep asserts progress reflects the disk
|
|
// transfer rather than an average over every pipeline step, which would report
|
|
// a four-step pipeline as 25% done before any data moved.
|
|
func TestMigrationProgressReadsTheTransferStep(t *testing.T) {
|
|
m := newObject(migrationGVK)
|
|
if err := unstructured.SetNestedSlice(m.Object, []interface{}{
|
|
map[string]interface{}{
|
|
"id": "vm-1",
|
|
"pipeline": []interface{}{
|
|
map[string]interface{}{
|
|
"name": "Initialize",
|
|
"progress": map[string]interface{}{"completed": int64(1), "total": int64(1)},
|
|
},
|
|
map[string]interface{}{
|
|
"name": "DiskTransfer",
|
|
"progress": map[string]interface{}{"completed": int64(3), "total": int64(10)},
|
|
},
|
|
},
|
|
},
|
|
}, "status", "vms"); err != nil {
|
|
t.Fatalf("set vms: %v", err)
|
|
}
|
|
|
|
done, progress, failure := migrationProgress(m)
|
|
if done {
|
|
t.Error("migration reported done with no completion timestamp")
|
|
}
|
|
if failure != "" {
|
|
t.Errorf("unexpected failure: %q", failure)
|
|
}
|
|
if progress != 30 {
|
|
t.Errorf("progress = %d, want 30", progress)
|
|
}
|
|
}
|
|
|
|
// TestMigrationProgressFallsBackWhenStepsAreUnnamed guards against pegging
|
|
// progress at zero for a whole transfer. The pipeline step names are not part
|
|
// of any contract, and a release that renames them would silently break a
|
|
// name-only match — so an unrecognised pipeline still has to report something.
|
|
func TestMigrationProgressFallsBackWhenStepsAreUnnamed(t *testing.T) {
|
|
m := newObject(migrationGVK)
|
|
if err := unstructured.SetNestedSlice(m.Object, []interface{}{
|
|
map[string]interface{}{
|
|
"id": "vm-1",
|
|
"pipeline": []interface{}{
|
|
map[string]interface{}{
|
|
"name": "SomeFutureStepName",
|
|
"progress": map[string]interface{}{"completed": int64(4096), "total": int64(16384)},
|
|
},
|
|
},
|
|
},
|
|
}, "status", "vms"); err != nil {
|
|
t.Fatalf("set vms: %v", err)
|
|
}
|
|
_, progress, _ := migrationProgress(m)
|
|
if progress != 25 {
|
|
t.Errorf("progress = %d, want 25 from the unnamed step", progress)
|
|
}
|
|
}
|
|
|
|
// TestMigrationProgressAcceptsCompletedPhase asserts the phase the engine
|
|
// settles on is treated as terminal. Waiting only on a completion timestamp
|
|
// would leave a finished transfer stuck in Transferring.
|
|
func TestMigrationProgressAcceptsCompletedPhase(t *testing.T) {
|
|
m := newObject(migrationGVK)
|
|
if err := unstructured.SetNestedSlice(m.Object, []interface{}{
|
|
map[string]interface{}{"id": "vm-1", "phase": "Completed"},
|
|
}, "status", "vms"); err != nil {
|
|
t.Fatalf("set vms: %v", err)
|
|
}
|
|
done, progress, failure := migrationProgress(m)
|
|
if !done {
|
|
t.Error("a Completed phase must count as done")
|
|
}
|
|
if progress != 100 || failure != "" {
|
|
t.Errorf("progress = %d, failure = %q", progress, failure)
|
|
}
|
|
}
|
|
|
|
// TestAdoptVolumeOrphansAClaimWithNoDataVolume is the populator path, which is
|
|
// the path Forklift actually takes: the transferred disk arrives as a populated
|
|
// claim with no DataVolume, carrying an ownerReference to the VirtualMachine
|
|
// Forklift built. Anything that later deletes that VM takes the disk with it,
|
|
// and garbage collection wins any race against a copy — so the reference has to
|
|
// be cleared even though there is no DataVolume to trigger the cleanup.
|
|
func TestAdoptVolumeOrphansAClaimWithNoDataVolume(t *testing.T) {
|
|
s := testScheme(t)
|
|
tk := task("import", "tenant-foo", "vcenter", "replicated")
|
|
|
|
pv := &corev1.PersistentVolume{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "pv-1"},
|
|
Spec: corev1.PersistentVolumeSpec{
|
|
StorageClassName: "replicated",
|
|
Capacity: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("16Gi")},
|
|
},
|
|
}
|
|
blockOwnerDeletion := true
|
|
notController := false
|
|
populated := &corev1.PersistentVolumeClaim{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
// Forklift's own naming: <plan>-vm-<id>-<random>.
|
|
Name: "import-vm-1234-pdbdm",
|
|
Namespace: "tenant-foo",
|
|
OwnerReferences: []metav1.OwnerReference{{
|
|
APIVersion: "kubevirt.io/v1",
|
|
Kind: "VirtualMachine",
|
|
Name: "test-vm",
|
|
UID: "vm-uid",
|
|
Controller: ¬Controller,
|
|
BlockOwnerDeletion: &blockOwnerDeletion,
|
|
}},
|
|
},
|
|
Spec: corev1.PersistentVolumeClaimSpec{
|
|
VolumeName: "pv-1",
|
|
Resources: corev1.VolumeResourceRequirements{
|
|
Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("16Gi")},
|
|
},
|
|
},
|
|
}
|
|
// Deliberately no DataVolume: that is what the populator path looks like.
|
|
c := clientfake.NewClientBuilder().WithScheme(s).WithObjects(pv, populated, tk).Build()
|
|
r := &VMImportTaskReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
|
|
|
|
if err := r.adoptVolume(context.Background(), tk, "vm-1", "import-vm-1234-pdbdm", "web-01-disk-0"); err != nil {
|
|
t.Fatalf("adoptVolume on the populator path: %v", err)
|
|
}
|
|
|
|
adopted := &corev1.PersistentVolumeClaim{}
|
|
if err := c.Get(context.Background(), types.NamespacedName{
|
|
Namespace: "tenant-foo", Name: "vm-disk-web-01-disk-0",
|
|
}, adopted); err != nil {
|
|
t.Fatalf("replacement claim was not created: %v", err)
|
|
}
|
|
if len(adopted.OwnerReferences) != 0 {
|
|
t.Error("the adopted claim carries an owner reference; deleting the Forklift VM would destroy the disk")
|
|
}
|
|
gotPV := &corev1.PersistentVolume{}
|
|
if err := c.Get(context.Background(), types.NamespacedName{Name: "pv-1"}, gotPV); err != nil {
|
|
t.Fatalf("get pv: %v", err)
|
|
}
|
|
if gotPV.Spec.PersistentVolumeReclaimPolicy != corev1.PersistentVolumeReclaimRetain {
|
|
t.Error("the volume was not retained")
|
|
}
|
|
}
|
|
|
|
// TestMigrationProgressSurfacesForkliftErrors asserts a failed transfer carries
|
|
// Forklift's own words rather than a paraphrase.
|
|
func TestMigrationProgressSurfacesForkliftErrors(t *testing.T) {
|
|
m := newObject(migrationGVK)
|
|
if err := unstructured.SetNestedSlice(m.Object, []interface{}{
|
|
map[string]interface{}{
|
|
"id": "vm-1",
|
|
"error": map[string]interface{}{
|
|
"reasons": []interface{}{"vddk: failed to open disk"},
|
|
},
|
|
},
|
|
}, "status", "vms"); err != nil {
|
|
t.Fatalf("set vms: %v", err)
|
|
}
|
|
done, _, failure := migrationProgress(m)
|
|
if done {
|
|
t.Error("a failed migration must not report done")
|
|
}
|
|
if !strings.Contains(failure, "failed to open disk") {
|
|
t.Errorf("failure = %q, want Forklift's own reason", failure)
|
|
}
|
|
}
|
|
|
|
// The topology comes out of an inventory record, and this is the shape the
|
|
// inventory actually returns — verified against a live Forklift v2.11.5
|
|
// inventory service on 2026-08-31, where a vSphere VM reported
|
|
// networks:[{kind:Network,id:"HaNetwork-..."}] and
|
|
// disks:[{...,datastore:{kind:Datastore,id:"61f94272-..."}}].
|
|
//
|
|
// An earlier revision read these from the Plan's VMNetworksNotMapped /
|
|
// VMStorageNotMapped conditions instead. That cannot work: those conditions
|
|
// carry the VM's own reference, not the network or datastore, so the map was
|
|
// built from parsed VM names and never resolved. The test that covered it
|
|
// asserted bare IDs Forklift never emits and so locked in the wrong contract.
|
|
func TestInventoryVMYieldsNetworksAndDatastores(t *testing.T) {
|
|
const record = `{
|
|
"id": "86",
|
|
"name": "test-matthieu",
|
|
"firmware": "efi",
|
|
"powerState": "poweredOff",
|
|
"networks": [{"kind": "Network", "id": "HaNetwork-Cluster Hidora tools"}],
|
|
"disks": [
|
|
{"datastore": {"kind": "Datastore", "id": "61f94272-8f3a3584-b45f-3448edf98902"}},
|
|
{"datastore": {"kind": "Datastore", "id": "61f94272-8f3a3584-b45f-3448edf98902"}}
|
|
]
|
|
}`
|
|
|
|
vm := &inventoryVM{}
|
|
if err := json.Unmarshal([]byte(record), vm); err != nil {
|
|
t.Fatalf("decoding the inventory record: %v", err)
|
|
}
|
|
if vm.Firmware != "efi" || vm.PowerState != "poweredOff" {
|
|
t.Errorf("firmware/power = %q/%q", vm.Firmware, vm.PowerState)
|
|
}
|
|
if got := vm.networkIDs(); len(got) != 1 || got[0] != "HaNetwork-Cluster Hidora tools" {
|
|
t.Errorf("networkIDs = %v", got)
|
|
}
|
|
// Two disks on one datastore yield one map entry, not two.
|
|
if got := vm.datastoreIDs(); len(got) != 1 || got[0] != "61f94272-8f3a3584-b45f-3448edf98902" {
|
|
t.Errorf("datastoreIDs = %v", got)
|
|
}
|
|
}
|
|
|
|
// A Plan that does not pin the target power state lets Forklift match the
|
|
// source's: a production cutover migrates running machines, so the imported VM
|
|
// would boot the instant the transfer finished — a duplicate of a live machine
|
|
// on the network, started on the very volume the handoff is about to re-point.
|
|
func TestPlanKeepsTheImportedVMPoweredOff(t *testing.T) {
|
|
s := testScheme(t)
|
|
tk := task("import", "tenant-foo", "vcenter", "replicated")
|
|
src := readySource("vcenter", "tenant-foo")
|
|
c := clientfake.NewClientBuilder().WithScheme(s).WithObjects(tk, src).Build()
|
|
r := &VMImportTaskReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
|
|
|
|
req := &migrationv1alpha1.VMImportRequest{ID: "vm-1234"}
|
|
if err := r.createPlan(context.Background(), tk, src, req); err != nil {
|
|
t.Fatalf("createPlan: %v", err)
|
|
}
|
|
|
|
plan := newObject(planGVK)
|
|
if err := c.Get(context.Background(), types.NamespacedName{
|
|
Namespace: "tenant-foo", Name: planName("import", "vm-1234"),
|
|
}, plan); err != nil {
|
|
t.Fatalf("Plan was not created: %v", err)
|
|
}
|
|
vms, _, _ := unstructured.NestedSlice(plan.Object, "spec", "vms")
|
|
if len(vms) != 1 {
|
|
t.Fatalf("spec.vms = %d entries, want 1", len(vms))
|
|
}
|
|
entry, _ := vms[0].(map[string]interface{})
|
|
got, _, _ := unstructured.NestedString(entry, "targetPowerState")
|
|
if got != "off" {
|
|
t.Errorf("targetPowerState = %q, want \"off\" — Forklift would boot the migrated guest before the handoff", got)
|
|
}
|
|
}
|
|
|
|
// The source's boot mode has to reach the VMInstance. A UEFI guest rendered
|
|
// with a BIOS bootloader imports "successfully" and then does not boot, which
|
|
// is the whole reason the vm-instance firmware field is a merge-order
|
|
// dependency of this controller.
|
|
func TestSourceFirmwareIsCarriedAcross(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
set func(vm *unstructured.Unstructured)
|
|
want map[string]interface{}
|
|
}{
|
|
{"efi", func(vm *unstructured.Unstructured) {
|
|
_ = unstructured.SetNestedMap(vm.Object, map[string]interface{}{},
|
|
"spec", "template", "spec", "domain", "firmware", "bootloader", "efi")
|
|
}, map[string]interface{}{"bootloader": "uefi"}},
|
|
{"efi with secureBoot", func(vm *unstructured.Unstructured) {
|
|
_ = unstructured.SetNestedField(vm.Object, true,
|
|
"spec", "template", "spec", "domain", "firmware", "bootloader", "efi", "secureBoot")
|
|
}, map[string]interface{}{"bootloader": "uefi", "secureBoot": true}},
|
|
{"bios", func(vm *unstructured.Unstructured) {
|
|
_ = unstructured.SetNestedMap(vm.Object, map[string]interface{}{},
|
|
"spec", "template", "spec", "domain", "firmware", "bootloader", "bios")
|
|
}, map[string]interface{}{"bootloader": "bios"}},
|
|
// Nothing on the source means nothing written: the instance profile's
|
|
// own default stands rather than being overridden with a guess.
|
|
{"no firmware at all", func(vm *unstructured.Unstructured) {}, nil},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
vm := newObject(virtualMachineGVK)
|
|
tc.set(vm)
|
|
got := sourceFirmware(vm)
|
|
if !reflect.DeepEqual(got, tc.want) {
|
|
t.Errorf("sourceFirmware = %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The boot disk of a guest copied verbatim out of VMware has to sit on a bus
|
|
// that guest can already read, or the import "succeeds" into a VM that stalls
|
|
// in its initramfs with no root device.
|
|
func TestImportedDisksPreferTheInboxBus(t *testing.T) {
|
|
for i := 0; i < ahciPorts; i++ {
|
|
if got := importedDiskBus(i); got != "sata" {
|
|
t.Errorf("disk %d: bus = %q, want sata", i, got)
|
|
}
|
|
}
|
|
// Past the controller's port count SATA has nowhere to put the disk, and a
|
|
// data disk may fall back: by then the guest is running off its own root
|
|
// and can load the driver itself.
|
|
if got := importedDiskBus(ahciPorts); got != "virtio" {
|
|
t.Errorf("disk %d: bus = %q, want virtio", ahciPorts, got)
|
|
}
|
|
}
|
|
|
|
// The failure this marker exists to stop: a first import half-succeeds, the
|
|
// tenant deletes the VMInstance but keeps the disks, and retries under the same
|
|
// name. Without an identity check the leftover claim reads as this task's own
|
|
// finished work, adoptVolume early-returns, and the freshly transferred claim
|
|
// keeps its ownerRef to the scaffolding VM — which fulfill then deletes, taking
|
|
// the new transfer with it. The import reports success over stale data.
|
|
func TestAdoptVolumeRefusesAClaimItDidNotCreate(t *testing.T) {
|
|
s := testScheme(t)
|
|
tk := task("import", "tenant-foo", "vcenter", "replicated")
|
|
foreign := &corev1.PersistentVolumeClaim{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "vm-disk-web-01-disk-0", Namespace: "tenant-foo"},
|
|
Spec: corev1.PersistentVolumeClaimSpec{
|
|
VolumeName: "pv-1",
|
|
Resources: corev1.VolumeResourceRequirements{
|
|
Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("20Gi")},
|
|
},
|
|
},
|
|
}
|
|
c := clientfake.NewClientBuilder().WithScheme(s).WithObjects(foreign, tk).Build()
|
|
r := &VMImportTaskReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
|
|
|
|
err := r.adoptVolume(context.Background(), tk, "vm-1", "forklift-claim", "web-01-disk-0")
|
|
if err == nil {
|
|
t.Fatal("adoptVolume accepted a claim this task never created; the transfer would be garbage-collected")
|
|
}
|
|
var verr *validationError
|
|
if !asValidationError(err, &verr) {
|
|
t.Fatalf("error = %v, want a tenant-visible validationError", err)
|
|
}
|
|
if verr.reason != migrationv1alpha1.ReasonOutputExists {
|
|
t.Errorf("reason = %q, want %q", verr.reason, migrationv1alpha1.ReasonOutputExists)
|
|
}
|
|
}
|
|
|
|
// A claim carrying another task's marker is just as foreign as an unmarked one:
|
|
// names are reused, so the UID is what decides.
|
|
func TestAdoptVolumeRefusesAnotherTasksOutput(t *testing.T) {
|
|
s := testScheme(t)
|
|
tk := task("import", "tenant-foo", "vcenter", "replicated")
|
|
other := &corev1.PersistentVolumeClaim{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "vm-disk-web-01-disk-0",
|
|
Namespace: "tenant-foo",
|
|
Labels: map[string]string{
|
|
migrationv1alpha1.OutputTaskUIDLabel: "some-older-task-uid",
|
|
migrationv1alpha1.OutputVMIDLabel: "vm-1",
|
|
},
|
|
},
|
|
Spec: corev1.PersistentVolumeClaimSpec{VolumeName: "pv-1"},
|
|
}
|
|
c := clientfake.NewClientBuilder().WithScheme(s).WithObjects(other, tk).Build()
|
|
r := &VMImportTaskReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
|
|
|
|
if err := r.adoptVolume(context.Background(), tk, "vm-1", "forklift-claim", "web-01-disk-0"); err == nil {
|
|
t.Fatal("adoptVolume adopted an output belonging to a different task")
|
|
}
|
|
}
|
|
|
|
// A deleted task must take the engine's transfer volumes with it. They carry no
|
|
// owner reference — the engine tracks them by label — so deleting the task
|
|
// removes the Plan and leaves the volume behind, still retrying the transfer
|
|
// against the source with nothing left to collect it. Observed live: 37 minutes
|
|
// and 8 restarts after its task was gone.
|
|
//
|
|
// The outputs must survive the same sweep, which is the other half of the test:
|
|
// they are what the tenant keeps.
|
|
func TestFinalizerRemovesTransferVolumesButNotOutputs(t *testing.T) {
|
|
s := testScheme(t)
|
|
tk := task("import", "tenant-foo", "vcenter", "replicated",
|
|
migrationv1alpha1.VMImportRequest{ID: "vm-1", Name: "web-01"})
|
|
tk.Finalizers = []string{migrationv1alpha1.TaskFinalizer}
|
|
now := metav1.Now()
|
|
tk.DeletionTimestamp = &now
|
|
|
|
plan := newObject(planGVK)
|
|
plan.SetName(planName("import", "vm-1"))
|
|
plan.SetNamespace("tenant-foo")
|
|
plan.SetUID(types.UID("plan-uid-1"))
|
|
|
|
transfer := newObject(dataVolumeGVK)
|
|
transfer.SetName("import-vm-1-abcde")
|
|
transfer.SetNamespace("tenant-foo")
|
|
transfer.SetLabels(map[string]string{"plan": "plan-uid-1", "vmID": "vm-1"})
|
|
|
|
// The imported disk: created by this controller, carries Helm metadata and
|
|
// no engine label, and must be left alone.
|
|
output := newObject(dataVolumeGVK)
|
|
output.SetName("vm-disk-web-01-disk-0")
|
|
output.SetNamespace("tenant-foo")
|
|
output.SetLabels(map[string]string{"app.kubernetes.io/instance": "vm-disk-web-01-disk-0"})
|
|
|
|
c := clientfake.NewClientBuilder().WithScheme(s).
|
|
WithObjects(tk, plan, transfer, output).Build()
|
|
r := &VMImportTaskReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
|
|
|
|
if _, err := r.finalize(context.Background(), tk); err != nil {
|
|
t.Fatalf("finalize: %v", err)
|
|
}
|
|
|
|
got := newObject(dataVolumeGVK)
|
|
err := c.Get(context.Background(), types.NamespacedName{
|
|
Namespace: "tenant-foo", Name: "import-vm-1-abcde",
|
|
}, got)
|
|
if !apierrors.IsNotFound(err) {
|
|
t.Errorf("the engine's transfer volume survived the task, got err %v — it would keep retrying forever", err)
|
|
}
|
|
|
|
if err := c.Get(context.Background(), types.NamespacedName{
|
|
Namespace: "tenant-foo", Name: "vm-disk-web-01-disk-0",
|
|
}, newObject(dataVolumeGVK)); err != nil {
|
|
t.Errorf("the imported disk was removed with the task: %v", err)
|
|
}
|
|
|
|
if controllerutil.ContainsFinalizer(tk, migrationv1alpha1.TaskFinalizer) {
|
|
t.Error("the finalizer was not released; the task would never delete")
|
|
}
|
|
}
|
|
|
|
// Outputs must carry the marker, or the checks above can never recognise them
|
|
// on a later pass and every resume becomes a collision.
|
|
func TestAdoptedOutputsCarryTheTaskMarker(t *testing.T) {
|
|
tk := task("import", "tenant-foo", "vcenter", "replicated")
|
|
obj := newObject(vmDiskGVK)
|
|
stampOutput(obj, tk, "vm-1")
|
|
|
|
if !isOwnOutput(obj, tk, "vm-1") {
|
|
t.Error("a freshly stamped output is not recognised as this task's own")
|
|
}
|
|
if isOwnOutput(obj, tk, "vm-2") {
|
|
t.Error("an output for one source VM is recognised as another's")
|
|
}
|
|
other := task("import", "tenant-foo", "vcenter", "replicated")
|
|
other.UID = "different-uid"
|
|
if isOwnOutput(obj, other, "vm-1") {
|
|
t.Error("an output is recognised as belonging to a different task")
|
|
}
|
|
}
|