Files
cozystack/internal/migrationcontroller/inventory_scope_test.go
Andrei Kvapil 54621bc046 feat(migration): tenant self-service VM import from VMware vSphere
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>
2026-09-07 17:03:44 +02:00

120 lines
4.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: Apache-2.0
package migrationcontroller
import (
"context"
"encoding/pem"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
migrationv1alpha1 "github.com/cozystack/cozystack/api/migration/v1alpha1"
)
// inventoryHarness stands up a TLS inventory the client will actually verify,
// wiring the server's own certificate in as the CA the controller trusts.
func inventoryHarness(t *testing.T, handler http.HandlerFunc) (*httptest.Server, *corev1.Secret, string) {
t.Helper()
srv := httptest.NewTLSServer(handler)
t.Cleanup(srv.Close)
ca := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srv.Certificate().Raw})
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: inventoryCASecret, Namespace: "cozy-forklift"},
Data: map[string][]byte{inventoryCAKey: ca},
}
tokenFile := filepath.Join(t.TempDir(), "token")
if err := os.WriteFile(tokenFile, []byte("test-token"), 0o600); err != nil {
t.Fatalf("writing the token file: %v", err)
}
return srv, secret, tokenFile
}
// Reading the whole task here would tie every VM's fate to every other one: one
// mistyped managed-object reference is the likeliest error on this API, and it
// would fail the VM being reconciled with a message naming a different machine,
// where a missing VM is meant to fail alone. It is also what keeps the cost
// linear rather than quadratic in the number of VMs, on every pass, for as long
// as anything is still transferring.
func TestSourceTopologyAsksOnlyAboutTheVMBeingReconciled(t *testing.T) {
var mu sync.Mutex
var asked []string
srv, caSecret, tokenFile := inventoryHarness(t, func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
asked = append(asked, r.URL.Path)
mu.Unlock()
if strings.HasSuffix(r.URL.Path, "/vms/vm-2") {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"vm-2","name":"web-02",
"networks":[{"kind":"Network","id":"net-b"}],
"disks":[{"datastore":{"kind":"Datastore","id":"ds-b"}}]}`))
return
}
// Every other VM of the task is absent from the inventory. If the
// implementation walks the whole spec, this is what turns one tenant
// typo into a task-wide failure — so the count assert below is the
// real subject of this test.
w.WriteHeader(http.StatusNotFound)
})
s := testScheme(t)
tk := task("import", "tenant-foo", "vcenter", "replicated",
migrationv1alpha1.VMImportRequest{ID: "vm-1", Name: "web-01"},
migrationv1alpha1.VMImportRequest{ID: "vm-2", Name: "web-02"},
migrationv1alpha1.VMImportRequest{ID: "vm-3", Name: "web-03"},
)
src := readySource("vcenter", "tenant-foo")
provider := newObject(providerGVK)
provider.SetName(sourceProviderName("vcenter"))
provider.SetNamespace("tenant-foo")
provider.SetUID(types.UID("provider-uid"))
c := clientfake.NewClientBuilder().WithScheme(s).
WithObjects(tk, src, provider, caSecret).Build()
r := &VMImportTaskReconciler{
Client: c,
Scheme: s,
Recorder: record.NewFakeRecorder(10),
Inventory: &inventoryClient{
BaseURL: srv.URL, Namespace: "cozy-forklift", Reader: c, TokenFile: tokenFile,
},
}
nets, stores, err := r.sourceTopology(context.Background(), tk, src,
&migrationv1alpha1.VMImportRequest{ID: "vm-2", Name: "web-02"})
if err != nil {
t.Fatalf("sourceTopology: %v", err)
}
if len(nets) != 1 || nets[0] != "net-b" {
t.Errorf("networks = %v, want [net-b]", nets)
}
if len(stores) != 1 || stores[0] != "ds-b" {
t.Errorf("datastores = %v, want [ds-b]", stores)
}
mu.Lock()
defer mu.Unlock()
if len(asked) != 1 {
t.Fatalf("issued %d inventory calls for one VM (%v); a task of N VMs would cost N×N per pass", len(asked), asked)
}
if !strings.HasSuffix(asked[0], "/vms/vm-2") {
t.Errorf("asked about %q, want the VM being reconciled", asked[0])
}
}