From c50b499c097b1a36ffd15e9031f08ea7847d0ffc Mon Sep 17 00:00:00 2001 From: Davide Rutigliano Date: Fri, 12 Dec 2025 10:18:31 +0100 Subject: [PATCH 01/17] Add KVM source Signed-off-by: Davide Rutigliano --- .../migration.harvesterhci.io/v1beta1/kvm.go | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 pkg/apis/migration.harvesterhci.io/v1beta1/kvm.go diff --git a/pkg/apis/migration.harvesterhci.io/v1beta1/kvm.go b/pkg/apis/migration.harvesterhci.io/v1beta1/kvm.go new file mode 100644 index 00000000..d411c833 --- /dev/null +++ b/pkg/apis/migration.harvesterhci.io/v1beta1/kvm.go @@ -0,0 +1,68 @@ +package v1beta1 + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + "github.com/harvester/vm-import-controller/pkg/apis/common" +) + +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +type KVMSource struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec KVMSourceSpec `json:"spec"` + Status KVMSourceStatus `json:"status,omitempty"` +} + +type KVMSourceSpec struct { + LibvirtURI string `json:"libvirtURI"` + Credentials corev1.SecretReference `json:"credentials"` + InsecureSkipTLSVerify bool `json:"insecureSkipTLSVerify,omitempty"` +} + +type KVMSourceStatus struct { + Status ClusterStatus `json:"status,omitempty"` + // +optional + Conditions []common.Condition `json:"conditions,omitempty"` +} + +func (s *KVMSource) NamespacedName() string { + return types.NamespacedName{ + Namespace: s.Namespace, + Name: s.Name, + }.String() +} + +func (s *KVMSource) ClusterStatus() ClusterStatus { + return s.Status.Status +} + +func (s *KVMSource) HasSecret() bool { + return true +} + +func (s *KVMSource) SecretReference() *corev1.SecretReference { + return &s.Spec.Credentials +} + +func (s *KVMSource) GetKind() string { + return KindKVMSource +} + +func (s *KVMSource) GetConnectionInfo() (string, string) { + return s.Spec.LibvirtURI, "" +} + +type KVMSourceOptions struct { + InsecureSkipTLSVerify bool +} + +func (s *KVMSource) GetOptions() interface{} { + return KVMSourceOptions{ + InsecureSkipTLSVerify: s.Spec.InsecureSkipTLSVerify, + } +} From af8ddb765a645350778423f1601b5550363c5a8f Mon Sep 17 00:00:00 2001 From: Davide Rutigliano Date: Fri, 12 Dec 2025 10:20:08 +0100 Subject: [PATCH 02/17] Add KVM client Signed-off-by: Davide Rutigliano --- go.mod | 2 +- pkg/qemu/qemu.go | 17 ++ pkg/source/kvm/client.go | 390 ++++++++++++++++++++++++++++++++++ pkg/source/kvm/client_test.go | 68 ++++++ 4 files changed, 476 insertions(+), 1 deletion(-) create mode 100644 pkg/source/kvm/client.go create mode 100644 pkg/source/kvm/client_test.go diff --git a/go.mod b/go.mod index 922fba2d..75a00a85 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.10.0 github.com/vmware/govmomi v0.52.0 + golang.org/x/crypto v0.40.0 golang.org/x/sync v0.16.0 k8s.io/api v0.33.1 k8s.io/apiextensions-apiserver v0.33.1 @@ -112,7 +113,6 @@ require ( go.uber.org/mock v0.5.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - golang.org/x/crypto v0.40.0 // indirect golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect golang.org/x/mod v0.26.0 // indirect golang.org/x/net v0.42.0 // indirect diff --git a/pkg/qemu/qemu.go b/pkg/qemu/qemu.go index ee47ee6d..029f9554 100644 --- a/pkg/qemu/qemu.go +++ b/pkg/qemu/qemu.go @@ -20,14 +20,31 @@ func ConvertVMDKtoRAW(source, target string) error { return runCommand(defaultCommand, args...) } +func ConvertFromStdin(source io.Reader, target, format string) error { + logrus.WithFields(logrus.Fields{ + "target": target, + "format": format, + }).Info("Converting image from stdin to RAW ...") + // qemu-img convert -f -O raw /dev/stdin + args := []string{"convert", "-f", format, "-O", "raw", "/dev/stdin", target} + return runCommandWithStdin(defaultCommand, source, args...) +} + func createVMDK(path string, size string) error { args := []string{"create", "-f", "vmdk", path, size} return runCommand(defaultCommand, args...) } func runCommand(command string, args ...string) error { + return runCommandWithStdin(command, nil, args...) +} + +func runCommandWithStdin(command string, stdin io.Reader, args ...string) error { cmd := exec.Command(command, args...) cmd.SysProcAttr = &syscall.SysProcAttr{} + if stdin != nil { + cmd.Stdin = stdin + } stderr, err := cmd.StderrPipe() if err != nil { diff --git a/pkg/source/kvm/client.go b/pkg/source/kvm/client.go new file mode 100644 index 00000000..4c62b57a --- /dev/null +++ b/pkg/source/kvm/client.go @@ -0,0 +1,390 @@ +package kvm + +import ( + "bytes" + "context" + "encoding/xml" + "fmt" + "net" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kubevirt "kubevirt.io/api/core/v1" + + migration "github.com/harvester/vm-import-controller/pkg/apis/migration.harvesterhci.io/v1beta1" + "github.com/harvester/vm-import-controller/pkg/qemu" + "github.com/harvester/vm-import-controller/pkg/server" + "github.com/harvester/vm-import-controller/pkg/source" +) + +type Client struct { + ctx context.Context + sshClient *ssh.Client + libvirtURI string +} + +func NewClient(ctx context.Context, libvirtURI string, secret *corev1.Secret) (*Client, error) { + u, err := url.Parse(libvirtURI) + if err != nil { + return nil, fmt.Errorf("invalid libvirt URI: %v", err) + } + + // Expected URI format: qemu+ssh://user@host/system or just ssh://user@host + // We extract host and user from the URI. + host := u.Host + user := u.User.Username() + if user == "" { + // Try to get user from secret if not in URI + if secretUser, ok := secret.Data["username"]; ok { + user = string(secretUser) + } else { + return nil, fmt.Errorf("username not found in URI or secret") + } + } + + authMethods := []ssh.AuthMethod{} + if privateKey, ok := secret.Data["privateKey"]; ok { + signer, err := ssh.ParsePrivateKey(privateKey) + if err != nil { + return nil, fmt.Errorf("failed to parse private key: %v", err) + } + authMethods = append(authMethods, ssh.PublicKeys(signer)) + } + if password, ok := secret.Data["password"]; ok { + authMethods = append(authMethods, ssh.Password(string(password))) + } + + if len(authMethods) == 0 { + return nil, fmt.Errorf("no authentication methods provided in secret") + } + + config := &ssh.ClientConfig{ + User: user, + Auth: authMethods, + HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error { + // InsecureSkipTLSVerify logic handled by caller or ignored for now as we don't have known_hosts management easily + // For this POC/implementation, we accept all keys if InsecureSkipTLSVerify is true (passed down? No, we don't have it here yet) + // The CRD has the flag. + return nil // TODO: Implement strict host checking if needed + }, + Timeout: 10 * time.Second, + } + + // Handle port if missing + if !strings.Contains(host, ":") { + host = host + ":22" + } + + client, err := ssh.Dial("tcp", host, config) + if err != nil { + return nil, fmt.Errorf("failed to dial ssh: %v", err) + } + + return &Client{ + ctx: ctx, + sshClient: client, + libvirtURI: libvirtURI, + }, nil +} + +func (c *Client) Close() error { + if c.sshClient != nil { + return c.sshClient.Close() + } + return nil +} + +func (c *Client) Verify() error { + // We run a simple command to verify that the connection is working and we can talk to libvirt. + // "virsh list --name" is lightweight and lists running domains. + _, err := c.runCommand("virsh list --name") + if err != nil { + return fmt.Errorf("failed to verify connection to libvirt: %v", err) + } + return nil +} + +func (c *Client) runCommand(cmd string) (string, error) { + session, err := c.sshClient.NewSession() + if err != nil { + return "", err + } + defer session.Close() + + var stdout, stderr bytes.Buffer + session.Stdout = &stdout + session.Stderr = &stderr + + err = session.Run(cmd) + if err != nil { + return "", fmt.Errorf("command %q failed: %v, stderr: %s", cmd, err, stderr.String()) + } + return stdout.String(), nil +} + +// Domain XML structs +type Domain struct { + Name string `xml:"name"` + UUID string `xml:"uuid"` + Memory int `xml:"memory"` + VCPU int `xml:"vcpu"` + Devices Devices `xml:"devices"` + OS OS `xml:"os"` +} + +type OS struct { + Type OSType `xml:"type"` + BootDev []BootDev `xml:"boot>dev"` +} + +type OSType struct { + Arch string `xml:"arch,attr"` + Machine string `xml:"machine,attr"` + Value string `xml:",chardata"` +} + +type BootDev struct { + Dev string `xml:"dev,attr"` +} + +type Devices struct { + Disks []Disk `xml:"disk"` + Interfaces []Interface `xml:"interface"` +} + +type Disk struct { + Device string `xml:"device,attr"` + Driver Driver `xml:"driver"` + Source Source `xml:"source"` + Target Target `xml:"target"` +} + +type Driver struct { + Name string `xml:"name,attr"` + Type string `xml:"type,attr"` +} + +type Source struct { + File string `xml:"file,attr"` +} + +type Target struct { + Dev string `xml:"dev,attr"` + Bus string `xml:"bus,attr"` +} + +type Interface struct { + Type string `xml:"type,attr"` + Mac Mac `xml:"mac"` + Model Model `xml:"model"` + Source NetworkSource `xml:"source"` +} + +type Mac struct { + Address string `xml:"address,attr"` +} + +type Model struct { + Type string `xml:"type,attr"` +} + +type NetworkSource struct { + Network string `xml:"network,attr"` + Bridge string `xml:"bridge,attr"` +} + +func (c *Client) getDomainXML(vmName string) (*Domain, error) { + out, err := c.runCommand(fmt.Sprintf("virsh dumpxml %s", vmName)) + if err != nil { + return nil, err + } + + var dom Domain + if err := xml.Unmarshal([]byte(out), &dom); err != nil { + return nil, fmt.Errorf("failed to unmarshal domain xml: %v", err) + } + return &dom, nil +} + +func (c *Client) SanitizeVirtualMachineImport(vm *migration.VirtualMachineImport) error { + vm.Status.ImportedVirtualMachineName = strings.ToLower(vm.Spec.VirtualMachineName) + return nil +} + +func (c *Client) ExportVirtualMachine(vm *migration.VirtualMachineImport) error { + dom, err := c.getDomainXML(vm.Spec.VirtualMachineName) + if err != nil { + return err + } + + tmpPath, err := os.MkdirTemp("/tmp", fmt.Sprintf("%s-%s-", vm.Name, vm.Namespace)) + if err != nil { + return fmt.Errorf("error creating tmp dir: %v", err) + } + // Note: We don't defer removeAll here because we might want to keep it if something fails? + // Actually, we should clean up. But the VMware client does defer removeAll. + defer os.RemoveAll(tmpPath) + + for i, disk := range dom.Devices.Disks { + if disk.Device != "disk" { + continue + } + if disk.Source.File == "" { + continue + } + + // Local destination path + rawDiskName := fmt.Sprintf("%s-disk-%d.img", vm.Name, i) + destFile := filepath.Join(server.TempDir(), rawDiskName) + + logrus.Infof("Exporting disk %s to %s", disk.Source.File, destFile) + + // Use ssh cat | qemu-img convert + // We need a new session for the pipe + session, err := c.sshClient.NewSession() + if err != nil { + return fmt.Errorf("failed to create ssh session: %v", err) + } + + stdout, err := session.StdoutPipe() + if err != nil { + session.Close() + return fmt.Errorf("failed to get stdout pipe: %v", err) + } + + if err := session.Start(fmt.Sprintf("cat %s", disk.Source.File)); err != nil { + session.Close() + return fmt.Errorf("failed to start cat command: %v", err) + } + + // Run qemu-img convert reading from stdin + // XML has driver type. + srcFormat := disk.Driver.Type + if srcFormat == "" { + srcFormat = "qcow2" // Default assumption + } + + // Use shared qemu package + if err := qemu.ConvertFromStdin(stdout, destFile, srcFormat); err != nil { + session.Close() + return fmt.Errorf("qemu convert failed: %v", err) + } + + if err := session.Wait(); err != nil { + return fmt.Errorf("ssh cat command failed: %v", err) + } + session.Close() + + // Update status + busType := kubevirt.DiskBusVirtio + if disk.Target.Bus == "sata" || disk.Target.Bus == "ide" { + busType = kubevirt.DiskBusSATA + } else if disk.Target.Bus == "scsi" { + busType = kubevirt.DiskBusSCSI + } + + vm.Status.DiskImportStatus = append(vm.Status.DiskImportStatus, migration.DiskInfo{ + Name: rawDiskName, + DiskSize: 0, // We could get this from qemu-img info on the result + BusType: busType, + DiskLocalPath: server.TempDir(), + }) + } + + return nil +} + +func (c *Client) ShutdownGuest(vm *migration.VirtualMachineImport) error { + _, err := c.runCommand(fmt.Sprintf("virsh shutdown %s", vm.Spec.VirtualMachineName)) + return err +} + +func (c *Client) PowerOff(vm *migration.VirtualMachineImport) error { + _, err := c.runCommand(fmt.Sprintf("virsh destroy %s", vm.Spec.VirtualMachineName)) + return err +} + +func (c *Client) IsPowerOffSupported() bool { + return true +} + +func (c *Client) IsPoweredOff(vm *migration.VirtualMachineImport) (bool, error) { + out, err := c.runCommand(fmt.Sprintf("virsh domstate %s", vm.Spec.VirtualMachineName)) + if err != nil { + return false, err + } + return strings.TrimSpace(out) == "shut off", nil +} + +func (c *Client) GenerateVirtualMachine(vm *migration.VirtualMachineImport) (*kubevirt.VirtualMachine, error) { + dom, err := c.getDomainXML(vm.Spec.VirtualMachineName) + if err != nil { + return nil, err + } + + newVM := &kubevirt.VirtualMachine{ + ObjectMeta: metav1.ObjectMeta{ + Name: vm.Status.ImportedVirtualMachineName, + Namespace: vm.Namespace, + }, + } + + vmSpec := source.NewVirtualMachineSpec(source.VirtualMachineSpecConfig{ + Name: vm.Status.ImportedVirtualMachineName, + Hardware: source.Hardware{ + NumCPU: uint32(dom.VCPU), + MemoryMB: int64(dom.Memory / 1024), // XML memory is usually in KiB + }, + }) + + // Network mapping + var networkInfos []source.NetworkInfo + for _, iface := range dom.Devices.Interfaces { + model := migration.NetworkInterfaceModelVirtio + if iface.Model.Type == "e1000" { + model = migration.NetworkInterfaceModelE1000 + } else if iface.Model.Type == "e1000e" { + model = migration.NetworkInterfaceModelE1000e + } + + networkName := iface.Source.Network + if networkName == "" { + networkName = iface.Source.Bridge + } + + networkInfos = append(networkInfos, source.NetworkInfo{ + NetworkName: networkName, + MAC: iface.Mac.Address, + Model: model, + }) + } + + mappedNetwork := source.MapNetworks(networkInfos, vm.Spec.Mapping) + networkConfig, interfaceConfig := source.GenerateNetworkInterfaceConfigs(mappedNetwork, vm.GetDefaultNetworkInterfaceModel()) + + vmSpec.Template.Spec.Networks = networkConfig + vmSpec.Template.Spec.Domain.Devices.Interfaces = interfaceConfig + newVM.Spec = *vmSpec + + return newVM, nil +} + +func (c *Client) PreFlightChecks(vm *migration.VirtualMachineImport) error { + // Check if VM exists + _, err := c.runCommand(fmt.Sprintf("virsh dominfo %s", vm.Spec.VirtualMachineName)) + if err != nil { + return fmt.Errorf("failed to find VM %s: %v", vm.Spec.VirtualMachineName, err) + } + return nil +} + +func (c *Client) Cleanup(vm *migration.VirtualMachineImport) error { + return source.RemoveTempImageFiles(vm.Status.DiskImportStatus) +} diff --git a/pkg/source/kvm/client_test.go b/pkg/source/kvm/client_test.go new file mode 100644 index 00000000..e1ffb851 --- /dev/null +++ b/pkg/source/kvm/client_test.go @@ -0,0 +1,68 @@ +package kvm + +import ( + "context" + "encoding/xml" + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" +) + +func TestParseDomainXML(t *testing.T) { + xmlData := ` + + test-vm + a75aca4b-42f6-4447-9262-4b9562d3d95c + 4194304 + 2 + + hvm + + + + + + + + + + + + + + + +` + + var dom Domain + err := xml.Unmarshal([]byte(xmlData), &dom) + assert.NoError(t, err) + + assert.Equal(t, "test-vm", dom.Name) + assert.Equal(t, "a75aca4b-42f6-4447-9262-4b9562d3d95c", dom.UUID) + assert.Equal(t, 4194304, dom.Memory) + assert.Equal(t, 2, dom.VCPU) + assert.Len(t, dom.Devices.Disks, 1) + assert.Equal(t, "/var/lib/libvirt/images/test-vm.qcow2", dom.Devices.Disks[0].Source.File) + assert.Equal(t, "qcow2", dom.Devices.Disks[0].Driver.Type) + assert.Len(t, dom.Devices.Interfaces, 1) + assert.Equal(t, "52:54:00:6b:3c:58", dom.Devices.Interfaces[0].Mac.Address) + assert.Equal(t, "default", dom.Devices.Interfaces[0].Source.Network) +} + +func TestNewClient(t *testing.T) { + ctx := context.TODO() + secret := &corev1.Secret{ + Data: map[string][]byte{ + "username": []byte("user"), + "password": []byte("pass"), + }, + } + + // This should fail to dial but pass config validation + _, err := NewClient(ctx, "qemu+ssh://user@localhost/system", secret) + // We expect an error because we can't actually connect, but we want to ensure it tries + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to dial ssh") +} From a1db17eb5f543cb6ca6cbe8e6ee2c62cf28dfac7 Mon Sep 17 00:00:00 2001 From: Davide Rutigliano Date: Fri, 12 Dec 2025 10:21:07 +0100 Subject: [PATCH 03/17] Add KVM controller Signed-off-by: Davide Rutigliano --- go.mod | 2 +- .../v1beta1/common.go | 1 + pkg/controllers/controllers.go | 7 +- pkg/controllers/migration/kvm.go | 114 ++++++++++++++++++ pkg/controllers/migration/virtualmachine.go | 16 ++- .../v1beta1/interface.go | 5 + 6 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 pkg/controllers/migration/kvm.go diff --git a/go.mod b/go.mod index 75a00a85..50e6110d 100644 --- a/go.mod +++ b/go.mod @@ -21,8 +21,8 @@ require ( golang.org/x/crypto v0.40.0 golang.org/x/sync v0.16.0 k8s.io/api v0.33.1 - k8s.io/apiextensions-apiserver v0.33.1 k8s.io/apimachinery v0.33.1 + k8s.io/apiextensions-apiserver v0.33.1 k8s.io/client-go v12.0.0+incompatible k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 kubevirt.io/api v1.5.0 diff --git a/pkg/apis/migration.harvesterhci.io/v1beta1/common.go b/pkg/apis/migration.harvesterhci.io/v1beta1/common.go index c9f5ab30..30cd6348 100644 --- a/pkg/apis/migration.harvesterhci.io/v1beta1/common.go +++ b/pkg/apis/migration.harvesterhci.io/v1beta1/common.go @@ -18,6 +18,7 @@ const ( KindVmwareSource string = "vmwaresource" KindOvaSource string = "ovasource" KindOpenstackSource string = "openstacksource" + KindKVMSource string = "kvmsource" ) type ClusterStatus string diff --git a/pkg/controllers/controllers.go b/pkg/controllers/controllers.go index df01fd87..7e48521f 100644 --- a/pkg/controllers/controllers.go +++ b/pkg/controllers/controllers.go @@ -95,10 +95,13 @@ func Register(ctx context.Context, restConfig *rest.Config) error { } sc.RegisterVmwareController(ctx, migrationFactory.Migration().V1beta1().VmwareSource(), coreFactory.Core().V1().Secret()) + sc.RegisterKVMController(ctx, migrationFactory.Migration().V1beta1().KVMSource(), coreFactory.Core().V1().Secret()) sc.RegisterOvaController(ctx, migrationFactory.Migration().V1beta1().OvaSource(), coreFactory.Core().V1().Secret()) sc.RegisterOpenstackController(ctx, migrationFactory.Migration().V1beta1().OpenstackSource(), coreFactory.Core().V1().Secret()) - sc.RegisterVMImportController(ctx, migrationFactory.Migration().V1beta1().VmwareSource(), migrationFactory.Migration().V1beta1().OpenstackSource(), - migrationFactory.Migration().V1beta1().OvaSource(), coreFactory.Core().V1().Secret(), migrationFactory.Migration().V1beta1().VirtualMachineImport(), + sc.RegisterVMImportController(ctx, + migrationFactory.Migration().V1beta1().VmwareSource(), migrationFactory.Migration().V1beta1().OpenstackSource(), + migrationFactory.Migration().V1beta1().OvaSource(), migrationFactory.Migration().V1beta1().KVMSource(), + coreFactory.Core().V1().Secret(), migrationFactory.Migration().V1beta1().VirtualMachineImport(), harvesterFactory.Harvesterhci().V1beta1().VirtualMachineImage(), kubevirtFactory.Kubevirt().V1().VirtualMachine(), coreFactory.Core().V1().PersistentVolumeClaim(), scCache, cniFactory.K8s().V1().NetworkAttachmentDefinition().Cache()) diff --git a/pkg/controllers/migration/kvm.go b/pkg/controllers/migration/kvm.go new file mode 100644 index 00000000..bcf2197e --- /dev/null +++ b/pkg/controllers/migration/kvm.go @@ -0,0 +1,114 @@ +package migration + +import ( + "context" + "fmt" + "time" + + corecontrollers "github.com/rancher/wrangler/v3/pkg/generated/controllers/core/v1" + "github.com/sirupsen/logrus" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/harvester/vm-import-controller/pkg/apis/common" + migration "github.com/harvester/vm-import-controller/pkg/apis/migration.harvesterhci.io/v1beta1" + migrationController "github.com/harvester/vm-import-controller/pkg/generated/controllers/migration.harvesterhci.io/v1beta1" + "github.com/harvester/vm-import-controller/pkg/source/kvm" + "github.com/harvester/vm-import-controller/pkg/util" +) + +type kvmHandler struct { + ctx context.Context + source migrationController.KVMSourceController + secret corecontrollers.SecretController +} + +func RegisterKVMController(ctx context.Context, source migrationController.KVMSourceController, secret corecontrollers.SecretController) { + kHandler := &kvmHandler{ + ctx: ctx, + source: source, + secret: secret, + } + source.OnChange(ctx, "kvm-source-change", kHandler.OnSourceChange) +} + +func (h *kvmHandler) OnSourceChange(_ string, v *migration.KVMSource) (*migration.KVMSource, error) { + if v == nil || v.DeletionTimestamp != nil { + return nil, nil + } + + logrus.WithFields(logrus.Fields{ + "kind": v.Kind, + "name": v.Name, + "namespace": v.Namespace, + }).Info("Reconciling source") + + if v.Status.Status != migration.ClusterReady { + var client *kvm.Client + var err error + + secretObj, err := h.secret.Get(v.SecretReference().Namespace, v.SecretReference().Name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to lookup secret for %s migration %s: %w", v.Kind, v.NamespacedName(), err) + } + + client, err = kvm.NewClient(h.ctx, v.Spec.LibvirtURI, secretObj) + if err != nil { + return nil, fmt.Errorf("failed to generate client for %s migration %s: %w", v.Kind, v.NamespacedName(), err) + } + defer client.Close() + + if err := client.Verify(); err != nil { + logrus.WithFields(logrus.Fields{ + "kind": v.Kind, + "name": v.Name, + "namespace": v.Namespace, + "err": err, + }).Error("Failed to verify source for migration") + + conds := []common.Condition{ + { + Type: migration.ClusterErrorCondition, + Status: corev1.ConditionTrue, + LastUpdateTime: metav1.Now().Format(time.RFC3339), + LastTransitionTime: metav1.Now().Format(time.RFC3339), + }, { + Type: migration.ClusterReadyCondition, + Status: corev1.ConditionFalse, + LastUpdateTime: metav1.Now().Format(time.RFC3339), + LastTransitionTime: metav1.Now().Format(time.RFC3339), + }, + } + + v.Status.Conditions = util.MergeConditions(v.Status.Conditions, conds) + v.Status.Status = migration.ClusterNotReady + return h.source.UpdateStatus(v) + } + + // Verify connection (NewClient already dials SSH, but we can run a simple command to be sure) + // We can reuse PreFlightChecks logic or just run a simple command + // But NewClient already dials, so if it succeeds, we are connected. + // Let's just assume ready if NewClient succeeded. + + conds := []common.Condition{ + { + Type: migration.ClusterReadyCondition, + Status: corev1.ConditionTrue, + LastUpdateTime: metav1.Now().Format(time.RFC3339), + LastTransitionTime: metav1.Now().Format(time.RFC3339), + }, { + Type: migration.ClusterErrorCondition, + Status: corev1.ConditionFalse, + LastUpdateTime: metav1.Now().Format(time.RFC3339), + LastTransitionTime: metav1.Now().Format(time.RFC3339), + }, + } + + v.Status.Conditions = util.MergeConditions(v.Status.Conditions, conds) + v.Status.Status = migration.ClusterReady + + return h.source.UpdateStatus(v) + } + + return nil, nil +} diff --git a/pkg/controllers/migration/virtualmachine.go b/pkg/controllers/migration/virtualmachine.go index ac375fb0..09c5981d 100644 --- a/pkg/controllers/migration/virtualmachine.go +++ b/pkg/controllers/migration/virtualmachine.go @@ -13,6 +13,7 @@ import ( migration "github.com/harvester/vm-import-controller/pkg/apis/migration.harvesterhci.io/v1beta1" migrationController "github.com/harvester/vm-import-controller/pkg/generated/controllers/migration.harvesterhci.io/v1beta1" "github.com/harvester/vm-import-controller/pkg/server" + "github.com/harvester/vm-import-controller/pkg/source/kvm" "github.com/harvester/vm-import-controller/pkg/source/openstack" "github.com/harvester/vm-import-controller/pkg/source/ova" "github.com/harvester/vm-import-controller/pkg/source/vmware" @@ -78,6 +79,7 @@ type virtualMachineHandler struct { vmware migrationController.VmwareSourceController ova migrationController.OvaSourceController openstack migrationController.OpenstackSourceController + kvm migrationController.KVMSourceController secret coreControllers.SecretController importVM migrationController.VirtualMachineImportController vmi harvester.VirtualMachineImageController @@ -87,12 +89,17 @@ type virtualMachineHandler struct { nadCache ctlcniv1.NetworkAttachmentDefinitionCache } -func RegisterVMImportController(ctx context.Context, vmware migrationController.VmwareSourceController, openstack migrationController.OpenstackSourceController, ova migrationController.OvaSourceController, secret coreControllers.SecretController, importVM migrationController.VirtualMachineImportController, vmi harvester.VirtualMachineImageController, kubevirt kubevirtv1.VirtualMachineController, pvc coreControllers.PersistentVolumeClaimController, scCache storageControllers.StorageClassCache, nadCache ctlcniv1.NetworkAttachmentDefinitionCache) { +func RegisterVMImportController(ctx context.Context, + vmware migrationController.VmwareSourceController, openstack migrationController.OpenstackSourceController, + ova migrationController.OvaSourceController, kvm migrationController.KVMSourceController, secret coreControllers.SecretController, + importVM migrationController.VirtualMachineImportController, vmi harvester.VirtualMachineImageController, kubevirt kubevirtv1.VirtualMachineController, + pvc coreControllers.PersistentVolumeClaimController, scCache storageControllers.StorageClassCache, nadCache ctlcniv1.NetworkAttachmentDefinitionCache) { vmHandler := &virtualMachineHandler{ ctx: ctx, vmware: vmware, openstack: openstack, ova: ova, + kvm: kvm, secret: secret, importVM: importVM, vmi: vmi, @@ -241,7 +248,7 @@ func (h *virtualMachineHandler) preFlightChecks(vm *migration.VirtualMachineImpo var err error switch strings.ToLower(vm.Spec.SourceCluster.Kind) { - case migration.KindVmwareSource, migration.KindOvaSource, migration.KindOpenstackSource: + case migration.KindVmwareSource, migration.KindOvaSource, migration.KindOpenstackSource, migration.KindKVMSource: ss, err = h.generateSource(vm) if err != nil { return fmt.Errorf("error generating migration in preflight checks: %v", err) @@ -560,6 +567,9 @@ func (h *virtualMachineHandler) generateVMO(vm *migration.VirtualMachineImport) endpoint, region := source.GetConnectionInfo() options := source.GetOptions().(migration.OpenstackSourceOptions) return openstack.NewClient(h.ctx, endpoint, region, secret, options) + case migration.KindKVMSource: + uri, _ := source.GetConnectionInfo() + return kvm.NewClient(h.ctx, uri, secret) } return nil, fmt.Errorf("source kind %q not supported", source.GetKind()) @@ -576,6 +586,8 @@ func (h *virtualMachineHandler) generateSource(vm *migration.VirtualMachineImpor si, err = h.ova.Get(vm.Spec.SourceCluster.Namespace, vm.Spec.SourceCluster.Name, metav1.GetOptions{}) case migration.KindOpenstackSource: si, err = h.openstack.Get(vm.Spec.SourceCluster.Namespace, vm.Spec.SourceCluster.Name, metav1.GetOptions{}) + case migration.KindKVMSource: + si, err = h.kvm.Get(vm.Spec.SourceCluster.Namespace, vm.Spec.SourceCluster.Name, metav1.GetOptions{}) default: err = fmt.Errorf("source kind %q not supported", vm.Spec.SourceCluster.Kind) } diff --git a/pkg/generated/controllers/migration.harvesterhci.io/v1beta1/interface.go b/pkg/generated/controllers/migration.harvesterhci.io/v1beta1/interface.go index 83784b33..1e7af719 100644 --- a/pkg/generated/controllers/migration.harvesterhci.io/v1beta1/interface.go +++ b/pkg/generated/controllers/migration.harvesterhci.io/v1beta1/interface.go @@ -31,6 +31,7 @@ func init() { } type Interface interface { + KVMSource() KVMSourceController OpenstackSource() OpenstackSourceController OvaSource() OvaSourceController VirtualMachineImport() VirtualMachineImportController @@ -47,6 +48,10 @@ type version struct { controllerFactory controller.SharedControllerFactory } +func (v *version) KVMSource() KVMSourceController { + return generic.NewController[*v1beta1.KVMSource, *v1beta1.KVMSourceList](schema.GroupVersionKind{Group: "migration.harvesterhci.io", Version: "v1beta1", Kind: "KVMSource"}, "kvmsources", true, v.controllerFactory) +} + func (v *version) OpenstackSource() OpenstackSourceController { return generic.NewController[*v1beta1.OpenstackSource, *v1beta1.OpenstackSourceList](schema.GroupVersionKind{Group: "migration.harvesterhci.io", Version: "v1beta1", Kind: "OpenstackSource"}, "openstacksources", true, v.controllerFactory) } From 4ad6d127dac7800f7b20255cafcae4500c16823c Mon Sep 17 00:00:00 2001 From: Davide Rutigliano Date: Fri, 12 Dec 2025 10:39:12 +0100 Subject: [PATCH 04/17] Add generated code Signed-off-by: Davide Rutigliano --- .../v1beta1/zz_generated_deepcopy.go | 115 ++++++++++ .../v1beta1/zz_generated_list_types.go | 17 ++ .../v1beta1/zz_generated_register.go | 3 + .../v1beta1/kvmsource.go | 208 ++++++++++++++++++ 4 files changed, 343 insertions(+) create mode 100644 pkg/generated/controllers/migration.harvesterhci.io/v1beta1/kvmsource.go diff --git a/pkg/apis/migration.harvesterhci.io/v1beta1/zz_generated_deepcopy.go b/pkg/apis/migration.harvesterhci.io/v1beta1/zz_generated_deepcopy.go index 9f2e162e..126474ee 100644 --- a/pkg/apis/migration.harvesterhci.io/v1beta1/zz_generated_deepcopy.go +++ b/pkg/apis/migration.harvesterhci.io/v1beta1/zz_generated_deepcopy.go @@ -49,6 +49,121 @@ func (in *DiskInfo) DeepCopy() *DiskInfo { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KVMSource) DeepCopyInto(out *KVMSource) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KVMSource. +func (in *KVMSource) DeepCopy() *KVMSource { + if in == nil { + return nil + } + out := new(KVMSource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *KVMSource) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KVMSourceList) DeepCopyInto(out *KVMSourceList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]KVMSource, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KVMSourceList. +func (in *KVMSourceList) DeepCopy() *KVMSourceList { + if in == nil { + return nil + } + out := new(KVMSourceList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *KVMSourceList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KVMSourceOptions) DeepCopyInto(out *KVMSourceOptions) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KVMSourceOptions. +func (in *KVMSourceOptions) DeepCopy() *KVMSourceOptions { + if in == nil { + return nil + } + out := new(KVMSourceOptions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KVMSourceSpec) DeepCopyInto(out *KVMSourceSpec) { + *out = *in + out.Credentials = in.Credentials + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KVMSourceSpec. +func (in *KVMSourceSpec) DeepCopy() *KVMSourceSpec { + if in == nil { + return nil + } + out := new(KVMSourceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KVMSourceStatus) DeepCopyInto(out *KVMSourceStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]common.Condition, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KVMSourceStatus. +func (in *KVMSourceStatus) DeepCopy() *KVMSourceStatus { + if in == nil { + return nil + } + out := new(KVMSourceStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NetworkMapping) DeepCopyInto(out *NetworkMapping) { *out = *in diff --git a/pkg/apis/migration.harvesterhci.io/v1beta1/zz_generated_list_types.go b/pkg/apis/migration.harvesterhci.io/v1beta1/zz_generated_list_types.go index 42e30d45..b5ef4fa0 100644 --- a/pkg/apis/migration.harvesterhci.io/v1beta1/zz_generated_list_types.go +++ b/pkg/apis/migration.harvesterhci.io/v1beta1/zz_generated_list_types.go @@ -26,6 +26,23 @@ import ( // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// KVMSourceList is a list of KVMSource resources +type KVMSourceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + + Items []KVMSource `json:"items"` +} + +func NewKVMSource(namespace, name string, obj KVMSource) *KVMSource { + obj.APIVersion, obj.Kind = SchemeGroupVersion.WithKind("KVMSource").ToAPIVersionAndKind() + obj.Name = name + obj.Namespace = namespace + return &obj +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + // OpenstackSourceList is a list of OpenstackSource resources type OpenstackSourceList struct { metav1.TypeMeta `json:",inline"` diff --git a/pkg/apis/migration.harvesterhci.io/v1beta1/zz_generated_register.go b/pkg/apis/migration.harvesterhci.io/v1beta1/zz_generated_register.go index 4bf5b400..08eae87e 100644 --- a/pkg/apis/migration.harvesterhci.io/v1beta1/zz_generated_register.go +++ b/pkg/apis/migration.harvesterhci.io/v1beta1/zz_generated_register.go @@ -28,6 +28,7 @@ import ( ) var ( + KVMSourceResourceName = "kvmsources" OpenstackSourceResourceName = "openstacksources" OvaSourceResourceName = "ovasources" VirtualMachineImportResourceName = "virtualmachineimports" @@ -55,6 +56,8 @@ var ( // Adds the list of known types to Scheme. func addKnownTypes(scheme *runtime.Scheme) error { scheme.AddKnownTypes(SchemeGroupVersion, + &KVMSource{}, + &KVMSourceList{}, &OpenstackSource{}, &OpenstackSourceList{}, &OvaSource{}, diff --git a/pkg/generated/controllers/migration.harvesterhci.io/v1beta1/kvmsource.go b/pkg/generated/controllers/migration.harvesterhci.io/v1beta1/kvmsource.go new file mode 100644 index 00000000..deb09c76 --- /dev/null +++ b/pkg/generated/controllers/migration.harvesterhci.io/v1beta1/kvmsource.go @@ -0,0 +1,208 @@ +/* +Copyright 2025 Rancher Labs, Inc. + +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. +*/ + +// Code generated by main. DO NOT EDIT. + +package v1beta1 + +import ( + "context" + "sync" + "time" + + v1beta1 "github.com/harvester/vm-import-controller/pkg/apis/migration.harvesterhci.io/v1beta1" + "github.com/rancher/wrangler/v3/pkg/apply" + "github.com/rancher/wrangler/v3/pkg/condition" + "github.com/rancher/wrangler/v3/pkg/generic" + "github.com/rancher/wrangler/v3/pkg/kv" + "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// KVMSourceController interface for managing KVMSource resources. +type KVMSourceController interface { + generic.ControllerInterface[*v1beta1.KVMSource, *v1beta1.KVMSourceList] +} + +// KVMSourceClient interface for managing KVMSource resources in Kubernetes. +type KVMSourceClient interface { + generic.ClientInterface[*v1beta1.KVMSource, *v1beta1.KVMSourceList] +} + +// KVMSourceCache interface for retrieving KVMSource resources in memory. +type KVMSourceCache interface { + generic.CacheInterface[*v1beta1.KVMSource] +} + +// KVMSourceStatusHandler is executed for every added or modified KVMSource. Should return the new status to be updated +type KVMSourceStatusHandler func(obj *v1beta1.KVMSource, status v1beta1.KVMSourceStatus) (v1beta1.KVMSourceStatus, error) + +// KVMSourceGeneratingHandler is the top-level handler that is executed for every KVMSource event. It extends KVMSourceStatusHandler by a returning a slice of child objects to be passed to apply.Apply +type KVMSourceGeneratingHandler func(obj *v1beta1.KVMSource, status v1beta1.KVMSourceStatus) ([]runtime.Object, v1beta1.KVMSourceStatus, error) + +// RegisterKVMSourceStatusHandler configures a KVMSourceController to execute a KVMSourceStatusHandler for every events observed. +// If a non-empty condition is provided, it will be updated in the status conditions for every handler execution +func RegisterKVMSourceStatusHandler(ctx context.Context, controller KVMSourceController, condition condition.Cond, name string, handler KVMSourceStatusHandler) { + statusHandler := &kVMSourceStatusHandler{ + client: controller, + condition: condition, + handler: handler, + } + controller.AddGenericHandler(ctx, name, generic.FromObjectHandlerToHandler(statusHandler.sync)) +} + +// RegisterKVMSourceGeneratingHandler configures a KVMSourceController to execute a KVMSourceGeneratingHandler for every events observed, passing the returned objects to the provided apply.Apply. +// If a non-empty condition is provided, it will be updated in the status conditions for every handler execution +func RegisterKVMSourceGeneratingHandler(ctx context.Context, controller KVMSourceController, apply apply.Apply, + condition condition.Cond, name string, handler KVMSourceGeneratingHandler, opts *generic.GeneratingHandlerOptions) { + statusHandler := &kVMSourceGeneratingHandler{ + KVMSourceGeneratingHandler: handler, + apply: apply, + name: name, + gvk: controller.GroupVersionKind(), + } + if opts != nil { + statusHandler.opts = *opts + } + controller.OnChange(ctx, name, statusHandler.Remove) + RegisterKVMSourceStatusHandler(ctx, controller, condition, name, statusHandler.Handle) +} + +type kVMSourceStatusHandler struct { + client KVMSourceClient + condition condition.Cond + handler KVMSourceStatusHandler +} + +// sync is executed on every resource addition or modification. Executes the configured handlers and sends the updated status to the Kubernetes API +func (a *kVMSourceStatusHandler) sync(key string, obj *v1beta1.KVMSource) (*v1beta1.KVMSource, error) { + if obj == nil { + return obj, nil + } + + origStatus := obj.Status.DeepCopy() + obj = obj.DeepCopy() + newStatus, err := a.handler(obj, obj.Status) + if err != nil { + // Revert to old status on error + newStatus = *origStatus.DeepCopy() + } + + if a.condition != "" { + if errors.IsConflict(err) { + a.condition.SetError(&newStatus, "", nil) + } else { + a.condition.SetError(&newStatus, "", err) + } + } + if !equality.Semantic.DeepEqual(origStatus, &newStatus) { + if a.condition != "" { + // Since status has changed, update the lastUpdatedTime + a.condition.LastUpdated(&newStatus, time.Now().UTC().Format(time.RFC3339)) + } + + var newErr error + obj.Status = newStatus + newObj, newErr := a.client.UpdateStatus(obj) + if err == nil { + err = newErr + } + if newErr == nil { + obj = newObj + } + } + return obj, err +} + +type kVMSourceGeneratingHandler struct { + KVMSourceGeneratingHandler + apply apply.Apply + opts generic.GeneratingHandlerOptions + gvk schema.GroupVersionKind + name string + seen sync.Map +} + +// Remove handles the observed deletion of a resource, cascade deleting every associated resource previously applied +func (a *kVMSourceGeneratingHandler) Remove(key string, obj *v1beta1.KVMSource) (*v1beta1.KVMSource, error) { + if obj != nil { + return obj, nil + } + + obj = &v1beta1.KVMSource{} + obj.Namespace, obj.Name = kv.RSplit(key, "/") + obj.SetGroupVersionKind(a.gvk) + + if a.opts.UniqueApplyForResourceVersion { + a.seen.Delete(key) + } + + return nil, generic.ConfigureApplyForObject(a.apply, obj, &a.opts). + WithOwner(obj). + WithSetID(a.name). + ApplyObjects() +} + +// Handle executes the configured KVMSourceGeneratingHandler and pass the resulting objects to apply.Apply, finally returning the new status of the resource +func (a *kVMSourceGeneratingHandler) Handle(obj *v1beta1.KVMSource, status v1beta1.KVMSourceStatus) (v1beta1.KVMSourceStatus, error) { + if !obj.DeletionTimestamp.IsZero() { + return status, nil + } + + objs, newStatus, err := a.KVMSourceGeneratingHandler(obj, status) + if err != nil { + return newStatus, err + } + if !a.isNewResourceVersion(obj) { + return newStatus, nil + } + + err = generic.ConfigureApplyForObject(a.apply, obj, &a.opts). + WithOwner(obj). + WithSetID(a.name). + ApplyObjects(objs...) + if err != nil { + return newStatus, err + } + a.storeResourceVersion(obj) + return newStatus, nil +} + +// isNewResourceVersion detects if a specific resource version was already successfully processed. +// Only used if UniqueApplyForResourceVersion is set in generic.GeneratingHandlerOptions +func (a *kVMSourceGeneratingHandler) isNewResourceVersion(obj *v1beta1.KVMSource) bool { + if !a.opts.UniqueApplyForResourceVersion { + return true + } + + // Apply once per resource version + key := obj.Namespace + "/" + obj.Name + previous, ok := a.seen.Load(key) + return !ok || previous != obj.ResourceVersion +} + +// storeResourceVersion keeps track of the latest resource version of an object for which Apply was executed +// Only used if UniqueApplyForResourceVersion is set in generic.GeneratingHandlerOptions +func (a *kVMSourceGeneratingHandler) storeResourceVersion(obj *v1beta1.KVMSource) { + if !a.opts.UniqueApplyForResourceVersion { + return + } + + key := obj.Namespace + "/" + obj.Name + a.seen.Store(key, obj.ResourceVersion) +} From b49f56bd98d0095ca752386e68843ca364234f30 Mon Sep 17 00:00:00 2001 From: Davide Rutigliano Date: Fri, 12 Dec 2025 23:06:38 +0100 Subject: [PATCH 05/17] Add KVM integration tests Signed-off-by: Davide Rutigliano --- tests/integration/kvm_test.go | 184 +++++++++++++++++++++++++++++++ tests/integration/suite_test.go | 24 ++-- tests/setup/setup_kvm.go | 188 ++++++++++++++++++++++++++++++++ 3 files changed, 385 insertions(+), 11 deletions(-) create mode 100644 tests/integration/kvm_test.go create mode 100644 tests/setup/setup_kvm.go diff --git a/tests/integration/kvm_test.go b/tests/integration/kvm_test.go new file mode 100644 index 00000000..422c8cfe --- /dev/null +++ b/tests/integration/kvm_test.go @@ -0,0 +1,184 @@ +package integration + +import ( + "fmt" + "strings" + + harvesterv1beta1 "github.com/harvester/harvester/pkg/apis/harvesterhci.io/v1beta1" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + kubevirt "kubevirt.io/api/core/v1" + + migration "github.com/harvester/vm-import-controller/pkg/apis/migration.harvesterhci.io/v1beta1" + "github.com/harvester/vm-import-controller/pkg/util" + "github.com/harvester/vm-import-controller/tests/setup" +) + +var _ = Describe("test kvm export/import integration", func() { + + BeforeEach(func() { + if !useExisting { + return + } + err := setup.SetupKVM(ctx, k8sClient) + Expect(err).ToNot(HaveOccurred()) + }) + + It("reconcile object status", func() { + if !useExisting { + Skip("skipping kvm integration tests as not using an existing environment") + } + + By("checking if kvm migration is ready", func() { + Eventually(func() error { + v := &migration.KVMSource{} + err := k8sClient.Get(ctx, setup.KVMSourceNamespacedName, v) + if err != nil { + return err + } + if v.Status.Status != migration.ClusterReady { + return fmt.Errorf("waiting for cluster migration to be ready. current condition is %s", v.Status.Status) + } + + return nil + }, "30s", "10s").ShouldNot(HaveOccurred()) + }) + + By("vm importjob has the correct conditions", func() { + Eventually(func() error { + v := &migration.VirtualMachineImport{} + err := k8sClient.Get(ctx, setup.KVMVMNamespacedName, v) + if err != nil { + return err + } + if !util.ConditionExists(v.Status.ImportConditions, migration.VirtualMachinePoweringOff, v1.ConditionTrue) { + return fmt.Errorf("expected virtualmachinepoweringoff condition to be present") + } + + if !util.ConditionExists(v.Status.ImportConditions, migration.VirtualMachinePoweredOff, v1.ConditionTrue) { + return fmt.Errorf("expected virtualmachinepoweredoff condition to be present") + } + + if !util.ConditionExists(v.Status.ImportConditions, migration.VirtualMachineExported, v1.ConditionTrue) { + return fmt.Errorf("expected virtualmachineexported condition to be present") + } + + return nil + }, "300s", "10s").ShouldNot(HaveOccurred()) + }) + + By("checking status of virtualmachineimage objects", func() { + Eventually(func() error { + v := &migration.VirtualMachineImport{} + err := k8sClient.Get(ctx, setup.KVMVMNamespacedName, v) + if err != nil { + return err + } + + if len(v.Status.DiskImportStatus) == 0 { + return fmt.Errorf("waiting for DiskImportStatus to be populated") + } + for _, d := range v.Status.DiskImportStatus { + if d.VirtualMachineImage == "" { + return fmt.Errorf("waiting for VMI to be populated") + } + vmi := &harvesterv1beta1.VirtualMachineImage{} + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: setup.KVMVMNamespacedName.Namespace, + Name: d.VirtualMachineImage}, vmi) + if err != nil { + return err + } + + if vmi.Status.Progress != 100 { + return fmt.Errorf("vmi %s not yet ready", vmi.Name) + } + } + return nil + }, "300s", "10s").ShouldNot(HaveOccurred()) + }) + + By("checking that PVC claim has been created", func() { + Eventually(func() error { + v := &migration.VirtualMachineImport{} + err := k8sClient.Get(ctx, setup.KVMVMNamespacedName, v) + if err != nil { + return err + } + if len(v.Status.DiskImportStatus) == 0 { + return fmt.Errorf("diskimportstatus should have image details available") + } + for _, d := range v.Status.DiskImportStatus { + if d.VirtualMachineImage == "" { + return fmt.Errorf("waiting for VMI to be populated") + } + pvc := &v1.PersistentVolumeClaim{} + pvcName := strings.ToLower(strings.Split(d.Name, ".img")[0]) + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: setup.KVMVMNamespacedName.Namespace, + Name: pvcName}, pvc) + if err != nil { + return err + } + + if pvc.Status.Phase != v1.ClaimBound { + return fmt.Errorf("waiting for pvc claim to be in state bound") + } + } + + return nil + }, "120s", "10s").ShouldNot(HaveOccurred()) + }) + + By("checking that the virtualmachine has been created", func() { + Eventually(func() error { + v := &migration.VirtualMachineImport{} + err := k8sClient.Get(ctx, setup.KVMVMNamespacedName, v) + if err != nil { + return err + } + + vm := &kubevirt.VirtualMachine{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Namespace: setup.KVMVMNamespacedName.Namespace, + Name: v.Spec.VirtualMachineName, + }, vm) + + return err + }, "300s", "10s").ShouldNot(HaveOccurred()) + }) + + // can take upto 5 mins for the VM to be marked as running + By("checking that the virtualmachineimage ownership has been removed", func() { + Eventually(func() error { + v := &migration.VirtualMachineImport{} + err := k8sClient.Get(ctx, setup.KVMVMNamespacedName, v) + if err != nil { + return err + } + + for _, d := range v.Status.DiskImportStatus { + vmi := &harvesterv1beta1.VirtualMachineImage{} + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: setup.KVMVMNamespacedName.Namespace, + Name: d.VirtualMachineImage}, vmi) + if err != nil { + return err + } + + if len(vmi.OwnerReferences) != 0 { + return fmt.Errorf("waiting for ownerRef to be cleared") + } + } + + return nil + }, "600s", "30s").ShouldNot(HaveOccurred()) + }) + + }) + + AfterEach(func() { + if !useExisting { + return + } + err := setup.CleanupKVM(ctx, k8sClient) + Expect(err).ToNot(HaveOccurred()) + }) +}) diff --git a/tests/integration/suite_test.go b/tests/integration/suite_test.go index 1e0bb996..cf9321a9 100644 --- a/tests/integration/suite_test.go +++ b/tests/integration/suite_test.go @@ -132,20 +132,22 @@ var _ = BeforeSuite(func() { return eg.Wait() }) - pool, err = dockertest.NewPool("") - Expect(err).NotTo(HaveOccurred()) - runOpts := &dockertest.RunOptions{ - Name: "vcsim-integration", - Repository: "vmware/vcsim", - Tag: "v0.29.0", - } + if os.Getenv("SKIP_VCSIM") != "true" { + pool, err = dockertest.NewPool("") + Expect(err).NotTo(HaveOccurred()) + runOpts := &dockertest.RunOptions{ + Name: "vcsim-integration", + Repository: "vmware/vcsim", + Tag: "v0.29.0", + } - vcsimMock, err = pool.RunWithOptions(runOpts) - Expect(err).NotTo(HaveOccurred()) + vcsimMock, err = pool.RunWithOptions(runOpts) + Expect(err).NotTo(HaveOccurred()) - vcsimPort = vcsimMock.GetPort("8989/tcp") + vcsimPort = vcsimMock.GetPort("8989/tcp") - time.Sleep(30 * time.Second) + time.Sleep(30 * time.Second) + } }) diff --git a/tests/setup/setup_kvm.go b/tests/setup/setup_kvm.go new file mode 100644 index 00000000..212b7568 --- /dev/null +++ b/tests/setup/setup_kvm.go @@ -0,0 +1,188 @@ +package setup + +import ( + "context" + "fmt" + "os" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + migration "github.com/harvester/vm-import-controller/pkg/apis/migration.harvesterhci.io/v1beta1" +) + +const ( + kvmSecret = "kvm-integration" + kvmSourceCluster = "kvm-integration" + kvmVirtualMachine = "kvm-export-test" + kvmDefaultNamespace = "default" + kvmDefaultKind = "KVMSource" + kvmDefaultAPIVersion = "migration.harvesterhci.io/v1beta1" +) + +var ( + KVMSourceNamespacedName types.NamespacedName + KVMVMNamespacedName types.NamespacedName +) + +// SetupKVM will try and setup a kvm migration based on environment variables +// It will check the following environment variables to build migration and importjob CRD's +// KVM_LIBVIRT_URI: Identify libvirt endpoint (e.g. qemu+ssh://user@host/system) +// KVM_SSH_USER: Username for migration secret +// KVM_SSH_PASSWORD: Password for migration secret +// SVC_ADDRESS: local machine address, used to generate the URL that Harvester downloads the exported images from +// VM_NAME: name of VM to be exported +func SetupKVM(ctx context.Context, k8sClient client.Client) error { + KVMSourceNamespacedName = types.NamespacedName{ + Name: kvmSourceCluster, + Namespace: kvmDefaultNamespace, + } + + KVMVMNamespacedName = types.NamespacedName{ + Name: kvmVirtualMachine, + Namespace: kvmDefaultNamespace, + } + + fnList := []applyObject{ + setupKVMSecret, + setupKVMSource, + setupKVMVMExport, + } + + for _, v := range fnList { + if err := v(ctx, k8sClient); err != nil { + return err + } + } + + return nil +} + +func setupKVMSecret(ctx context.Context, k8sClient client.Client) error { + username, ok := os.LookupEnv("KVM_SSH_USER") + if !ok { + return fmt.Errorf("env variable KVM_SSH_USER not set") + } + + stringData := map[string]string{ + "username": username, + } + + password, hasPassword := os.LookupEnv("KVM_SSH_PASSWORD") + if hasPassword { + stringData["password"] = password + } + + keyPath, hasKey := os.LookupEnv("KVM_SSH_PRIVATE_KEY_PATH") + if hasKey { + keyContent, err := os.ReadFile(keyPath) + if err != nil { + return fmt.Errorf("failed to read private key file %s: %v", keyPath, err) + } + stringData["privateKey"] = string(keyContent) + } + + if !hasPassword && !hasKey { + return fmt.Errorf("neither KVM_SSH_PASSWORD nor KVM_SSH_PRIVATE_KEY_PATH set") + } + + s := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: kvmSecret, + Namespace: kvmDefaultNamespace, + }, + StringData: stringData, + } + + return k8sClient.Create(ctx, s) +} + +func setupKVMSource(ctx context.Context, k8sClient client.Client) error { + endpoint, ok := os.LookupEnv("KVM_LIBVIRT_URI") + if !ok { + return fmt.Errorf("env variable KVM_LIBVIRT_URI not set") + } + + s := &migration.KVMSource{ + ObjectMeta: metav1.ObjectMeta{ + Name: kvmSourceCluster, + Namespace: kvmDefaultNamespace, + }, + Spec: migration.KVMSourceSpec{ + LibvirtURI: endpoint, + Credentials: corev1.SecretReference{ + Name: kvmSecret, + Namespace: kvmDefaultNamespace, + }, + }, + } + + return k8sClient.Create(ctx, s) + +} + +func setupKVMVMExport(ctx context.Context, k8sClient client.Client) error { + vm, ok := os.LookupEnv("VM_NAME") + if !ok { + return fmt.Errorf("env variable VM_NAME not specified") + } + + _, ok = os.LookupEnv("SVC_ADDRESS") + if !ok { + return fmt.Errorf("env variable SVC_ADDRESS not specified") + } + + j := &migration.VirtualMachineImport{ + ObjectMeta: metav1.ObjectMeta{ + Name: kvmVirtualMachine, + Namespace: kvmDefaultNamespace, + }, + Spec: migration.VirtualMachineImportSpec{ + SourceCluster: corev1.ObjectReference{ + Name: kvmSourceCluster, + Namespace: kvmDefaultNamespace, + Kind: kvmDefaultKind, + APIVersion: kvmDefaultAPIVersion, + }, + VirtualMachineName: vm, + }, + } + + return k8sClient.Create(ctx, j) +} + +func CleanupKVM(ctx context.Context, k8sClient client.Client) error { + s := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: kvmSecret, + Namespace: kvmDefaultNamespace, + }, + } + err := k8sClient.Delete(ctx, s) + if err != nil { + return err + } + + kvm := &migration.KVMSource{ + ObjectMeta: metav1.ObjectMeta{ + Name: kvmSourceCluster, + Namespace: kvmDefaultNamespace, + }, + } + + err = k8sClient.Delete(ctx, kvm) + if err != nil { + return err + } + + i := &migration.VirtualMachineImport{ + ObjectMeta: metav1.ObjectMeta{ + Name: kvmVirtualMachine, + Namespace: kvmDefaultNamespace, + }, + } + + return k8sClient.Delete(ctx, i) +} From ddea49c17823b4a76ebb3d5f5eaf8416cda69d12 Mon Sep 17 00:00:00 2001 From: Davide Rutigliano Date: Fri, 12 Dec 2025 23:07:15 +0100 Subject: [PATCH 06/17] Remove unused KVMSource option Signed-off-by: Davide Rutigliano --- .../migration.harvesterhci.io/v1beta1/kvm.go | 14 ++++---------- .../v1beta1/zz_generated_deepcopy.go | 16 ---------------- pkg/source/kvm/client.go | 5 +---- 3 files changed, 5 insertions(+), 30 deletions(-) diff --git a/pkg/apis/migration.harvesterhci.io/v1beta1/kvm.go b/pkg/apis/migration.harvesterhci.io/v1beta1/kvm.go index d411c833..93fb2603 100644 --- a/pkg/apis/migration.harvesterhci.io/v1beta1/kvm.go +++ b/pkg/apis/migration.harvesterhci.io/v1beta1/kvm.go @@ -10,6 +10,7 @@ import ( // +genclient // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:subresource:status type KVMSource struct { metav1.TypeMeta `json:",inline"` @@ -19,9 +20,8 @@ type KVMSource struct { } type KVMSourceSpec struct { - LibvirtURI string `json:"libvirtURI"` - Credentials corev1.SecretReference `json:"credentials"` - InsecureSkipTLSVerify bool `json:"insecureSkipTLSVerify,omitempty"` + LibvirtURI string `json:"libvirtURI"` + Credentials corev1.SecretReference `json:"credentials"` } type KVMSourceStatus struct { @@ -57,12 +57,6 @@ func (s *KVMSource) GetConnectionInfo() (string, string) { return s.Spec.LibvirtURI, "" } -type KVMSourceOptions struct { - InsecureSkipTLSVerify bool -} - func (s *KVMSource) GetOptions() interface{} { - return KVMSourceOptions{ - InsecureSkipTLSVerify: s.Spec.InsecureSkipTLSVerify, - } + return nil } diff --git a/pkg/apis/migration.harvesterhci.io/v1beta1/zz_generated_deepcopy.go b/pkg/apis/migration.harvesterhci.io/v1beta1/zz_generated_deepcopy.go index 126474ee..15e5368a 100644 --- a/pkg/apis/migration.harvesterhci.io/v1beta1/zz_generated_deepcopy.go +++ b/pkg/apis/migration.harvesterhci.io/v1beta1/zz_generated_deepcopy.go @@ -110,22 +110,6 @@ func (in *KVMSourceList) DeepCopyObject() runtime.Object { return nil } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *KVMSourceOptions) DeepCopyInto(out *KVMSourceOptions) { - *out = *in - return -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KVMSourceOptions. -func (in *KVMSourceOptions) DeepCopy() *KVMSourceOptions { - if in == nil { - return nil - } - out := new(KVMSourceOptions) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *KVMSourceSpec) DeepCopyInto(out *KVMSourceSpec) { *out = *in diff --git a/pkg/source/kvm/client.go b/pkg/source/kvm/client.go index 4c62b57a..3dcc605d 100644 --- a/pkg/source/kvm/client.go +++ b/pkg/source/kvm/client.go @@ -69,10 +69,7 @@ func NewClient(ctx context.Context, libvirtURI string, secret *corev1.Secret) (* User: user, Auth: authMethods, HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error { - // InsecureSkipTLSVerify logic handled by caller or ignored for now as we don't have known_hosts management easily - // For this POC/implementation, we accept all keys if InsecureSkipTLSVerify is true (passed down? No, we don't have it here yet) - // The CRD has the flag. - return nil // TODO: Implement strict host checking if needed + return nil }, Timeout: 10 * time.Second, } From 86cef7e01680a9c1ec55c3ac425e84fa33f8e9b9 Mon Sep 17 00:00:00 2001 From: Davide Rutigliano Date: Fri, 12 Dec 2025 23:07:26 +0100 Subject: [PATCH 07/17] Update gitignore Signed-off-by: Davide Rutigliano --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e20f8611..a27498f1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ *.swp .idea *.DS_Store +.env From c94a364ac49a2b95618629fd18a98c4d6f51dbc6 Mon Sep 17 00:00:00 2001 From: Davide Rutigliano Date: Sun, 14 Dec 2025 11:15:50 +0100 Subject: [PATCH 08/17] Change KVM disk export to sftp Signed-off-by: Davide Rutigliano --- go.mod | 10 +++--- go.sum | 16 +++++---- pkg/qemu/qemu.go | 21 ++++------- pkg/source/kvm/client.go | 68 ++++++++++++++++++----------------- tests/integration/kvm_test.go | 4 +-- 5 files changed, 59 insertions(+), 60 deletions(-) diff --git a/go.mod b/go.mod index 50e6110d..1af57d43 100644 --- a/go.mod +++ b/go.mod @@ -13,16 +13,17 @@ require ( github.com/onsi/ginkgo/v2 v2.23.3 github.com/onsi/gomega v1.37.0 github.com/ory/dockertest/v3 v3.9.1 + github.com/pkg/sftp v1.13.10 github.com/rancher/lasso v0.2.3 github.com/rancher/wrangler/v3 v3.2.2 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.10.0 github.com/vmware/govmomi v0.52.0 - golang.org/x/crypto v0.40.0 + golang.org/x/crypto v0.41.0 golang.org/x/sync v0.16.0 k8s.io/api v0.33.1 - k8s.io/apimachinery v0.33.1 k8s.io/apiextensions-apiserver v0.33.1 + k8s.io/apimachinery v0.33.1 k8s.io/client-go v12.0.0+incompatible k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 kubevirt.io/api v1.5.0 @@ -76,6 +77,7 @@ require ( github.com/jinzhu/copier v0.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/kr/fs v0.1.0 // indirect github.com/kubernetes-csi/external-snapshotter/client/v4 v4.2.0 // indirect github.com/longhorn/go-common-libs v0.0.0-20250215052214-151615b29f8e // indirect github.com/mailru/easyjson v0.9.0 // indirect @@ -117,8 +119,8 @@ require ( golang.org/x/mod v0.26.0 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sys v0.34.0 // indirect - golang.org/x/term v0.33.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/term v0.34.0 // indirect golang.org/x/text v0.28.0 // indirect golang.org/x/time v0.11.0 // indirect golang.org/x/tools v0.35.0 // indirect diff --git a/go.sum b/go.sum index c635c85c..d46bb2e7 100644 --- a/go.sum +++ b/go.sum @@ -200,6 +200,8 @@ github.com/k8snetworkplumbingwg/network-attachment-definition-client v1.7.5 h1:C github.com/k8snetworkplumbingwg/network-attachment-definition-client v1.7.5/go.mod h1:CM7HAH5PNuIsqjMN0fGc1ydM74Uj+0VZFhob620nklw= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -318,6 +320,8 @@ github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= +github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -434,8 +438,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= @@ -565,8 +569,8 @@ golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/telemetry v0.0.0-20240208230135-b75ee8823808/go.mod h1:KG1lNk5ZFNssSZLrpVb4sMXKMpGwGXOxSG3rnu2gZQQ= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= @@ -592,8 +596,8 @@ golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= -golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= -golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/pkg/qemu/qemu.go b/pkg/qemu/qemu.go index 029f9554..5f2f6a5e 100644 --- a/pkg/qemu/qemu.go +++ b/pkg/qemu/qemu.go @@ -4,7 +4,6 @@ import ( "fmt" "io" "os/exec" - "syscall" "github.com/sirupsen/logrus" ) @@ -20,14 +19,15 @@ func ConvertVMDKtoRAW(source, target string) error { return runCommand(defaultCommand, args...) } -func ConvertFromStdin(source io.Reader, target, format string) error { +func ConvertFromPath(source, target, format string) error { logrus.WithFields(logrus.Fields{ + "source": source, "target": target, "format": format, - }).Info("Converting image from stdin to RAW ...") - // qemu-img convert -f -O raw /dev/stdin - args := []string{"convert", "-f", format, "-O", "raw", "/dev/stdin", target} - return runCommandWithStdin(defaultCommand, source, args...) + }).Info("Converting image from path to RAW ...") + // qemu-img convert -f -O raw + args := []string{"convert", "-f", format, "-O", "raw", source, target} + return runCommand(defaultCommand, args...) } func createVMDK(path string, size string) error { @@ -36,17 +36,8 @@ func createVMDK(path string, size string) error { } func runCommand(command string, args ...string) error { - return runCommandWithStdin(command, nil, args...) -} - -func runCommandWithStdin(command string, stdin io.Reader, args ...string) error { cmd := exec.Command(command, args...) - cmd.SysProcAttr = &syscall.SysProcAttr{} - if stdin != nil { - cmd.Stdin = stdin - } stderr, err := cmd.StderrPipe() - if err != nil { return fmt.Errorf("error creating stderr pipe: %v", err) } diff --git a/pkg/source/kvm/client.go b/pkg/source/kvm/client.go index 3dcc605d..171c6fff 100644 --- a/pkg/source/kvm/client.go +++ b/pkg/source/kvm/client.go @@ -5,6 +5,7 @@ import ( "context" "encoding/xml" "fmt" + "io" "net" "net/url" "os" @@ -12,6 +13,7 @@ import ( "strings" "time" + "github.com/pkg/sftp" "github.com/sirupsen/logrus" "golang.org/x/crypto/ssh" corev1 "k8s.io/api/core/v1" @@ -221,13 +223,11 @@ func (c *Client) ExportVirtualMachine(vm *migration.VirtualMachineImport) error return err } - tmpPath, err := os.MkdirTemp("/tmp", fmt.Sprintf("%s-%s-", vm.Name, vm.Namespace)) + sftpClient, err := sftp.NewClient(c.sshClient) if err != nil { - return fmt.Errorf("error creating tmp dir: %v", err) + return fmt.Errorf("failed to create sftp client: %v", err) } - // Note: We don't defer removeAll here because we might want to keep it if something fails? - // Actually, we should clean up. But the VMware client does defer removeAll. - defer os.RemoveAll(tmpPath) + defer sftpClient.Close() for i, disk := range dom.Devices.Disks { if disk.Device != "disk" { @@ -237,48 +237,44 @@ func (c *Client) ExportVirtualMachine(vm *migration.VirtualMachineImport) error continue } - // Local destination path - rawDiskName := fmt.Sprintf("%s-disk-%d.img", vm.Name, i) - destFile := filepath.Join(server.TempDir(), rawDiskName) - - logrus.Infof("Exporting disk %s to %s", disk.Source.File, destFile) - - // Use ssh cat | qemu-img convert - // We need a new session for the pipe - session, err := c.sshClient.NewSession() + // Create a temporary file to store the downloaded disk + tmpFile, err := os.CreateTemp("", fmt.Sprintf("%s-disk-%d-", vm.Name, i)) if err != nil { - return fmt.Errorf("failed to create ssh session: %v", err) + return fmt.Errorf("failed to create temporary file for download: %v", err) } + defer os.Remove(tmpFile.Name()) - stdout, err := session.StdoutPipe() + logrus.Infof("Downloading disk %s to %s", disk.Source.File, tmpFile.Name()) + + // Open the remote file + remoteFile, err := sftpClient.Open(disk.Source.File) if err != nil { - session.Close() - return fmt.Errorf("failed to get stdout pipe: %v", err) + return fmt.Errorf("failed to open remote file %s: %v", disk.Source.File, err) } + defer remoteFile.Close() - if err := session.Start(fmt.Sprintf("cat %s", disk.Source.File)); err != nil { - session.Close() - return fmt.Errorf("failed to start cat command: %v", err) + // Copy the remote file to the temporary local file + if _, err := io.Copy(tmpFile, remoteFile); err != nil { + return fmt.Errorf("failed to download remote file: %v", err) } + tmpFile.Close() // Close the file so qemu-img can access it + + // Local destination path for the converted RAW image + rawDiskName := fmt.Sprintf("%s-disk-%d.img", vm.Name, i) + destFile := filepath.Join(server.TempDir(), rawDiskName) - // Run qemu-img convert reading from stdin - // XML has driver type. + logrus.Infof("Converting downloaded disk %s to %s", tmpFile.Name(), destFile) + + // Use qemu-img convert on the local, downloaded file srcFormat := disk.Driver.Type if srcFormat == "" { srcFormat = "qcow2" // Default assumption } - // Use shared qemu package - if err := qemu.ConvertFromStdin(stdout, destFile, srcFormat); err != nil { - session.Close() + if err := qemu.ConvertFromPath(tmpFile.Name(), destFile, srcFormat); err != nil { return fmt.Errorf("qemu convert failed: %v", err) } - if err := session.Wait(); err != nil { - return fmt.Errorf("ssh cat command failed: %v", err) - } - session.Close() - // Update status busType := kubevirt.DiskBusVirtio if disk.Target.Bus == "sata" || disk.Target.Bus == "ide" { @@ -299,7 +295,15 @@ func (c *Client) ExportVirtualMachine(vm *migration.VirtualMachineImport) error } func (c *Client) ShutdownGuest(vm *migration.VirtualMachineImport) error { - _, err := c.runCommand(fmt.Sprintf("virsh shutdown %s", vm.Spec.VirtualMachineName)) + powerOff, err := c.IsPoweredOff(vm) + if err != nil { + return err + } + if powerOff { + logrus.Infof("VM %s is already powered off, skipping shutdown.", vm.Spec.VirtualMachineName) + return nil + } + _, err = c.runCommand(fmt.Sprintf("virsh shutdown %s", vm.Spec.VirtualMachineName)) return err } diff --git a/tests/integration/kvm_test.go b/tests/integration/kvm_test.go index 422c8cfe..db292f44 100644 --- a/tests/integration/kvm_test.go +++ b/tests/integration/kvm_test.go @@ -2,7 +2,6 @@ package integration import ( "fmt" - "strings" harvesterv1beta1 "github.com/harvester/harvester/pkg/apis/harvesterhci.io/v1beta1" v1 "k8s.io/api/core/v1" @@ -112,9 +111,8 @@ var _ = Describe("test kvm export/import integration", func() { return fmt.Errorf("waiting for VMI to be populated") } pvc := &v1.PersistentVolumeClaim{} - pvcName := strings.ToLower(strings.Split(d.Name, ".img")[0]) err := k8sClient.Get(ctx, types.NamespacedName{Namespace: setup.KVMVMNamespacedName.Namespace, - Name: pvcName}, pvc) + Name: d.VirtualMachineImage}, pvc) if err != nil { return err } From 5ef1e8db95044ddd7ddac8eca7447a9eb5ab573b Mon Sep 17 00:00:00 2001 From: Davide Rutigliano Date: Sun, 14 Dec 2025 21:51:45 +0100 Subject: [PATCH 09/17] Update readme Signed-off-by: Davide Rutigliano --- README.md | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/README.md b/README.md index bd372696..837d9e60 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,45 @@ NAME STATUS devstack clusterReady ``` +For KVM based source clusters a sample definition is as follows: + +```yaml +apiVersion: migration.harvesterhci.io/v1beta1 +kind: KVMSource +metadata: + name: kvm + namespace: default +spec: + libvirtURI: qemu+ssh:///system + credentials: + name: kvm-credentials + namespace: default +``` + +The secret contains the credentials for the KVM host: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: kvm-credentials + namespace: default +type: Opaque +stringData: + privateKey: | + -----BEGIN RSA PRIVATE KEY----- + ... + -----END RSA PRIVATE KEY----- +``` + +KVM source reconcile process, attempts to list VM's in the cluster, and marks the source as ready + +```shell +$ kubectl get kvmsource.migration +NAME STATUS +kvm clusterReady +``` + ### VirtualMachimeImport The VirtualMachineImport crd provides a way for users to define the source VM and mapping to the actual source cluster to perform the VM export-import from. @@ -158,6 +197,25 @@ spec: *NOTE:* Openstack allows users to have multiple instances with the same name. In such a scenario the users are advised to use the Instance ID. The reconcile logic tries to perform a lookup from name to ID when a name is used. +And VirtualMachineImport for KMV as well: + +```yaml +apiVersion: migration.harvesterhci.io/v1beta1 +kind: VirtualMachineImport +metadata: + name: kvm-demo + namespace: default +spec: + virtualMachineName: kvm-demo + networkMapping: + - sourceNetwork: "default" + destinationNetwork: "default/vlan1" + sourceCluster: + kind: KVMSource + name: kvm + namespace: default + apiVersion: migration.harvesterhci.io/v1beta1 +``` ## Testing Currently basic integration tests are available under `tests/integration` @@ -183,5 +241,9 @@ export OS_USERNAME="openstack-username" export OS_PASSWORD="openstack-password" export OS_VM_NAME="openstack-export-test-vm-name" export OS_REGION_NAME="openstack-region" +export KVM_LIBVIRT_URI="qemu+ssh://@" +export KVM_SSH_USER="KVM user" +export KVM_SSH_PRIVATE_KEY_PATH="path to KVM ssh key" +export SKIP_VCSIM=true #When testing for KVM only export KUBECONFIG="kubeconfig-for-harvester-cluster" ``` \ No newline at end of file From 7ef6f0715aee7e0247ded69f2f069366243b119d Mon Sep 17 00:00:00 2001 From: Davide Rutigliano Date: Mon, 15 Dec 2025 16:34:59 +0100 Subject: [PATCH 10/17] Add CRD registration Signed-off-by: Davide Rutigliano --- pkg/apis/migration.harvesterhci.io/v1beta1/kvm.go | 1 - pkg/crd/crd.go | 4 ++++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/apis/migration.harvesterhci.io/v1beta1/kvm.go b/pkg/apis/migration.harvesterhci.io/v1beta1/kvm.go index 93fb2603..e270613f 100644 --- a/pkg/apis/migration.harvesterhci.io/v1beta1/kvm.go +++ b/pkg/apis/migration.harvesterhci.io/v1beta1/kvm.go @@ -10,7 +10,6 @@ import ( // +genclient // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object -// +kubebuilder:subresource:status type KVMSource struct { metav1.TypeMeta `json:",inline"` diff --git a/pkg/crd/crd.go b/pkg/crd/crd.go index 51a710b7..e2404ff8 100644 --- a/pkg/crd/crd.go +++ b/pkg/crd/crd.go @@ -24,6 +24,10 @@ func List() []crd.CRD { return c. WithColumn("Status", ".status.status") }), + newCRD("migration.harvesterhci.io", &migration.KVMSource{}, func(c crd.CRD) crd.CRD { + return c. + WithColumn("Status", ".status.status") + }), newCRD("migration.harvesterhci.io", &migration.VirtualMachineImport{}, func(c crd.CRD) crd.CRD { return c. WithColumn("Status", ".status.importStatus") From 97f025bb8ee5e011a28bfbce55797e34d8fe9a21 Mon Sep 17 00:00:00 2001 From: Davide Rutigliano Date: Mon, 15 Dec 2025 22:22:26 +0100 Subject: [PATCH 11/17] Add libvirtxml to parse VM domains Signed-off-by: Davide Rutigliano --- README.md | 2 +- go.mod | 1 + go.sum | 2 + pkg/source/kvm/client.go | 167 +++++++++++++--------------------- pkg/source/kvm/client_test.go | 20 ++-- 5 files changed, 75 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index 837d9e60..c43c3a3b 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ $ kubectl get virtualmachineimport.migration NAME STATUS alpine-export-test virtualMachineRunning openstack-cirros-test virtualMachineRunning - +kvm-export-test virtualMachineRunning ``` Similarly, users can define a VirtualMachineImport for Openstack source as well: diff --git a/go.mod b/go.mod index 1af57d43..c5b5fc9e 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 kubevirt.io/api v1.5.0 kubevirt.io/kubevirt v1.5.0 + libvirt.org/go/libvirtxml v1.11010.0 sigs.k8s.io/cluster-api v1.9.4 sigs.k8s.io/controller-runtime v0.20.2 ) diff --git a/go.sum b/go.sum index d46bb2e7..ff28ded8 100644 --- a/go.sum +++ b/go.sum @@ -751,6 +751,8 @@ kubevirt.io/controller-lifecycle-operator-sdk/api v0.0.0-20220329064328-f3cc58c6 kubevirt.io/controller-lifecycle-operator-sdk/api v0.0.0-20220329064328-f3cc58c6ed90/go.mod h1:018lASpFYBsYN6XwmA2TIrPCx6e0gviTd/ZNtSitKgc= kubevirt.io/kubevirt v1.6.0 h1:xk7NgCHB3PVedEbuR7J+ehfR6ihmoDrjpl8tG3on3VE= kubevirt.io/kubevirt v1.6.0/go.mod h1:tu6AWqWL1BdGQccdsy8aNsmoqzWWHBf1yDpiZrgSEQo= +libvirt.org/go/libvirtxml v1.11010.0 h1:lGUv6OQ4gz5Hm7F40G+swxmK/kcrMZGQ3M8/S+UyhME= +libvirt.org/go/libvirtxml v1.11010.0/go.mod h1:7Oq2BLDstLr/XtoQD8Fr3mfDNrzlI3utYKySXF2xkng= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.32.1 h1:Cf+ed5N8038zbsaXFO7mKQDi/+VcSRafb0jM84KX5so= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.32.1/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/cluster-api v1.9.4 h1:pa2Ho50F9Js/Vv/Jy11TcpmGiqY2ukXCoDj/dY25Y7M= diff --git a/pkg/source/kvm/client.go b/pkg/source/kvm/client.go index 171c6fff..e64d0b37 100644 --- a/pkg/source/kvm/client.go +++ b/pkg/source/kvm/client.go @@ -3,7 +3,6 @@ package kvm import ( "bytes" "context" - "encoding/xml" "fmt" "io" "net" @@ -19,6 +18,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" kubevirt "kubevirt.io/api/core/v1" + libvirtxml "libvirt.org/go/libvirtxml" migration "github.com/harvester/vm-import-controller/pkg/apis/migration.harvesterhci.io/v1beta1" "github.com/harvester/vm-import-controller/pkg/qemu" @@ -128,85 +128,14 @@ func (c *Client) runCommand(cmd string) (string, error) { return stdout.String(), nil } -// Domain XML structs -type Domain struct { - Name string `xml:"name"` - UUID string `xml:"uuid"` - Memory int `xml:"memory"` - VCPU int `xml:"vcpu"` - Devices Devices `xml:"devices"` - OS OS `xml:"os"` -} - -type OS struct { - Type OSType `xml:"type"` - BootDev []BootDev `xml:"boot>dev"` -} - -type OSType struct { - Arch string `xml:"arch,attr"` - Machine string `xml:"machine,attr"` - Value string `xml:",chardata"` -} - -type BootDev struct { - Dev string `xml:"dev,attr"` -} - -type Devices struct { - Disks []Disk `xml:"disk"` - Interfaces []Interface `xml:"interface"` -} - -type Disk struct { - Device string `xml:"device,attr"` - Driver Driver `xml:"driver"` - Source Source `xml:"source"` - Target Target `xml:"target"` -} - -type Driver struct { - Name string `xml:"name,attr"` - Type string `xml:"type,attr"` -} - -type Source struct { - File string `xml:"file,attr"` -} - -type Target struct { - Dev string `xml:"dev,attr"` - Bus string `xml:"bus,attr"` -} - -type Interface struct { - Type string `xml:"type,attr"` - Mac Mac `xml:"mac"` - Model Model `xml:"model"` - Source NetworkSource `xml:"source"` -} - -type Mac struct { - Address string `xml:"address,attr"` -} - -type Model struct { - Type string `xml:"type,attr"` -} - -type NetworkSource struct { - Network string `xml:"network,attr"` - Bridge string `xml:"bridge,attr"` -} - -func (c *Client) getDomainXML(vmName string) (*Domain, error) { +func (c *Client) getDomainXML(vmName string) (*libvirtxml.Domain, error) { out, err := c.runCommand(fmt.Sprintf("virsh dumpxml %s", vmName)) if err != nil { return nil, err } - var dom Domain - if err := xml.Unmarshal([]byte(out), &dom); err != nil { + var dom libvirtxml.Domain + if err := dom.Unmarshal(out); err != nil { return nil, fmt.Errorf("failed to unmarshal domain xml: %v", err) } return &dom, nil @@ -229,13 +158,18 @@ func (c *Client) ExportVirtualMachine(vm *migration.VirtualMachineImport) error } defer sftpClient.Close() + if dom.Devices == nil { + return fmt.Errorf("no devices found in domain XML") + } + for i, disk := range dom.Devices.Disks { if disk.Device != "disk" { continue } - if disk.Source.File == "" { + if disk.Source == nil || disk.Source.File == nil { continue } + sourceFile := disk.Source.File.File // Create a temporary file to store the downloaded disk tmpFile, err := os.CreateTemp("", fmt.Sprintf("%s-disk-%d-", vm.Name, i)) @@ -244,12 +178,12 @@ func (c *Client) ExportVirtualMachine(vm *migration.VirtualMachineImport) error } defer os.Remove(tmpFile.Name()) - logrus.Infof("Downloading disk %s to %s", disk.Source.File, tmpFile.Name()) + logrus.Infof("Downloading disk %s to %s", sourceFile, tmpFile.Name()) // Open the remote file - remoteFile, err := sftpClient.Open(disk.Source.File) + remoteFile, err := sftpClient.Open(sourceFile) if err != nil { - return fmt.Errorf("failed to open remote file %s: %v", disk.Source.File, err) + return fmt.Errorf("failed to open remote file %s: %v", sourceFile, err) } defer remoteFile.Close() @@ -266,9 +200,9 @@ func (c *Client) ExportVirtualMachine(vm *migration.VirtualMachineImport) error logrus.Infof("Converting downloaded disk %s to %s", tmpFile.Name(), destFile) // Use qemu-img convert on the local, downloaded file - srcFormat := disk.Driver.Type - if srcFormat == "" { - srcFormat = "qcow2" // Default assumption + srcFormat := "qcow2" // Default assumption + if disk.Driver != nil && disk.Driver.Type != "" { + srcFormat = disk.Driver.Type } if err := qemu.ConvertFromPath(tmpFile.Name(), destFile, srcFormat); err != nil { @@ -277,15 +211,23 @@ func (c *Client) ExportVirtualMachine(vm *migration.VirtualMachineImport) error // Update status busType := kubevirt.DiskBusVirtio - if disk.Target.Bus == "sata" || disk.Target.Bus == "ide" { - busType = kubevirt.DiskBusSATA - } else if disk.Target.Bus == "scsi" { - busType = kubevirt.DiskBusSCSI + if disk.Target != nil { + if disk.Target.Bus == "sata" || disk.Target.Bus == "ide" { + busType = kubevirt.DiskBusSATA + } else if disk.Target.Bus == "scsi" { + busType = kubevirt.DiskBusSCSI + } + } + + // Get the size of the converted image + destFileInfo, err := os.Stat(destFile) + if err != nil { + return fmt.Errorf("failed to get stats for converted disk: %v", err) } vm.Status.DiskImportStatus = append(vm.Status.DiskImportStatus, migration.DiskInfo{ Name: rawDiskName, - DiskSize: 0, // We could get this from qemu-img info on the result + DiskSize: destFileInfo.Size(), BusType: busType, DiskLocalPath: server.TempDir(), }) @@ -340,31 +282,44 @@ func (c *Client) GenerateVirtualMachine(vm *migration.VirtualMachineImport) (*ku vmSpec := source.NewVirtualMachineSpec(source.VirtualMachineSpecConfig{ Name: vm.Status.ImportedVirtualMachineName, Hardware: source.Hardware{ - NumCPU: uint32(dom.VCPU), - MemoryMB: int64(dom.Memory / 1024), // XML memory is usually in KiB + NumCPU: uint32(dom.VCPU.Value), + MemoryMB: int64(dom.Memory.Value / 1024), // XML memory is usually in KiB }, }) // Network mapping var networkInfos []source.NetworkInfo - for _, iface := range dom.Devices.Interfaces { - model := migration.NetworkInterfaceModelVirtio - if iface.Model.Type == "e1000" { - model = migration.NetworkInterfaceModelE1000 - } else if iface.Model.Type == "e1000e" { - model = migration.NetworkInterfaceModelE1000e + if dom.Devices != nil { + for _, iface := range dom.Devices.Interfaces { + model := migration.NetworkInterfaceModelVirtio + if iface.Model != nil { + if iface.Model.Type == "e1000" { + model = migration.NetworkInterfaceModelE1000 + } else if iface.Model.Type == "e1000e" { + model = migration.NetworkInterfaceModelE1000e + } + } + + networkName := "" + if iface.Source != nil { + if iface.Source.Network != nil { + networkName = iface.Source.Network.Network + } else if iface.Source.Bridge != nil { + networkName = iface.Source.Bridge.Bridge + } + } + + macAddr := "" + if iface.MAC != nil { + macAddr = iface.MAC.Address + } + + networkInfos = append(networkInfos, source.NetworkInfo{ + NetworkName: networkName, + MAC: macAddr, + Model: model, + }) } - - networkName := iface.Source.Network - if networkName == "" { - networkName = iface.Source.Bridge - } - - networkInfos = append(networkInfos, source.NetworkInfo{ - NetworkName: networkName, - MAC: iface.Mac.Address, - Model: model, - }) } mappedNetwork := source.MapNetworks(networkInfos, vm.Spec.Mapping) diff --git a/pkg/source/kvm/client_test.go b/pkg/source/kvm/client_test.go index e1ffb851..b9fe050d 100644 --- a/pkg/source/kvm/client_test.go +++ b/pkg/source/kvm/client_test.go @@ -2,11 +2,11 @@ package kvm import ( "context" - "encoding/xml" "testing" "github.com/stretchr/testify/assert" corev1 "k8s.io/api/core/v1" + libvirtxml "libvirt.org/go/libvirtxml" ) func TestParseDomainXML(t *testing.T) { @@ -35,20 +35,20 @@ func TestParseDomainXML(t *testing.T) { ` - var dom Domain - err := xml.Unmarshal([]byte(xmlData), &dom) + var dom libvirtxml.Domain + err := dom.Unmarshal(xmlData) assert.NoError(t, err) assert.Equal(t, "test-vm", dom.Name) assert.Equal(t, "a75aca4b-42f6-4447-9262-4b9562d3d95c", dom.UUID) - assert.Equal(t, 4194304, dom.Memory) - assert.Equal(t, 2, dom.VCPU) + assert.Equal(t, uint(4194304), dom.Memory.Value) + assert.Equal(t, 2, dom.VCPU.Value) assert.Len(t, dom.Devices.Disks, 1) - assert.Equal(t, "/var/lib/libvirt/images/test-vm.qcow2", dom.Devices.Disks[0].Source.File) + assert.Equal(t, "/var/lib/libvirt/images/test-vm.qcow2", dom.Devices.Disks[0].Source.File.File) assert.Equal(t, "qcow2", dom.Devices.Disks[0].Driver.Type) assert.Len(t, dom.Devices.Interfaces, 1) - assert.Equal(t, "52:54:00:6b:3c:58", dom.Devices.Interfaces[0].Mac.Address) - assert.Equal(t, "default", dom.Devices.Interfaces[0].Source.Network) + assert.Equal(t, "52:54:00:6b:3c:58", dom.Devices.Interfaces[0].MAC.Address) + assert.Equal(t, "default", dom.Devices.Interfaces[0].Source.Network.Network) } func TestNewClient(t *testing.T) { @@ -60,9 +60,9 @@ func TestNewClient(t *testing.T) { }, } - // This should fail to dial but pass config validation + // Test with a dummy URI. Since we are using standard ssh.Dial, + // checking valid URI parsing is still useful, even if Dial fails. _, err := NewClient(ctx, "qemu+ssh://user@localhost/system", secret) - // We expect an error because we can't actually connect, but we want to ensure it tries assert.Error(t, err) assert.Contains(t, err.Error(), "failed to dial ssh") } From 5b3181422ee1b31cea1f542e84529a5140c1d400 Mon Sep 17 00:00:00 2001 From: Davide Rutigliano Date: Thu, 18 Dec 2025 20:06:00 +0100 Subject: [PATCH 12/17] Add support for block disks Signed-off-by: Davide Rutigliano --- pkg/source/kvm/client.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/pkg/source/kvm/client.go b/pkg/source/kvm/client.go index e64d0b37..4fa81e09 100644 --- a/pkg/source/kvm/client.go +++ b/pkg/source/kvm/client.go @@ -166,10 +166,18 @@ func (c *Client) ExportVirtualMachine(vm *migration.VirtualMachineImport) error if disk.Device != "disk" { continue } - if disk.Source == nil || disk.Source.File == nil { + var sourceFile string + if disk.Source != nil { + if disk.Source.File != nil { + sourceFile = disk.Source.File.File + } else if disk.Source.Block != nil { + sourceFile = disk.Source.Block.Dev + } + } + + if sourceFile == "" { continue } - sourceFile := disk.Source.File.File // Create a temporary file to store the downloaded disk tmpFile, err := os.CreateTemp("", fmt.Sprintf("%s-disk-%d-", vm.Name, i)) From 7e9e70b6cebfb94015e8ebd61f6d9dc33a1add82 Mon Sep 17 00:00:00 2001 From: Davide Rutigliano Date: Thu, 18 Dec 2025 22:07:46 +0100 Subject: [PATCH 13/17] Add support for KVM CPU model Signed-off-by: Davide Rutigliano --- pkg/source/helper.go | 6 ++++-- pkg/source/kvm/client.go | 10 ++++++++-- pkg/source/kvm/client_test.go | 8 ++++++-- pkg/source/ova/client.go | 2 +- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/pkg/source/helper.go b/pkg/source/helper.go index a8e78bec..b5bab4bc 100644 --- a/pkg/source/helper.go +++ b/pkg/source/helper.go @@ -31,10 +31,11 @@ type Hardware struct { NumCPU uint32 // The type is adapted to KubeVirt CPU NumCoresPerSocket uint32 // The type is adapted to KubeVirt CPU MemoryMB int64 + CPUModel string } -func NewHardware(numCPU, numCoresPerSocket uint32, memoryMB int64) *Hardware { - return &Hardware{NumCPU: numCPU, NumCoresPerSocket: numCoresPerSocket, MemoryMB: memoryMB} +func NewHardware(numCPU, numCoresPerSocket uint32, memoryMB int64, cpuModel string) *Hardware { + return &Hardware{NumCPU: numCPU, NumCoresPerSocket: numCoresPerSocket, MemoryMB: memoryMB, CPUModel: cpuModel} } type VirtualMachineSpecConfig struct { @@ -58,6 +59,7 @@ func NewVirtualMachineSpec(cfg VirtualMachineSpecConfig) *kubevirtv1.VirtualMach Cores: cfg.Hardware.NumCPU, Sockets: cfg.Hardware.NumCoresPerSocket, Threads: 1, + Model: cfg.Hardware.CPUModel, }, Memory: &kubevirtv1.Memory{ Guest: ptr.To(resource.MustParse(fmt.Sprintf("%dM", cfg.Hardware.MemoryMB))), diff --git a/pkg/source/kvm/client.go b/pkg/source/kvm/client.go index 4fa81e09..c9b61a2d 100644 --- a/pkg/source/kvm/client.go +++ b/pkg/source/kvm/client.go @@ -287,11 +287,17 @@ func (c *Client) GenerateVirtualMachine(vm *migration.VirtualMachineImport) (*ku }, } + var cpuModel string + if dom.CPU != nil && dom.CPU.Model != nil { + cpuModel = dom.CPU.Model.Value + } + vmSpec := source.NewVirtualMachineSpec(source.VirtualMachineSpecConfig{ Name: vm.Status.ImportedVirtualMachineName, Hardware: source.Hardware{ - NumCPU: uint32(dom.VCPU.Value), - MemoryMB: int64(dom.Memory.Value / 1024), // XML memory is usually in KiB + NumCPU: uint32(dom.VCPU.Value), + MemoryMB: int64(dom.Memory.Value / 1024), // XML memory is usually in KiB + CPUModel: cpuModel, }, }) diff --git a/pkg/source/kvm/client_test.go b/pkg/source/kvm/client_test.go index b9fe050d..4c30334a 100644 --- a/pkg/source/kvm/client_test.go +++ b/pkg/source/kvm/client_test.go @@ -17,9 +17,12 @@ func TestParseDomainXML(t *testing.T) { 4194304 2 - hvm + hvm + + IvyBridge-IBRS + @@ -42,13 +45,14 @@ func TestParseDomainXML(t *testing.T) { assert.Equal(t, "test-vm", dom.Name) assert.Equal(t, "a75aca4b-42f6-4447-9262-4b9562d3d95c", dom.UUID) assert.Equal(t, uint(4194304), dom.Memory.Value) - assert.Equal(t, 2, dom.VCPU.Value) + assert.Equal(t, uint(2), dom.VCPU.Value) assert.Len(t, dom.Devices.Disks, 1) assert.Equal(t, "/var/lib/libvirt/images/test-vm.qcow2", dom.Devices.Disks[0].Source.File.File) assert.Equal(t, "qcow2", dom.Devices.Disks[0].Driver.Type) assert.Len(t, dom.Devices.Interfaces, 1) assert.Equal(t, "52:54:00:6b:3c:58", dom.Devices.Interfaces[0].MAC.Address) assert.Equal(t, "default", dom.Devices.Interfaces[0].Source.Network.Network) + assert.Equal(t, "IvyBridge-IBRS", dom.CPU.Model.Value) } func TestNewClient(t *testing.T) { diff --git a/pkg/source/ova/client.go b/pkg/source/ova/client.go index f6780314..7c50e3df 100644 --- a/pkg/source/ova/client.go +++ b/pkg/source/ova/client.go @@ -561,7 +561,7 @@ func detectDiskBusType(rasd *ovf.ResourceAllocationSettingData) (kubevirtv1.Disk // parseEnvelope retrieves the firmware, virtual hardware and network settings from the OVF envelope. func parseEnvelope(e *ovf.Envelope, defaultInterfaceModel string, defaultDiskBusType kubevirtv1.DiskBus) (*source.Firmware, *source.Hardware, []source.NetworkInfo, []migration.DiskInfo) { fw := source.NewFirmware(false, false, false) - hw := source.NewHardware(0, 0, 0) + hw := source.NewHardware(0, 0, 0, "") nis := make([]source.NetworkInfo, 0) dis := make([]migration.DiskInfo, 0) From 4728aef0561d8a42f47762834f68a8af10efd70c Mon Sep 17 00:00:00 2001 From: Davide Rutigliano Date: Fri, 19 Dec 2025 08:16:16 +0100 Subject: [PATCH 14/17] Allow DNS subdomains in imported VM names Signed-off-by: Davide Rutigliano --- pkg/source/kvm/client.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/source/kvm/client.go b/pkg/source/kvm/client.go index c9b61a2d..37312c2d 100644 --- a/pkg/source/kvm/client.go +++ b/pkg/source/kvm/client.go @@ -142,7 +142,7 @@ func (c *Client) getDomainXML(vmName string) (*libvirtxml.Domain, error) { } func (c *Client) SanitizeVirtualMachineImport(vm *migration.VirtualMachineImport) error { - vm.Status.ImportedVirtualMachineName = strings.ToLower(vm.Spec.VirtualMachineName) + vm.Status.ImportedVirtualMachineName = strings.Split(strings.ToLower(vm.Spec.VirtualMachineName), ".")[0] return nil } @@ -295,9 +295,9 @@ func (c *Client) GenerateVirtualMachine(vm *migration.VirtualMachineImport) (*ku vmSpec := source.NewVirtualMachineSpec(source.VirtualMachineSpecConfig{ Name: vm.Status.ImportedVirtualMachineName, Hardware: source.Hardware{ - NumCPU: uint32(dom.VCPU.Value), - MemoryMB: int64(dom.Memory.Value / 1024), // XML memory is usually in KiB - CPUModel: cpuModel, + NumCPU: uint32(dom.VCPU.Value), + MemoryMB: int64(dom.Memory.Value / 1024), // XML memory is usually in KiB + CPUModel: cpuModel, }, }) From 73bd8a5fcf5dd68db99ca936a2a41c1a1fe992e0 Mon Sep 17 00:00:00 2001 From: Davide Rutigliano Date: Fri, 19 Dec 2025 09:39:51 +0100 Subject: [PATCH 15/17] Add KVM firmware settings Signed-off-by: Davide Rutigliano --- pkg/source/kvm/client.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pkg/source/kvm/client.go b/pkg/source/kvm/client.go index 37312c2d..5d0e8c39 100644 --- a/pkg/source/kvm/client.go +++ b/pkg/source/kvm/client.go @@ -292,6 +292,15 @@ func (c *Client) GenerateVirtualMachine(vm *migration.VirtualMachineImport) (*ku cpuModel = dom.CPU.Model.Value } + // Firmware settings + fw := source.NewFirmware(false, false, false) + if dom.OS != nil && dom.OS.Loader != nil { + fw.UEFI = true // Presence of a loader usually indicates UEFI + } + if dom.Devices != nil && len(dom.Devices.TPMs) > 0 { + fw.TPM = true + } + vmSpec := source.NewVirtualMachineSpec(source.VirtualMachineSpecConfig{ Name: vm.Status.ImportedVirtualMachineName, Hardware: source.Hardware{ @@ -339,6 +348,9 @@ func (c *Client) GenerateVirtualMachine(vm *migration.VirtualMachineImport) (*ku mappedNetwork := source.MapNetworks(networkInfos, vm.Spec.Mapping) networkConfig, interfaceConfig := source.GenerateNetworkInterfaceConfigs(mappedNetwork, vm.GetDefaultNetworkInterfaceModel()) + // Apply firmware settings + source.ApplyFirmwareSettings(vmSpec, fw) + vmSpec.Template.Spec.Networks = networkConfig vmSpec.Template.Spec.Domain.Devices.Interfaces = interfaceConfig newVM.Spec = *vmSpec From b5b18b883946ced190f67b9585db7074022acacd Mon Sep 17 00:00:00 2001 From: Volker Theile Date: Mon, 26 Jan 2026 16:30:06 +0100 Subject: [PATCH 16/17] Several improvements Signed-off-by: Volker Theile --- .../migration.harvesterhci.io/v1beta1/kvm.go | 35 +++++++- pkg/controllers/migration/kvm.go | 41 +++++---- pkg/controllers/migration/virtualmachine.go | 3 +- .../v1beta1/kvmsource.go | 2 +- pkg/source/helper.go | 6 ++ pkg/source/helper_test.go | 6 +- pkg/source/kvm/client.go | 87 ++++++++++--------- pkg/source/kvm/client_test.go | 6 +- pkg/source/openstack/client.go | 9 +- tests/integration/kvm_test.go | 1 - tests/setup/setup_kvm.go | 4 +- 11 files changed, 118 insertions(+), 82 deletions(-) diff --git a/pkg/apis/migration.harvesterhci.io/v1beta1/kvm.go b/pkg/apis/migration.harvesterhci.io/v1beta1/kvm.go index e270613f..17a1ea16 100644 --- a/pkg/apis/migration.harvesterhci.io/v1beta1/kvm.go +++ b/pkg/apis/migration.harvesterhci.io/v1beta1/kvm.go @@ -1,13 +1,20 @@ package v1beta1 import ( + "time" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" "github.com/harvester/vm-import-controller/pkg/apis/common" ) +const ( + DefaultSSHTimeoutSeconds = 10 +) + // +genclient // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object @@ -19,7 +26,18 @@ type KVMSource struct { } type KVMSourceSpec struct { - LibvirtURI string `json:"libvirtURI"` + // The libvirt connection URI to connect to the KVM host. + // E.g., qemu+ssh://user@hostname/system + LibvirtURI string `json:"libvirtURI"` + + // Additional options. + KVMSourceOptions `json:",inline"` + + // The referenced `Secret` should contain the following keys: + // - username: (optional) The username to authenticate at the specified server. + // - password: (optional) The password to authenticate at the specified server. + // - privateKey: (optional) The private key to authenticate at the specified server. + // One of the authentication fields password or privateKey must be specified. Credentials corev1.SecretReference `json:"credentials"` } @@ -29,6 +47,14 @@ type KVMSourceStatus struct { Conditions []common.Condition `json:"conditions,omitempty"` } +type KVMSourceOptions struct { + // +optional + // Timeout is the maximum amount of time in seconds for the SSH connection + // to establish. A timeout of zero means no timeout. + // Defaults to 10 seconds. + SSHTimeoutSeconds *int `json:"sshTimeoutSeconds,omitempty"` +} + func (s *KVMSource) NamespacedName() string { return types.NamespacedName{ Namespace: s.Namespace, @@ -57,5 +83,10 @@ func (s *KVMSource) GetConnectionInfo() (string, string) { } func (s *KVMSource) GetOptions() interface{} { - return nil + return s.Spec.KVMSourceOptions +} + +// GetSSHTimeout returns the SSH timeout duration. +func (s *KVMSourceOptions) GetSSHTimeout() time.Duration { + return time.Duration(ptr.Deref(s.SSHTimeoutSeconds, DefaultSSHTimeoutSeconds)) * time.Second } diff --git a/pkg/controllers/migration/kvm.go b/pkg/controllers/migration/kvm.go index bcf2197e..2aa668ad 100644 --- a/pkg/controllers/migration/kvm.go +++ b/pkg/controllers/migration/kvm.go @@ -32,37 +32,37 @@ func RegisterKVMController(ctx context.Context, source migrationController.KVMSo source.OnChange(ctx, "kvm-source-change", kHandler.OnSourceChange) } -func (h *kvmHandler) OnSourceChange(_ string, v *migration.KVMSource) (*migration.KVMSource, error) { - if v == nil || v.DeletionTimestamp != nil { +func (h *kvmHandler) OnSourceChange(_ string, s *migration.KVMSource) (*migration.KVMSource, error) { + if s == nil || s.DeletionTimestamp != nil { return nil, nil } logrus.WithFields(logrus.Fields{ - "kind": v.Kind, - "name": v.Name, - "namespace": v.Namespace, + "kind": s.Kind, + "name": s.Name, + "namespace": s.Namespace, }).Info("Reconciling source") - if v.Status.Status != migration.ClusterReady { + if s.Status.Status != migration.ClusterReady { var client *kvm.Client var err error - secretObj, err := h.secret.Get(v.SecretReference().Namespace, v.SecretReference().Name, metav1.GetOptions{}) + secretObj, err := h.secret.Get(s.SecretReference().Namespace, s.SecretReference().Name, metav1.GetOptions{}) if err != nil { - return nil, fmt.Errorf("failed to lookup secret for %s migration %s: %w", v.Kind, v.NamespacedName(), err) + return nil, fmt.Errorf("failed to lookup secret for %s migration %s: %w", s.Kind, s.NamespacedName(), err) } - client, err = kvm.NewClient(h.ctx, v.Spec.LibvirtURI, secretObj) + client, err = kvm.NewClient(h.ctx, s.Spec.LibvirtURI, secretObj, s.GetOptions().(migration.KVMSourceOptions)) if err != nil { - return nil, fmt.Errorf("failed to generate client for %s migration %s: %w", v.Kind, v.NamespacedName(), err) + return nil, fmt.Errorf("failed to generate client for %s migration %s: %w", s.Kind, s.NamespacedName(), err) } - defer client.Close() + defer client.Close() //nolint:errcheck if err := client.Verify(); err != nil { logrus.WithFields(logrus.Fields{ - "kind": v.Kind, - "name": v.Name, - "namespace": v.Namespace, + "kind": s.Kind, + "name": s.Name, + "namespace": s.Namespace, "err": err, }).Error("Failed to verify source for migration") @@ -80,16 +80,15 @@ func (h *kvmHandler) OnSourceChange(_ string, v *migration.KVMSource) (*migratio }, } - v.Status.Conditions = util.MergeConditions(v.Status.Conditions, conds) - v.Status.Status = migration.ClusterNotReady - return h.source.UpdateStatus(v) + s.Status.Conditions = util.MergeConditions(s.Status.Conditions, conds) + s.Status.Status = migration.ClusterNotReady + return h.source.UpdateStatus(s) } // Verify connection (NewClient already dials SSH, but we can run a simple command to be sure) // We can reuse PreFlightChecks logic or just run a simple command // But NewClient already dials, so if it succeeds, we are connected. // Let's just assume ready if NewClient succeeded. - conds := []common.Condition{ { Type: migration.ClusterReadyCondition, @@ -104,10 +103,10 @@ func (h *kvmHandler) OnSourceChange(_ string, v *migration.KVMSource) (*migratio }, } - v.Status.Conditions = util.MergeConditions(v.Status.Conditions, conds) - v.Status.Status = migration.ClusterReady + s.Status.Conditions = util.MergeConditions(s.Status.Conditions, conds) + s.Status.Status = migration.ClusterReady - return h.source.UpdateStatus(v) + return h.source.UpdateStatus(s) } return nil, nil diff --git a/pkg/controllers/migration/virtualmachine.go b/pkg/controllers/migration/virtualmachine.go index 09c5981d..471737d2 100644 --- a/pkg/controllers/migration/virtualmachine.go +++ b/pkg/controllers/migration/virtualmachine.go @@ -569,7 +569,8 @@ func (h *virtualMachineHandler) generateVMO(vm *migration.VirtualMachineImport) return openstack.NewClient(h.ctx, endpoint, region, secret, options) case migration.KindKVMSource: uri, _ := source.GetConnectionInfo() - return kvm.NewClient(h.ctx, uri, secret) + options := source.GetOptions().(migration.KVMSourceOptions) + return kvm.NewClient(h.ctx, uri, secret, options) } return nil, fmt.Errorf("source kind %q not supported", source.GetKind()) diff --git a/pkg/generated/controllers/migration.harvesterhci.io/v1beta1/kvmsource.go b/pkg/generated/controllers/migration.harvesterhci.io/v1beta1/kvmsource.go index deb09c76..1cc8ccd0 100644 --- a/pkg/generated/controllers/migration.harvesterhci.io/v1beta1/kvmsource.go +++ b/pkg/generated/controllers/migration.harvesterhci.io/v1beta1/kvmsource.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Rancher Labs, Inc. +Copyright 2026 SUSE, LLC. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/pkg/source/helper.go b/pkg/source/helper.go index b5bab4bc..1ea06c05 100644 --- a/pkg/source/helper.go +++ b/pkg/source/helper.go @@ -128,3 +128,9 @@ func RemoveTempImageFiles(dis []migration.DiskInfo) error { return nil } + +// GenerateRawImageFileName Generate the raw image file name based on the VM name and +// index of the attached volume. +func GenerateRawImageFileName(vmName string, index int) string { + return fmt.Sprintf("%s-%d.img", vmName, index) +} diff --git a/pkg/source/helper_test.go b/pkg/source/helper_test.go index 05ed2c11..8dd94bb5 100644 --- a/pkg/source/helper_test.go +++ b/pkg/source/helper_test.go @@ -77,7 +77,7 @@ func Test_NewVirtualMachineSpec(t *testing.T) { desc: "Basic configuration", config: VirtualMachineSpecConfig{ Name: "basic-vm", - Hardware: *NewHardware(4, 2, 8192), + Hardware: *NewHardware(4, 2, 8192, ""), }, expectedCPUCores: 4, expectedCPUSockets: 2, @@ -87,7 +87,7 @@ func Test_NewVirtualMachineSpec(t *testing.T) { desc: "High CPU and memory configuration", config: VirtualMachineSpecConfig{ Name: "high-performance-vm", - Hardware: *NewHardware(64, 32, 65536), + Hardware: *NewHardware(64, 32, 65536, ""), }, expectedCPUCores: 64, expectedCPUSockets: 32, @@ -97,7 +97,7 @@ func Test_NewVirtualMachineSpec(t *testing.T) { desc: "Minimal hardware configuration", config: VirtualMachineSpecConfig{ Name: "minimal-vm", - Hardware: *NewHardware(1, 1, 512), + Hardware: *NewHardware(1, 1, 512, ""), }, expectedCPUCores: 1, expectedCPUSockets: 1, diff --git a/pkg/source/kvm/client.go b/pkg/source/kvm/client.go index 5d0e8c39..915ffc38 100644 --- a/pkg/source/kvm/client.go +++ b/pkg/source/kvm/client.go @@ -5,12 +5,11 @@ import ( "context" "fmt" "io" - "net" "net/url" "os" + "os/exec" "path/filepath" "strings" - "time" "github.com/pkg/sftp" "github.com/sirupsen/logrus" @@ -32,10 +31,10 @@ type Client struct { libvirtURI string } -func NewClient(ctx context.Context, libvirtURI string, secret *corev1.Secret) (*Client, error) { +func NewClient(ctx context.Context, libvirtURI string, secret *corev1.Secret, options migration.KVMSourceOptions) (*Client, error) { u, err := url.Parse(libvirtURI) if err != nil { - return nil, fmt.Errorf("invalid libvirt URI: %v", err) + return nil, fmt.Errorf("invalid libvirt URI: %w", err) } // Expected URI format: qemu+ssh://user@host/system or just ssh://user@host @@ -55,7 +54,7 @@ func NewClient(ctx context.Context, libvirtURI string, secret *corev1.Secret) (* if privateKey, ok := secret.Data["privateKey"]; ok { signer, err := ssh.ParsePrivateKey(privateKey) if err != nil { - return nil, fmt.Errorf("failed to parse private key: %v", err) + return nil, fmt.Errorf("failed to parse private key: %w", err) } authMethods = append(authMethods, ssh.PublicKeys(signer)) } @@ -67,13 +66,11 @@ func NewClient(ctx context.Context, libvirtURI string, secret *corev1.Secret) (* return nil, fmt.Errorf("no authentication methods provided in secret") } - config := &ssh.ClientConfig{ - User: user, - Auth: authMethods, - HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error { - return nil - }, - Timeout: 10 * time.Second, + sshClientConfig := &ssh.ClientConfig{ + User: user, + Auth: authMethods, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), // nolint:gosec + Timeout: options.GetSSHTimeout(), } // Handle port if missing @@ -81,14 +78,14 @@ func NewClient(ctx context.Context, libvirtURI string, secret *corev1.Secret) (* host = host + ":22" } - client, err := ssh.Dial("tcp", host, config) + sshClient, err := ssh.Dial("tcp", host, sshClientConfig) if err != nil { - return nil, fmt.Errorf("failed to dial ssh: %v", err) + return nil, fmt.Errorf("failed to dial host %s: %w", host, err) } return &Client{ ctx: ctx, - sshClient: client, + sshClient: sshClient, libvirtURI: libvirtURI, }, nil } @@ -101,16 +98,17 @@ func (c *Client) Close() error { } func (c *Client) Verify() error { - // We run a simple command to verify that the connection is working and we can talk to libvirt. + // We run a simple command to verify that the connection is working, and we can talk to libvirt. // "virsh list --name" is lightweight and lists running domains. - _, err := c.runCommand("virsh list --name") + _, err := c.runCommand([]string{"list", "--name"}) if err != nil { - return fmt.Errorf("failed to verify connection to libvirt: %v", err) + return fmt.Errorf("failed to verify connection to %s: %w", c.libvirtURI, err) } + logrus.Infof("Connection verified to %s", c.libvirtURI) return nil } -func (c *Client) runCommand(cmd string) (string, error) { +func (c *Client) runCommand(args []string) (string, error) { session, err := c.sshClient.NewSession() if err != nil { return "", err @@ -121,7 +119,10 @@ func (c *Client) runCommand(cmd string) (string, error) { session.Stdout = &stdout session.Stderr = &stderr - err = session.Run(cmd) + args = append([]string{"-c", c.libvirtURI}, args...) + cmd := exec.Command("virsh", args...) + + err = session.Run(cmd.String()) if err != nil { return "", fmt.Errorf("command %q failed: %v, stderr: %s", cmd, err, stderr.String()) } @@ -129,14 +130,14 @@ func (c *Client) runCommand(cmd string) (string, error) { } func (c *Client) getDomainXML(vmName string) (*libvirtxml.Domain, error) { - out, err := c.runCommand(fmt.Sprintf("virsh dumpxml %s", vmName)) + out, err := c.runCommand([]string{"dumpxml", vmName}) if err != nil { return nil, err } var dom libvirtxml.Domain if err := dom.Unmarshal(out); err != nil { - return nil, fmt.Errorf("failed to unmarshal domain xml: %v", err) + return nil, fmt.Errorf("failed to unmarshal domain xml: %w", err) } return &dom, nil } @@ -154,7 +155,7 @@ func (c *Client) ExportVirtualMachine(vm *migration.VirtualMachineImport) error sftpClient, err := sftp.NewClient(c.sshClient) if err != nil { - return fmt.Errorf("failed to create sftp client: %v", err) + return fmt.Errorf("failed to create sftp client: %w", err) } defer sftpClient.Close() @@ -182,7 +183,7 @@ func (c *Client) ExportVirtualMachine(vm *migration.VirtualMachineImport) error // Create a temporary file to store the downloaded disk tmpFile, err := os.CreateTemp("", fmt.Sprintf("%s-disk-%d-", vm.Name, i)) if err != nil { - return fmt.Errorf("failed to create temporary file for download: %v", err) + return fmt.Errorf("failed to create temporary file for download: %w", err) } defer os.Remove(tmpFile.Name()) @@ -191,18 +192,18 @@ func (c *Client) ExportVirtualMachine(vm *migration.VirtualMachineImport) error // Open the remote file remoteFile, err := sftpClient.Open(sourceFile) if err != nil { - return fmt.Errorf("failed to open remote file %s: %v", sourceFile, err) + return fmt.Errorf("failed to open remote file %s: %w", sourceFile, err) } defer remoteFile.Close() // Copy the remote file to the temporary local file if _, err := io.Copy(tmpFile, remoteFile); err != nil { - return fmt.Errorf("failed to download remote file: %v", err) + return fmt.Errorf("failed to download remote file: %w", err) } tmpFile.Close() // Close the file so qemu-img can access it // Local destination path for the converted RAW image - rawDiskName := fmt.Sprintf("%s-disk-%d.img", vm.Name, i) + rawDiskName := source.GenerateRawImageFileName(vm.Name, i) destFile := filepath.Join(server.TempDir(), rawDiskName) logrus.Infof("Converting downloaded disk %s to %s", tmpFile.Name(), destFile) @@ -214,15 +215,16 @@ func (c *Client) ExportVirtualMachine(vm *migration.VirtualMachineImport) error } if err := qemu.ConvertFromPath(tmpFile.Name(), destFile, srcFormat); err != nil { - return fmt.Errorf("qemu convert failed: %v", err) + return fmt.Errorf("qemu convert failed: %w", err) } // Update status busType := kubevirt.DiskBusVirtio if disk.Target != nil { - if disk.Target.Bus == "sata" || disk.Target.Bus == "ide" { + switch disk.Target.Bus { + case "sata", "ide": busType = kubevirt.DiskBusSATA - } else if disk.Target.Bus == "scsi" { + case "scsi": busType = kubevirt.DiskBusSCSI } } @@ -230,7 +232,7 @@ func (c *Client) ExportVirtualMachine(vm *migration.VirtualMachineImport) error // Get the size of the converted image destFileInfo, err := os.Stat(destFile) if err != nil { - return fmt.Errorf("failed to get stats for converted disk: %v", err) + return fmt.Errorf("failed to get stats for converted disk: %w", err) } vm.Status.DiskImportStatus = append(vm.Status.DiskImportStatus, migration.DiskInfo{ @@ -253,12 +255,12 @@ func (c *Client) ShutdownGuest(vm *migration.VirtualMachineImport) error { logrus.Infof("VM %s is already powered off, skipping shutdown.", vm.Spec.VirtualMachineName) return nil } - _, err = c.runCommand(fmt.Sprintf("virsh shutdown %s", vm.Spec.VirtualMachineName)) + _, err = c.runCommand([]string{"shutdown", vm.Spec.VirtualMachineName}) return err } func (c *Client) PowerOff(vm *migration.VirtualMachineImport) error { - _, err := c.runCommand(fmt.Sprintf("virsh destroy %s", vm.Spec.VirtualMachineName)) + _, err := c.runCommand([]string{"destroy", vm.Spec.VirtualMachineName}) return err } @@ -267,7 +269,7 @@ func (c *Client) IsPowerOffSupported() bool { } func (c *Client) IsPoweredOff(vm *migration.VirtualMachineImport) (bool, error) { - out, err := c.runCommand(fmt.Sprintf("virsh domstate %s", vm.Spec.VirtualMachineName)) + out, err := c.runCommand([]string{"domstate", vm.Spec.VirtualMachineName}) if err != nil { return false, err } @@ -287,7 +289,7 @@ func (c *Client) GenerateVirtualMachine(vm *migration.VirtualMachineImport) (*ku }, } - var cpuModel string + cpuModel := "" if dom.CPU != nil && dom.CPU.Model != nil { cpuModel = dom.CPU.Model.Value } @@ -295,7 +297,7 @@ func (c *Client) GenerateVirtualMachine(vm *migration.VirtualMachineImport) (*ku // Firmware settings fw := source.NewFirmware(false, false, false) if dom.OS != nil && dom.OS.Loader != nil { - fw.UEFI = true // Presence of a loader usually indicates UEFI + fw.UEFI = true // The presence of a loader usually indicates UEFI } if dom.Devices != nil && len(dom.Devices.TPMs) > 0 { fw.TPM = true @@ -304,8 +306,8 @@ func (c *Client) GenerateVirtualMachine(vm *migration.VirtualMachineImport) (*ku vmSpec := source.NewVirtualMachineSpec(source.VirtualMachineSpecConfig{ Name: vm.Status.ImportedVirtualMachineName, Hardware: source.Hardware{ - NumCPU: uint32(dom.VCPU.Value), - MemoryMB: int64(dom.Memory.Value / 1024), // XML memory is usually in KiB + NumCPU: uint32(dom.VCPU.Value), // nolint:gosec + MemoryMB: int64(dom.Memory.Value / 1024), // nolint:gosec // XML memory is usually in KiB CPUModel: cpuModel, }, }) @@ -316,9 +318,10 @@ func (c *Client) GenerateVirtualMachine(vm *migration.VirtualMachineImport) (*ku for _, iface := range dom.Devices.Interfaces { model := migration.NetworkInterfaceModelVirtio if iface.Model != nil { - if iface.Model.Type == "e1000" { + switch iface.Model.Type { + case "e1000": model = migration.NetworkInterfaceModelE1000 - } else if iface.Model.Type == "e1000e" { + case "e1000e": model = migration.NetworkInterfaceModelE1000e } } @@ -360,9 +363,9 @@ func (c *Client) GenerateVirtualMachine(vm *migration.VirtualMachineImport) (*ku func (c *Client) PreFlightChecks(vm *migration.VirtualMachineImport) error { // Check if VM exists - _, err := c.runCommand(fmt.Sprintf("virsh dominfo %s", vm.Spec.VirtualMachineName)) + _, err := c.runCommand([]string{"dominfo", vm.Spec.VirtualMachineName}) if err != nil { - return fmt.Errorf("failed to find VM %s: %v", vm.Spec.VirtualMachineName, err) + return fmt.Errorf("failed to find VM %s: %w", vm.Spec.VirtualMachineName, err) } return nil } diff --git a/pkg/source/kvm/client_test.go b/pkg/source/kvm/client_test.go index 4c30334a..8d530039 100644 --- a/pkg/source/kvm/client_test.go +++ b/pkg/source/kvm/client_test.go @@ -7,6 +7,8 @@ import ( "github.com/stretchr/testify/assert" corev1 "k8s.io/api/core/v1" libvirtxml "libvirt.org/go/libvirtxml" + + migration "github.com/harvester/vm-import-controller/pkg/apis/migration.harvesterhci.io/v1beta1" ) func TestParseDomainXML(t *testing.T) { @@ -66,7 +68,7 @@ func TestNewClient(t *testing.T) { // Test with a dummy URI. Since we are using standard ssh.Dial, // checking valid URI parsing is still useful, even if Dial fails. - _, err := NewClient(ctx, "qemu+ssh://user@localhost/system", secret) + _, err := NewClient(ctx, "qemu+ssh://user@localhost/system", secret, migration.KVMSourceOptions{}) assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to dial ssh") + assert.Contains(t, err.Error(), "failed to dial host localhost:22") } diff --git a/pkg/source/openstack/client.go b/pkg/source/openstack/client.go index 7b0fe07e..7aed3925 100644 --- a/pkg/source/openstack/client.go +++ b/pkg/source/openstack/client.go @@ -440,7 +440,7 @@ func (c *Client) ExportVirtualMachine(vm *migration.VirtualMachineImport) error return fmt.Errorf("error downloading image %s: %w", volumeImage.ImageID, err) } - rawImageFileName := generateRawImageFileName(vm.Status.ImportedVirtualMachineName, index) + rawImageFileName := source.GenerateRawImageFileName(vm.Status.ImportedVirtualMachineName, index) logrus.WithFields(logrus.Fields{ "name": vm.Name, @@ -539,7 +539,7 @@ func (c *Client) GenerateVirtualMachine(vm *migration.VirtualMachineImport) (*ku fw, err := c.getFirmwareSettings(&vmObj.Server) if err != nil { - return nil, fmt.Errorf("error getting firware settings: %w", err) + return nil, fmt.Errorf("error getting firmware settings: %w", err) } networkInfos, err := generateNetworkInfos(vmObj.Addresses, vm.GetDefaultNetworkInterfaceModel()) @@ -823,8 +823,3 @@ func writeRawImageFile(name string, src io.ReadCloser) error { _, err = io.Copy(dst, src) return err } - -// generateRawImageFileName Generate the raw image file name based on the VM name and index of the attached volume. -func generateRawImageFileName(vmName string, index int) string { - return fmt.Sprintf("%s-%d.img", vmName, index) -} diff --git a/tests/integration/kvm_test.go b/tests/integration/kvm_test.go index db292f44..90d68817 100644 --- a/tests/integration/kvm_test.go +++ b/tests/integration/kvm_test.go @@ -14,7 +14,6 @@ import ( ) var _ = Describe("test kvm export/import integration", func() { - BeforeEach(func() { if !useExisting { return diff --git a/tests/setup/setup_kvm.go b/tests/setup/setup_kvm.go index 212b7568..8761daa1 100644 --- a/tests/setup/setup_kvm.go +++ b/tests/setup/setup_kvm.go @@ -27,9 +27,9 @@ var ( KVMVMNamespacedName types.NamespacedName ) -// SetupKVM will try and setup a kvm migration based on environment variables +// SetupKVM will try and set up a KVM migration based on environment variables. // It will check the following environment variables to build migration and importjob CRD's -// KVM_LIBVIRT_URI: Identify libvirt endpoint (e.g. qemu+ssh://user@host/system) +// KVM_LIBVIRT_URI: Identify libvirt endpoint (e.g., qemu+ssh://user@host/system) // KVM_SSH_USER: Username for migration secret // KVM_SSH_PASSWORD: Password for migration secret // SVC_ADDRESS: local machine address, used to generate the URL that Harvester downloads the exported images from From 2b0f7a150fcb51f4d689aacbb965beb2471ea8e5 Mon Sep 17 00:00:00 2001 From: Volker Theile Date: Thu, 29 Jan 2026 10:35:27 +0100 Subject: [PATCH 17/17] Refactor configuration options Signed-off-by: Volker Theile --- .../migration.harvesterhci.io/v1beta1/kvm.go | 26 ++++++-- .../v1beta1/zz_generated_deepcopy.go | 29 ++++++++- pkg/controllers/migration/kvm.go | 2 +- pkg/controllers/migration/virtualmachine.go | 4 +- pkg/qemu/gemu_test.go | 2 +- pkg/qemu/qemu.go | 14 +---- pkg/source/kvm/client.go | 59 +++++++++++-------- pkg/source/kvm/client_test.go | 4 +- pkg/source/ova/client.go | 2 +- pkg/source/vmware/client.go | 4 +- tests/setup/setup_kvm.go | 6 +- 11 files changed, 95 insertions(+), 57 deletions(-) diff --git a/pkg/apis/migration.harvesterhci.io/v1beta1/kvm.go b/pkg/apis/migration.harvesterhci.io/v1beta1/kvm.go index 17a1ea16..e10efeb3 100644 --- a/pkg/apis/migration.harvesterhci.io/v1beta1/kvm.go +++ b/pkg/apis/migration.harvesterhci.io/v1beta1/kvm.go @@ -12,7 +12,8 @@ import ( ) const ( - DefaultSSHTimeoutSeconds = 10 + DefaultSSHTimeoutSeconds = 30 + DefaultVirshConnectionURI = "" ) // +genclient @@ -26,9 +27,10 @@ type KVMSource struct { } type KVMSourceSpec struct { - // The libvirt connection URI to connect to the KVM host. - // E.g., qemu+ssh://user@hostname/system - LibvirtURI string `json:"libvirtURI"` + // The SSH host URI to connect to. If no port is specified, then default + // SSH port 22 will be used. + // E.g., `ssh://user@hostname:2222` + EndpointAddress string `json:"endpoint"` // Additional options. KVMSourceOptions `json:",inline"` @@ -51,8 +53,15 @@ type KVMSourceOptions struct { // +optional // Timeout is the maximum amount of time in seconds for the SSH connection // to establish. A timeout of zero means no timeout. - // Defaults to 10 seconds. + // Defaults to 30 seconds. SSHTimeoutSeconds *int `json:"sshTimeoutSeconds,omitempty"` + + // +optional + // The connection URI to be used by the `virsh` command that is executed on + // the host specified by the endpoint address. + // E.g., `qemu:///system` + // See https://libvirt.org/uri.html#local-hypervisor-uris for more information. + VirshConnectionURI *string `json:"virshConnectionURI"` } func (s *KVMSource) NamespacedName() string { @@ -79,7 +88,7 @@ func (s *KVMSource) GetKind() string { } func (s *KVMSource) GetConnectionInfo() (string, string) { - return s.Spec.LibvirtURI, "" + return s.Spec.EndpointAddress, "" } func (s *KVMSource) GetOptions() interface{} { @@ -90,3 +99,8 @@ func (s *KVMSource) GetOptions() interface{} { func (s *KVMSourceOptions) GetSSHTimeout() time.Duration { return time.Duration(ptr.Deref(s.SSHTimeoutSeconds, DefaultSSHTimeoutSeconds)) * time.Second } + +// GetVirshConnectionURI returns the virsh connection URI. +func (s *KVMSourceOptions) GetVirshConnectionURI() string { + return ptr.Deref(s.VirshConnectionURI, DefaultVirshConnectionURI) +} diff --git a/pkg/apis/migration.harvesterhci.io/v1beta1/zz_generated_deepcopy.go b/pkg/apis/migration.harvesterhci.io/v1beta1/zz_generated_deepcopy.go index 15e5368a..e26d7feb 100644 --- a/pkg/apis/migration.harvesterhci.io/v1beta1/zz_generated_deepcopy.go +++ b/pkg/apis/migration.harvesterhci.io/v1beta1/zz_generated_deepcopy.go @@ -54,7 +54,7 @@ func (in *KVMSource) DeepCopyInto(out *KVMSource) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) in.Status.DeepCopyInto(&out.Status) return } @@ -110,9 +110,36 @@ func (in *KVMSourceList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KVMSourceOptions) DeepCopyInto(out *KVMSourceOptions) { + *out = *in + if in.SSHTimeoutSeconds != nil { + in, out := &in.SSHTimeoutSeconds, &out.SSHTimeoutSeconds + *out = new(int) + **out = **in + } + if in.VirshConnectionURI != nil { + in, out := &in.VirshConnectionURI, &out.VirshConnectionURI + *out = new(string) + **out = **in + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KVMSourceOptions. +func (in *KVMSourceOptions) DeepCopy() *KVMSourceOptions { + if in == nil { + return nil + } + out := new(KVMSourceOptions) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *KVMSourceSpec) DeepCopyInto(out *KVMSourceSpec) { *out = *in + in.KVMSourceOptions.DeepCopyInto(&out.KVMSourceOptions) out.Credentials = in.Credentials return } diff --git a/pkg/controllers/migration/kvm.go b/pkg/controllers/migration/kvm.go index 2aa668ad..ecdbadc0 100644 --- a/pkg/controllers/migration/kvm.go +++ b/pkg/controllers/migration/kvm.go @@ -52,7 +52,7 @@ func (h *kvmHandler) OnSourceChange(_ string, s *migration.KVMSource) (*migratio return nil, fmt.Errorf("failed to lookup secret for %s migration %s: %w", s.Kind, s.NamespacedName(), err) } - client, err = kvm.NewClient(h.ctx, s.Spec.LibvirtURI, secretObj, s.GetOptions().(migration.KVMSourceOptions)) + client, err = kvm.NewClient(h.ctx, s.Spec.EndpointAddress, secretObj, s.GetOptions().(migration.KVMSourceOptions)) if err != nil { return nil, fmt.Errorf("failed to generate client for %s migration %s: %w", s.Kind, s.NamespacedName(), err) } diff --git a/pkg/controllers/migration/virtualmachine.go b/pkg/controllers/migration/virtualmachine.go index 471737d2..bff7de41 100644 --- a/pkg/controllers/migration/virtualmachine.go +++ b/pkg/controllers/migration/virtualmachine.go @@ -568,9 +568,9 @@ func (h *virtualMachineHandler) generateVMO(vm *migration.VirtualMachineImport) options := source.GetOptions().(migration.OpenstackSourceOptions) return openstack.NewClient(h.ctx, endpoint, region, secret, options) case migration.KindKVMSource: - uri, _ := source.GetConnectionInfo() + endpoint, _ := source.GetConnectionInfo() options := source.GetOptions().(migration.KVMSourceOptions) - return kvm.NewClient(h.ctx, uri, secret, options) + return kvm.NewClient(h.ctx, endpoint, secret, options) } return nil, fmt.Errorf("source kind %q not supported", source.GetKind()) diff --git a/pkg/qemu/gemu_test.go b/pkg/qemu/gemu_test.go index 63fc6ab8..82ecd566 100644 --- a/pkg/qemu/gemu_test.go +++ b/pkg/qemu/gemu_test.go @@ -17,7 +17,7 @@ func Test_ConvertVMDKToRaw(t *testing.T) { err = createVMDK(tmpVMDK, "512M") assert.NoError(err, "expected no error during tmp vmdk creation") destRaw := filepath.Join(tmpDir, "vmdktest.img") - err = ConvertVMDKtoRAW(tmpVMDK, destRaw) + err = ConvertToRAW(tmpVMDK, destRaw, "vmdk") assert.NoError(err, "expected no error during disk conversion") f, err := os.Stat(destRaw) assert.NoError(err, "expected no error during check for raw file") diff --git a/pkg/qemu/qemu.go b/pkg/qemu/qemu.go index 5f2f6a5e..d5cc4086 100644 --- a/pkg/qemu/qemu.go +++ b/pkg/qemu/qemu.go @@ -10,22 +10,12 @@ import ( const defaultCommand = "qemu-wrapper.sh" -func ConvertVMDKtoRAW(source, target string) error { - logrus.WithFields(logrus.Fields{ - "source": source, - "target": target, - }).Info("Converting VMDK image to RAW ...") - args := []string{"convert", "-f", "vmdk", "-O", "raw", source, target} - return runCommand(defaultCommand, args...) -} - -func ConvertFromPath(source, target, format string) error { +func ConvertToRAW(source, target string, format string) error { logrus.WithFields(logrus.Fields{ "source": source, "target": target, "format": format, - }).Info("Converting image from path to RAW ...") - // qemu-img convert -f -O raw + }).Info("Converting VMDK image ...") args := []string{"convert", "-f", format, "-O", "raw", source, target} return runCommand(defaultCommand, args...) } diff --git a/pkg/source/kvm/client.go b/pkg/source/kvm/client.go index 915ffc38..617007ed 100644 --- a/pkg/source/kvm/client.go +++ b/pkg/source/kvm/client.go @@ -26,27 +26,24 @@ import ( ) type Client struct { - ctx context.Context - sshClient *ssh.Client - libvirtURI string + ctx context.Context + options migration.KVMSourceOptions + sshClient *ssh.Client } -func NewClient(ctx context.Context, libvirtURI string, secret *corev1.Secret, options migration.KVMSourceOptions) (*Client, error) { - u, err := url.Parse(libvirtURI) +func NewClient(ctx context.Context, endpoint string, secret *corev1.Secret, options migration.KVMSourceOptions) (*Client, error) { + endpointURL, err := url.Parse(endpoint) if err != nil { - return nil, fmt.Errorf("invalid libvirt URI: %w", err) + return nil, fmt.Errorf("error parsing endpoint URL: %w", err) } - // Expected URI format: qemu+ssh://user@host/system or just ssh://user@host - // We extract host and user from the URI. - host := u.Host - user := u.User.Username() + host := endpointURL.Host + user := endpointURL.User.Username() if user == "" { - // Try to get user from secret if not in URI if secretUser, ok := secret.Data["username"]; ok { user = string(secretUser) } else { - return nil, fmt.Errorf("username not found in URI or secret") + return nil, fmt.Errorf("username not found in endpoint URL or secret") } } @@ -73,20 +70,24 @@ func NewClient(ctx context.Context, libvirtURI string, secret *corev1.Secret, op Timeout: options.GetSSHTimeout(), } - // Handle port if missing if !strings.Contains(host, ":") { - host = host + ":22" + host = fmt.Sprintf("%s:22", host) } + logrus.WithFields(logrus.Fields{ + "host": host, + "user": user, + "timeout": options.GetSSHTimeout().String(), + }).Info("Dialing endpoint ...") sshClient, err := ssh.Dial("tcp", host, sshClientConfig) if err != nil { - return nil, fmt.Errorf("failed to dial host %s: %w", host, err) + return nil, fmt.Errorf("failed to dial endpoint: %w", err) } return &Client{ - ctx: ctx, - sshClient: sshClient, - libvirtURI: libvirtURI, + ctx: ctx, + options: options, + sshClient: sshClient, }, nil } @@ -102,9 +103,9 @@ func (c *Client) Verify() error { // "virsh list --name" is lightweight and lists running domains. _, err := c.runCommand([]string{"list", "--name"}) if err != nil { - return fmt.Errorf("failed to verify connection to %s: %w", c.libvirtURI, err) + return fmt.Errorf("failed to verify connection to %s: %w", c.sshClient.RemoteAddr().String(), err) } - logrus.Infof("Connection verified to %s", c.libvirtURI) + logrus.Infof("Connection verified to %s", c.sshClient.RemoteAddr().String()) return nil } @@ -119,13 +120,19 @@ func (c *Client) runCommand(args []string) (string, error) { session.Stdout = &stdout session.Stderr = &stderr - args = append([]string{"-c", c.libvirtURI}, args...) + uri := c.options.GetVirshConnectionURI() + if uri != "" { + args = append([]string{"-c", uri}, args...) + } + cmd := exec.Command("virsh", args...) + cmdLine := cmd.String() - err = session.Run(cmd.String()) + err = session.Run(cmdLine) if err != nil { - return "", fmt.Errorf("command %q failed: %v, stderr: %s", cmd, err, stderr.String()) + return "", fmt.Errorf("command %q failed: %v, stderr: %s", cmdLine, err, stderr.String()) } + return stdout.String(), nil } @@ -209,12 +216,12 @@ func (c *Client) ExportVirtualMachine(vm *migration.VirtualMachineImport) error logrus.Infof("Converting downloaded disk %s to %s", tmpFile.Name(), destFile) // Use qemu-img convert on the local, downloaded file - srcFormat := "qcow2" // Default assumption + format := "qcow2" // Default assumption if disk.Driver != nil && disk.Driver.Type != "" { - srcFormat = disk.Driver.Type + format = disk.Driver.Type } - if err := qemu.ConvertFromPath(tmpFile.Name(), destFile, srcFormat); err != nil { + if err := qemu.ConvertToRAW(tmpFile.Name(), destFile, format); err != nil { return fmt.Errorf("qemu convert failed: %w", err) } diff --git a/pkg/source/kvm/client_test.go b/pkg/source/kvm/client_test.go index 8d530039..944a5f56 100644 --- a/pkg/source/kvm/client_test.go +++ b/pkg/source/kvm/client_test.go @@ -68,7 +68,7 @@ func TestNewClient(t *testing.T) { // Test with a dummy URI. Since we are using standard ssh.Dial, // checking valid URI parsing is still useful, even if Dial fails. - _, err := NewClient(ctx, "qemu+ssh://user@localhost/system", secret, migration.KVMSourceOptions{}) + _, err := NewClient(ctx, "ssh://user@localhost:2222", secret, migration.KVMSourceOptions{}) assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to dial host localhost:22") + assert.Contains(t, err.Error(), "failed to dial endpoint") } diff --git a/pkg/source/ova/client.go b/pkg/source/ova/client.go index 7c50e3df..3a208d15 100644 --- a/pkg/source/ova/client.go +++ b/pkg/source/ova/client.go @@ -414,7 +414,7 @@ func (c *Client) extractAndConvertVMDKToRAW(archivePath, name, dstPath string, c } if convert { - err = qemu.ConvertVMDKtoRAW(vmdkFile.Name(), dstPath) + err = qemu.ConvertToRAW(vmdkFile.Name(), dstPath, "vmdk") if err != nil { return fmt.Errorf("failed to convert VMDK file %q to RAW %q: %w", vmdkFile.Name(), dstPath, err) } diff --git a/pkg/source/vmware/client.go b/pkg/source/vmware/client.go index ec270234..b4ee5413 100644 --- a/pkg/source/vmware/client.go +++ b/pkg/source/vmware/client.go @@ -57,7 +57,7 @@ func NewClient(ctx context.Context, endpoint string, dc string, secret *corev1.S endpointURL, err := url.Parse(endpoint) if err != nil { - return nil, fmt.Errorf("error parsing endpoint url: %v", err) + return nil, fmt.Errorf("error parsing endpoint URL: %v", err) } sc := soap.NewClient(endpointURL, insecure) @@ -292,7 +292,7 @@ func (c *Client) ExportVirtualMachine(vm *migration.VirtualMachineImport) (err e rawDiskName := util.BaseName(d.Name) + ".img" destFile := filepath.Join(server.TempDir(), rawDiskName) - err = qemu.ConvertVMDKtoRAW(sourceFile, destFile) + err = qemu.ConvertToRAW(sourceFile, destFile, "vmdk") if err != nil { return fmt.Errorf("error during conversion of VMDK to RAW disk: %v", err) } diff --git a/tests/setup/setup_kvm.go b/tests/setup/setup_kvm.go index 8761daa1..8614c889 100644 --- a/tests/setup/setup_kvm.go +++ b/tests/setup/setup_kvm.go @@ -100,9 +100,9 @@ func setupKVMSecret(ctx context.Context, k8sClient client.Client) error { } func setupKVMSource(ctx context.Context, k8sClient client.Client) error { - endpoint, ok := os.LookupEnv("KVM_LIBVIRT_URI") + endpoint, ok := os.LookupEnv("KVM_URL") if !ok { - return fmt.Errorf("env variable KVM_LIBVIRT_URI not set") + return fmt.Errorf("env variable KVM_URL not set") } s := &migration.KVMSource{ @@ -111,7 +111,7 @@ func setupKVMSource(ctx context.Context, k8sClient client.Client) error { Namespace: kvmDefaultNamespace, }, Spec: migration.KVMSourceSpec{ - LibvirtURI: endpoint, + EndpointAddress: endpoint, Credentials: corev1.SecretReference{ Name: kvmSecret, Namespace: kvmDefaultNamespace,