mirror of
https://github.com/cozystack/cozystack.git
synced 2026-10-28 23:57:08 +09:00
Run go fix over every first-party module: interface{} becomes any,
hand-written loops become slices, maps, min/max and range-over-int,
errors.As becomes errors.AsType, pointer helpers become new(expr) and
are inlined at their call sites, and so on. Every rewrite keeps
behaviour.
Three rewrites stay out. embedlit flattens embedded-field literals
such as ObjectMeta, syntax only Go 1.27 accepts, which would break
backports to release branches still on Go 1.26. omitzero drops
omitempty from struct fields, and openapi-gen reads omitempty as
optional, so metadata, spec and status would turn required in the
published schemas. And errorsastype rewrote an errors.As whose
target, apierrors.APIStatus, does not implement error, which
errors.AsType rejects at compile time.
Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
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 []any
|
|
wantReady metav1.ConditionStatus
|
|
wantInMsg string
|
|
}{
|
|
{
|
|
name: "no conditions yet",
|
|
conditions: nil,
|
|
wantReady: metav1.ConditionFalse,
|
|
wantInMsg: "waiting for Forklift",
|
|
},
|
|
{
|
|
name: "forklift says ready",
|
|
conditions: []any{
|
|
map[string]any{"type": "Ready", "status": "True"},
|
|
},
|
|
wantReady: metav1.ConditionTrue,
|
|
},
|
|
{
|
|
name: "forklift reports a critical problem",
|
|
conditions: []any{
|
|
map[string]any{
|
|
"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)
|
|
}
|
|
})
|
|
}
|
|
}
|