diff --git a/.gitignore b/.gitignore index e20f8611..a27498f1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ *.swp .idea *.DS_Store +.env diff --git a/README.md b/README.md index bd372696..c43c3a3b 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. @@ -131,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: @@ -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 diff --git a/go.mod b/go.mod index 922fba2d..c5b5fc9e 100644 --- a/go.mod +++ b/go.mod @@ -13,11 +13,13 @@ 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.41.0 golang.org/x/sync v0.16.0 k8s.io/api v0.33.1 k8s.io/apiextensions-apiserver v0.33.1 @@ -26,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 ) @@ -75,6 +78,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 @@ -112,13 +116,12 @@ 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 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..ff28ded8 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= @@ -747,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/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/apis/migration.harvesterhci.io/v1beta1/kvm.go b/pkg/apis/migration.harvesterhci.io/v1beta1/kvm.go new file mode 100644 index 00000000..e10efeb3 --- /dev/null +++ b/pkg/apis/migration.harvesterhci.io/v1beta1/kvm.go @@ -0,0 +1,106 @@ +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 = 30 + DefaultVirshConnectionURI = "" +) + +// +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 { + // 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"` + + // 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"` +} + +type KVMSourceStatus struct { + Status ClusterStatus `json:"status,omitempty"` + // +optional + 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 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 { + 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.EndpointAddress, "" +} + +func (s *KVMSource) GetOptions() interface{} { + 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 +} + +// 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 9f2e162e..e26d7feb 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,132 @@ 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) + in.Spec.DeepCopyInto(&out.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 + 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 +} + +// 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/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..ecdbadc0 --- /dev/null +++ b/pkg/controllers/migration/kvm.go @@ -0,0 +1,113 @@ +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, s *migration.KVMSource) (*migration.KVMSource, error) { + if s == nil || s.DeletionTimestamp != nil { + return nil, nil + } + + logrus.WithFields(logrus.Fields{ + "kind": s.Kind, + "name": s.Name, + "namespace": s.Namespace, + }).Info("Reconciling source") + + if s.Status.Status != migration.ClusterReady { + var client *kvm.Client + var err error + + 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", s.Kind, s.NamespacedName(), err) + } + + 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) + } + defer client.Close() //nolint:errcheck + + if err := client.Verify(); err != nil { + logrus.WithFields(logrus.Fields{ + "kind": s.Kind, + "name": s.Name, + "namespace": s.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), + }, + } + + 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, + 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), + }, + } + + s.Status.Conditions = util.MergeConditions(s.Status.Conditions, conds) + s.Status.Status = migration.ClusterReady + + return h.source.UpdateStatus(s) + } + + return nil, nil +} diff --git a/pkg/controllers/migration/virtualmachine.go b/pkg/controllers/migration/virtualmachine.go index ac375fb0..bff7de41 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,10 @@ 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: + endpoint, _ := source.GetConnectionInfo() + options := source.GetOptions().(migration.KVMSourceOptions) + return kvm.NewClient(h.ctx, endpoint, secret, options) } return nil, fmt.Errorf("source kind %q not supported", source.GetKind()) @@ -576,6 +587,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/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") 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) } 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..1cc8ccd0 --- /dev/null +++ b/pkg/generated/controllers/migration.harvesterhci.io/v1beta1/kvmsource.go @@ -0,0 +1,208 @@ +/* +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. +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) +} 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 ee47ee6d..d5cc4086 100644 --- a/pkg/qemu/qemu.go +++ b/pkg/qemu/qemu.go @@ -4,19 +4,19 @@ import ( "fmt" "io" "os/exec" - "syscall" "github.com/sirupsen/logrus" ) const defaultCommand = "qemu-wrapper.sh" -func ConvertVMDKtoRAW(source, target string) error { +func ConvertToRAW(source, target string, format 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} + "format": format, + }).Info("Converting VMDK image ...") + args := []string{"convert", "-f", format, "-O", "raw", source, target} return runCommand(defaultCommand, args...) } @@ -27,9 +27,7 @@ func createVMDK(path string, size string) error { func runCommand(command string, args ...string) error { cmd := exec.Command(command, args...) - cmd.SysProcAttr = &syscall.SysProcAttr{} stderr, err := cmd.StderrPipe() - if err != nil { return fmt.Errorf("error creating stderr pipe: %v", err) } diff --git a/pkg/source/helper.go b/pkg/source/helper.go index a8e78bec..1ea06c05 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))), @@ -126,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 new file mode 100644 index 00000000..617007ed --- /dev/null +++ b/pkg/source/kvm/client.go @@ -0,0 +1,382 @@ +package kvm + +import ( + "bytes" + "context" + "fmt" + "io" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/pkg/sftp" + "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" + 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" + "github.com/harvester/vm-import-controller/pkg/server" + "github.com/harvester/vm-import-controller/pkg/source" +) + +type Client struct { + ctx context.Context + options migration.KVMSourceOptions + sshClient *ssh.Client +} + +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("error parsing endpoint URL: %w", err) + } + + host := endpointURL.Host + user := endpointURL.User.Username() + if user == "" { + if secretUser, ok := secret.Data["username"]; ok { + user = string(secretUser) + } else { + return nil, fmt.Errorf("username not found in endpoint URL 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: %w", 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") + } + + sshClientConfig := &ssh.ClientConfig{ + User: user, + Auth: authMethods, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), // nolint:gosec + Timeout: options.GetSSHTimeout(), + } + + if !strings.Contains(host, ":") { + 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 endpoint: %w", err) + } + + return &Client{ + ctx: ctx, + options: options, + sshClient: sshClient, + }, 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([]string{"list", "--name"}) + if err != nil { + return fmt.Errorf("failed to verify connection to %s: %w", c.sshClient.RemoteAddr().String(), err) + } + logrus.Infof("Connection verified to %s", c.sshClient.RemoteAddr().String()) + return nil +} + +func (c *Client) runCommand(args []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 + + uri := c.options.GetVirshConnectionURI() + if uri != "" { + args = append([]string{"-c", uri}, args...) + } + + cmd := exec.Command("virsh", args...) + cmdLine := cmd.String() + + err = session.Run(cmdLine) + if err != nil { + return "", fmt.Errorf("command %q failed: %v, stderr: %s", cmdLine, err, stderr.String()) + } + + return stdout.String(), nil +} + +func (c *Client) getDomainXML(vmName string) (*libvirtxml.Domain, error) { + 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: %w", err) + } + return &dom, nil +} + +func (c *Client) SanitizeVirtualMachineImport(vm *migration.VirtualMachineImport) error { + vm.Status.ImportedVirtualMachineName = strings.Split(strings.ToLower(vm.Spec.VirtualMachineName), ".")[0] + return nil +} + +func (c *Client) ExportVirtualMachine(vm *migration.VirtualMachineImport) error { + dom, err := c.getDomainXML(vm.Spec.VirtualMachineName) + if err != nil { + return err + } + + sftpClient, err := sftp.NewClient(c.sshClient) + if err != nil { + return fmt.Errorf("failed to create sftp client: %w", err) + } + 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 + } + 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 + } + + // 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: %w", err) + } + defer os.Remove(tmpFile.Name()) + + logrus.Infof("Downloading disk %s to %s", sourceFile, tmpFile.Name()) + + // Open the remote file + remoteFile, err := sftpClient.Open(sourceFile) + if err != nil { + 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: %w", err) + } + tmpFile.Close() // Close the file so qemu-img can access it + + // Local destination path for the converted RAW image + rawDiskName := source.GenerateRawImageFileName(vm.Name, i) + destFile := filepath.Join(server.TempDir(), rawDiskName) + + logrus.Infof("Converting downloaded disk %s to %s", tmpFile.Name(), destFile) + + // Use qemu-img convert on the local, downloaded file + format := "qcow2" // Default assumption + if disk.Driver != nil && disk.Driver.Type != "" { + format = disk.Driver.Type + } + + if err := qemu.ConvertToRAW(tmpFile.Name(), destFile, format); err != nil { + return fmt.Errorf("qemu convert failed: %w", err) + } + + // Update status + busType := kubevirt.DiskBusVirtio + if disk.Target != nil { + switch disk.Target.Bus { + case "sata", "ide": + busType = kubevirt.DiskBusSATA + case "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: %w", err) + } + + vm.Status.DiskImportStatus = append(vm.Status.DiskImportStatus, migration.DiskInfo{ + Name: rawDiskName, + DiskSize: destFileInfo.Size(), + BusType: busType, + DiskLocalPath: server.TempDir(), + }) + } + + return nil +} + +func (c *Client) ShutdownGuest(vm *migration.VirtualMachineImport) error { + 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([]string{"shutdown", vm.Spec.VirtualMachineName}) + return err +} + +func (c *Client) PowerOff(vm *migration.VirtualMachineImport) error { + _, err := c.runCommand([]string{"destroy", 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([]string{"domstate", 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, + }, + } + + cpuModel := "" + if dom.CPU != nil && dom.CPU.Model != nil { + cpuModel = dom.CPU.Model.Value + } + + // Firmware settings + fw := source.NewFirmware(false, false, false) + if dom.OS != nil && dom.OS.Loader != nil { + fw.UEFI = true // The 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{ + NumCPU: uint32(dom.VCPU.Value), // nolint:gosec + MemoryMB: int64(dom.Memory.Value / 1024), // nolint:gosec // XML memory is usually in KiB + CPUModel: cpuModel, + }, + }) + + // Network mapping + var networkInfos []source.NetworkInfo + if dom.Devices != nil { + for _, iface := range dom.Devices.Interfaces { + model := migration.NetworkInterfaceModelVirtio + if iface.Model != nil { + switch iface.Model.Type { + case "e1000": + model = migration.NetworkInterfaceModelE1000 + case "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, + }) + } + } + + 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 + + return newVM, nil +} + +func (c *Client) PreFlightChecks(vm *migration.VirtualMachineImport) error { + // Check if VM exists + _, err := c.runCommand([]string{"dominfo", vm.Spec.VirtualMachineName}) + if err != nil { + return fmt.Errorf("failed to find VM %s: %w", 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..944a5f56 --- /dev/null +++ b/pkg/source/kvm/client_test.go @@ -0,0 +1,74 @@ +package kvm + +import ( + "context" + "testing" + + "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) { + xmlData := ` + + test-vm + a75aca4b-42f6-4447-9262-4b9562d3d95c + 4194304 + 2 + + hvm + + + + IvyBridge-IBRS + + + + + + + + + + + + + + +` + + 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, uint(4194304), dom.Memory.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) { + ctx := context.TODO() + secret := &corev1.Secret{ + Data: map[string][]byte{ + "username": []byte("user"), + "password": []byte("pass"), + }, + } + + // 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, "ssh://user@localhost:2222", secret, migration.KVMSourceOptions{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to dial endpoint") +} 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/pkg/source/ova/client.go b/pkg/source/ova/client.go index f6780314..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) } @@ -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) 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/integration/kvm_test.go b/tests/integration/kvm_test.go new file mode 100644 index 00000000..90d68817 --- /dev/null +++ b/tests/integration/kvm_test.go @@ -0,0 +1,181 @@ +package integration + +import ( + "fmt" + + 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{} + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: setup.KVMVMNamespacedName.Namespace, + Name: d.VirtualMachineImage}, 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..8614c889 --- /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 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_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_URL") + if !ok { + return fmt.Errorf("env variable KVM_URL not set") + } + + s := &migration.KVMSource{ + ObjectMeta: metav1.ObjectMeta{ + Name: kvmSourceCluster, + Namespace: kvmDefaultNamespace, + }, + Spec: migration.KVMSourceSpec{ + EndpointAddress: 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) +}