mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-09 05:25:14 +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>
348 lines
14 KiB
Go
348 lines
14 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
package migrationcontroller
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
corev1 "k8s.io/api/core/v1"
|
|
"k8s.io/apimachinery/pkg/api/meta"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"k8s.io/client-go/kubernetes/scheme"
|
|
"k8s.io/client-go/tools/record"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
|
|
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
|
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
|
|
|
migrationv1alpha1 "github.com/cozystack/cozystack/api/migration/v1alpha1"
|
|
)
|
|
|
|
func testScheme(t *testing.T) *runtime.Scheme {
|
|
t.Helper()
|
|
s := runtime.NewScheme()
|
|
if err := scheme.AddToScheme(s); err != nil {
|
|
t.Fatalf("add client-go scheme: %v", err)
|
|
}
|
|
if err := migrationv1alpha1.AddToScheme(s); err != nil {
|
|
t.Fatalf("add migration scheme: %v", err)
|
|
}
|
|
// Forklift, KubeVirt and CDI objects are unstructured at runtime, so the
|
|
// fake client needs both the object and list kinds registered to serve them.
|
|
s.AddKnownTypeWithName(providerGVK, &unstructured.Unstructured{})
|
|
s.AddKnownTypeWithName(providerGVK.GroupVersion().WithKind("ProviderList"), &unstructured.UnstructuredList{})
|
|
s.AddKnownTypeWithName(planGVK, &unstructured.Unstructured{})
|
|
s.AddKnownTypeWithName(planGVK.GroupVersion().WithKind("PlanList"), &unstructured.UnstructuredList{})
|
|
s.AddKnownTypeWithName(migrationGVK, &unstructured.Unstructured{})
|
|
s.AddKnownTypeWithName(migrationGVK.GroupVersion().WithKind("MigrationList"), &unstructured.UnstructuredList{})
|
|
s.AddKnownTypeWithName(networkMapGVK, &unstructured.Unstructured{})
|
|
s.AddKnownTypeWithName(networkMapGVK.GroupVersion().WithKind("NetworkMapList"), &unstructured.UnstructuredList{})
|
|
s.AddKnownTypeWithName(storageMapGVK, &unstructured.Unstructured{})
|
|
s.AddKnownTypeWithName(storageMapGVK.GroupVersion().WithKind("StorageMapList"), &unstructured.UnstructuredList{})
|
|
s.AddKnownTypeWithName(dataVolumeGVK, &unstructured.Unstructured{})
|
|
s.AddKnownTypeWithName(dataVolumeGVK.GroupVersion().WithKind("DataVolumeList"), &unstructured.UnstructuredList{})
|
|
s.AddKnownTypeWithName(virtualMachineGVK, &unstructured.Unstructured{})
|
|
s.AddKnownTypeWithName(virtualMachineGVK.GroupVersion().WithKind("VirtualMachineList"), &unstructured.UnstructuredList{})
|
|
s.AddKnownTypeWithName(vmDiskGVK, &unstructured.Unstructured{})
|
|
s.AddKnownTypeWithName(vmDiskGVK.GroupVersion().WithKind("VMDiskList"), &unstructured.UnstructuredList{})
|
|
s.AddKnownTypeWithName(vmInstanceGVK, &unstructured.Unstructured{})
|
|
s.AddKnownTypeWithName(vmInstanceGVK.GroupVersion().WithKind("VMInstanceList"), &unstructured.UnstructuredList{})
|
|
return s
|
|
}
|
|
|
|
func vsphereSource(name, namespace string) *migrationv1alpha1.VMImportSource {
|
|
return &migrationv1alpha1.VMImportSource{
|
|
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, UID: types.UID(name + "-uid")},
|
|
Spec: migrationv1alpha1.VMImportSourceSpec{
|
|
Type: migrationv1alpha1.ProviderVSphere,
|
|
URL: "https://vcenter.example.com/sdk",
|
|
Credentials: migrationv1alpha1.ProviderCredentials{
|
|
Username: "migration@vsphere.local",
|
|
Password: "hunter2",
|
|
CACert: "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// TestSourceWithoutVDDKIsUnavailableNotBroken locks in the policy that a
|
|
// missing VDDK image is reported on the object at registration time. The whole
|
|
// point is that the tenant learns the VMware path is unconfigured before a
|
|
// transfer starts, so this must never silently create a Provider.
|
|
func TestSourceWithoutVDDKIsUnavailableNotBroken(t *testing.T) {
|
|
s := testScheme(t)
|
|
src := vsphereSource("vcenter", "tenant-foo")
|
|
c := clientfake.NewClientBuilder().WithScheme(s).
|
|
WithObjects(src).WithStatusSubresource(src).Build()
|
|
|
|
r := &VMImportSourceReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10), VDDKImage: ""}
|
|
if _, err := r.Reconcile(context.Background(), reconcile.Request{
|
|
NamespacedName: types.NamespacedName{Namespace: "tenant-foo", Name: "vcenter"},
|
|
}); err != nil {
|
|
t.Fatalf("reconcile: %v", err)
|
|
}
|
|
|
|
got := &migrationv1alpha1.VMImportSource{}
|
|
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-foo", Name: "vcenter"}, got); err != nil {
|
|
t.Fatalf("get source: %v", err)
|
|
}
|
|
cond := meta.FindStatusCondition(got.Status.Conditions, migrationv1alpha1.SourceReadyCondition)
|
|
if cond == nil {
|
|
t.Fatal("expected a Ready condition")
|
|
}
|
|
if cond.Status != metav1.ConditionFalse {
|
|
t.Errorf("Ready = %s, want False", cond.Status)
|
|
}
|
|
if cond.Reason != migrationv1alpha1.ReasonVDDKNotConfigured {
|
|
t.Errorf("reason = %q, want %q", cond.Reason, migrationv1alpha1.ReasonVDDKNotConfigured)
|
|
}
|
|
|
|
provider := newObject(providerGVK)
|
|
err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-foo", Name: sourceProviderName("vcenter")}, provider)
|
|
if err == nil {
|
|
t.Error("a Provider was created even though no VDDK image is configured")
|
|
}
|
|
}
|
|
|
|
// TestSourceProjectsCredentialsAndProviders asserts the happy path materializes
|
|
// exactly what Forklift needs, and that the credentials never require the
|
|
// tenant to have created a Secret.
|
|
func TestSourceProjectsCredentialsAndProviders(t *testing.T) {
|
|
s := testScheme(t)
|
|
src := vsphereSource("vcenter", "tenant-foo")
|
|
c := clientfake.NewClientBuilder().WithScheme(s).
|
|
WithObjects(src).WithStatusSubresource(src).Build()
|
|
|
|
r := &VMImportSourceReconciler{
|
|
Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10),
|
|
VDDKImage: "registry.example.com/vddk:8.0.3",
|
|
}
|
|
if _, err := r.Reconcile(context.Background(), reconcile.Request{
|
|
NamespacedName: types.NamespacedName{Namespace: "tenant-foo", Name: "vcenter"},
|
|
}); err != nil {
|
|
t.Fatalf("reconcile: %v", err)
|
|
}
|
|
|
|
secret := &corev1.Secret{}
|
|
if err := c.Get(context.Background(), types.NamespacedName{
|
|
Namespace: "tenant-foo", Name: credentialsSecretName("vcenter"),
|
|
}, secret); err != nil {
|
|
t.Fatalf("credentials Secret was not projected: %v", err)
|
|
}
|
|
if got := string(secret.Data["user"]); got != "migration@vsphere.local" {
|
|
t.Errorf("user = %q", got)
|
|
}
|
|
if got := string(secret.Data["password"]); got != "hunter2" {
|
|
t.Errorf("password was not projected")
|
|
}
|
|
if secret.Labels[migrationv1alpha1.ManagedByLabel] != migrationv1alpha1.ManagedByValue {
|
|
t.Error("projected Secret is missing the managed-by label that protects it from being adopted by mistake")
|
|
}
|
|
|
|
provider := newObject(providerGVK)
|
|
if err := c.Get(context.Background(), types.NamespacedName{
|
|
Namespace: "tenant-foo", Name: sourceProviderName("vcenter"),
|
|
}, provider); err != nil {
|
|
t.Fatalf("source Provider was not created: %v", err)
|
|
}
|
|
image, _, _ := unstructured.NestedString(provider.Object, "spec", "settings", "vddkInitImage")
|
|
if image != "registry.example.com/vddk:8.0.3" {
|
|
t.Errorf("vddkInitImage = %q, want the operator-configured image", image)
|
|
}
|
|
ns, _, _ := unstructured.NestedString(provider.Object, "spec", "secret", "namespace")
|
|
if ns != "tenant-foo" {
|
|
t.Errorf("secret namespace = %q, want the source's own namespace", ns)
|
|
}
|
|
|
|
dest := newObject(providerGVK)
|
|
if err := c.Get(context.Background(), types.NamespacedName{
|
|
Namespace: "tenant-foo", Name: destinationProviderName("vcenter"),
|
|
}, dest); err != nil {
|
|
t.Fatalf("destination Provider was not created: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestProjectorRefusesForeignSecret is the guard that keeps a name collision
|
|
// from destroying a tenant's own object: a pre-existing Secret without our
|
|
// label is reported, never overwritten.
|
|
func TestProjectorRefusesForeignSecret(t *testing.T) {
|
|
s := testScheme(t)
|
|
src := vsphereSource("vcenter", "tenant-foo")
|
|
foreign := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: credentialsSecretName("vcenter"),
|
|
Namespace: "tenant-foo",
|
|
},
|
|
Data: map[string][]byte{"something": []byte("precious")},
|
|
}
|
|
c := clientfake.NewClientBuilder().WithScheme(s).
|
|
WithObjects(src, foreign).WithStatusSubresource(src).Build()
|
|
|
|
r := &VMImportSourceReconciler{
|
|
Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10),
|
|
VDDKImage: "registry.example.com/vddk:8.0.3",
|
|
}
|
|
if _, err := r.Reconcile(context.Background(), reconcile.Request{
|
|
NamespacedName: types.NamespacedName{Namespace: "tenant-foo", Name: "vcenter"},
|
|
}); err != nil {
|
|
t.Fatalf("reconcile: %v", err)
|
|
}
|
|
|
|
kept := &corev1.Secret{}
|
|
if err := c.Get(context.Background(), types.NamespacedName{
|
|
Namespace: "tenant-foo", Name: credentialsSecretName("vcenter"),
|
|
}, kept); err != nil {
|
|
t.Fatalf("get secret: %v", err)
|
|
}
|
|
if string(kept.Data["something"]) != "precious" {
|
|
t.Fatal("a pre-existing Secret was overwritten by the projector")
|
|
}
|
|
|
|
got := &migrationv1alpha1.VMImportSource{}
|
|
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-foo", Name: "vcenter"}, got); err != nil {
|
|
t.Fatalf("get source: %v", err)
|
|
}
|
|
cond := meta.FindStatusCondition(got.Status.Conditions, migrationv1alpha1.SourceReadyCondition)
|
|
if cond == nil || cond.Status != metav1.ConditionFalse {
|
|
t.Fatal("expected the source to report itself not ready")
|
|
}
|
|
if !strings.Contains(cond.Message, "not managed by the migration controller") {
|
|
t.Errorf("message does not explain the collision: %q", cond.Message)
|
|
}
|
|
}
|
|
|
|
// TestSourceReportsMissingForkliftInsteadOfLoopingOnAnError locks in a
|
|
// live-only finding. When the Forklift CRDs are absent, creating a Provider
|
|
// fails with a no-match error. Returning that as a reconcile error means the
|
|
// pass never reaches the status write, so the Source keeps whatever condition
|
|
// it had last — which on the cluster meant a Source insisting VDDKNotConfigured
|
|
// long after a VDDK image had been configured, while the controller hot-looped.
|
|
func TestSourceReportsMissingForkliftInsteadOfLoopingOnAnError(t *testing.T) {
|
|
s := testScheme(t)
|
|
src := vsphereSource("vcenter", "tenant-foo")
|
|
|
|
// The error has to be the one a real apiserver produces for an absent CRD:
|
|
// a RESTMapper NoKindMatchError. Simply leaving the kind out of the fake
|
|
// client's scheme yields runtime.notRegisteredErr instead, which
|
|
// meta.IsNoMatchError does not recognise — so a test built that way would
|
|
// pass against the wrong error and prove nothing about production.
|
|
noMatch := &meta.NoKindMatchError{
|
|
GroupKind: providerGVK.GroupKind(),
|
|
SearchedVersions: []string{providerGVK.Version},
|
|
}
|
|
c := clientfake.NewClientBuilder().WithScheme(s).
|
|
WithObjects(src).WithStatusSubresource(src).
|
|
WithInterceptorFuncs(interceptor.Funcs{
|
|
Get: func(ctx context.Context, cl client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
|
|
if obj.GetObjectKind().GroupVersionKind() == providerGVK {
|
|
return noMatch
|
|
}
|
|
return cl.Get(ctx, key, obj, opts...)
|
|
},
|
|
}).Build()
|
|
|
|
r := &VMImportSourceReconciler{
|
|
Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10),
|
|
VDDKImage: "registry.example.com/vddk:8.0.3",
|
|
}
|
|
if _, err := r.Reconcile(context.Background(), reconcile.Request{
|
|
NamespacedName: types.NamespacedName{Namespace: "tenant-foo", Name: "vcenter"},
|
|
}); err != nil {
|
|
t.Fatalf("a cluster without Forklift must not produce a reconcile error: %v", err)
|
|
}
|
|
|
|
got := &migrationv1alpha1.VMImportSource{}
|
|
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-foo", Name: "vcenter"}, got); err != nil {
|
|
t.Fatalf("get source: %v", err)
|
|
}
|
|
cond := meta.FindStatusCondition(got.Status.Conditions, migrationv1alpha1.SourceReadyCondition)
|
|
if cond == nil {
|
|
t.Fatal("expected a Ready condition explaining why the source is unusable")
|
|
}
|
|
if cond.Reason != migrationv1alpha1.ReasonForkliftNotInstalled {
|
|
t.Errorf("reason = %q, want %q", cond.Reason, migrationv1alpha1.ReasonForkliftNotInstalled)
|
|
}
|
|
}
|
|
|
|
// TestSourceMirrorsForkliftVerdict asserts the Source reports Forklift's own
|
|
// conclusion rather than assuming a connection nobody tested.
|
|
func TestSourceMirrorsForkliftVerdict(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
conditions []interface{}
|
|
wantReady metav1.ConditionStatus
|
|
wantInMsg string
|
|
}{
|
|
{
|
|
name: "no conditions yet",
|
|
conditions: nil,
|
|
wantReady: metav1.ConditionFalse,
|
|
wantInMsg: "waiting for Forklift",
|
|
},
|
|
{
|
|
name: "forklift says ready",
|
|
conditions: []interface{}{
|
|
map[string]interface{}{"type": "Ready", "status": "True"},
|
|
},
|
|
wantReady: metav1.ConditionTrue,
|
|
},
|
|
{
|
|
name: "forklift reports a critical problem",
|
|
conditions: []interface{}{
|
|
map[string]interface{}{
|
|
"type": "ConnectionTestFailed", "status": "True",
|
|
"category": "Critical", "message": "host is unreachable",
|
|
},
|
|
},
|
|
wantReady: metav1.ConditionFalse,
|
|
wantInMsg: "host is unreachable",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
s := testScheme(t)
|
|
src := vsphereSource("vcenter", "tenant-foo")
|
|
provider := newObject(providerGVK)
|
|
provider.SetName(sourceProviderName("vcenter"))
|
|
provider.SetNamespace("tenant-foo")
|
|
if tc.conditions != nil {
|
|
if err := unstructured.SetNestedSlice(provider.Object, tc.conditions, "status", "conditions"); err != nil {
|
|
t.Fatalf("set conditions: %v", err)
|
|
}
|
|
}
|
|
c := clientfake.NewClientBuilder().WithScheme(s).
|
|
WithObjects(src, provider).WithStatusSubresource(src).Build()
|
|
|
|
r := &VMImportSourceReconciler{
|
|
Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10),
|
|
VDDKImage: "registry.example.com/vddk:8.0.3",
|
|
}
|
|
if _, err := r.Reconcile(context.Background(), reconcile.Request{
|
|
NamespacedName: types.NamespacedName{Namespace: "tenant-foo", Name: "vcenter"},
|
|
}); err != nil {
|
|
t.Fatalf("reconcile: %v", err)
|
|
}
|
|
|
|
got := &migrationv1alpha1.VMImportSource{}
|
|
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-foo", Name: "vcenter"}, got); err != nil {
|
|
t.Fatalf("get source: %v", err)
|
|
}
|
|
cond := meta.FindStatusCondition(got.Status.Conditions, migrationv1alpha1.SourceReadyCondition)
|
|
if cond == nil {
|
|
t.Fatal("expected a Ready condition")
|
|
}
|
|
if cond.Status != tc.wantReady {
|
|
t.Errorf("Ready = %s, want %s (message %q)", cond.Status, tc.wantReady, cond.Message)
|
|
}
|
|
if tc.wantInMsg != "" && !strings.Contains(cond.Message, tc.wantInMsg) {
|
|
t.Errorf("message = %q, want it to contain %q", cond.Message, tc.wantInMsg)
|
|
}
|
|
})
|
|
}
|
|
}
|