mirror of
https://github.com/cozystack/cozystack.git
synced 2026-10-30 16:47:20 +09:00
Hidora's customers are leaving VMware now, and a tenant has to be able to import their own machines without a platform administrator. The chart approach could not express that: a tenant can neither create the Secret holding vCenter credentials nor name the VDDK image, so both had to come from somewhere they cannot reach. Two CRDs in a new `forklift.cozystack.io` group, split by lifecycle. A VMImportSource is a long-lived connection -- type, endpoint, credentials, readiness -- and a VMImportTask is the one-shot operation that names machines and produces VMDisks and VMInstances. The outputs carry no owner reference back to the task, so deleting a finished import leaves the machines alone. Konveyor Forklift does the transfer underneath; the controller renders its objects, mirrors its verdicts, and hands each finished volume into a VMDisk without copying it. What a live vCenter migration forced out, and no amount of source reading would have: - A guest copied verbatim keeps the drivers it had under VMware and no virtio, so on a virtio disk it does not fail the import -- it succeeds and then does not boot. Imported disks go on SATA. - VDDK connects straight to the ESXi host at the address vCenter advertises, which is routinely unreachable, and on the first cluster collided with the Service CIDR. `spec.hosts` redirects per host. - Firmware must be carried across or a UEFI guest imports as BIOS and never boots (stacked on #3002). - Deleting a task mid-transfer orphaned its DataVolume; a finalizer now removes the engine's volumes and leaves the outputs. The console gains a Migration section: connections are editable, and the VM field is a picker built from a machine list the controller publishes, since the aggregated API cannot reach the inventory and should not hold a source's credentials to try. Two general mechanisms fall out of that -- an option source may take an argument, and one named in a CRD annotation may reference a sibling field. Design: cozystack/community#62. Docs: cozystack/website#673. Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
746 lines
27 KiB
Go
746 lines
27 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package migrationcontroller
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
|
|
corev1 "k8s.io/api/core/v1"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
"k8s.io/apimachinery/pkg/api/meta"
|
|
"k8s.io/apimachinery/pkg/api/resource"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/controller-runtime/pkg/log"
|
|
|
|
migrationv1alpha1 "github.com/cozystack/cozystack/api/migration/v1alpha1"
|
|
)
|
|
|
|
// populatedForAnnotation is what makes the handoff free.
|
|
//
|
|
// CDI reads it as pure data: a string compare against the DataVolume's name,
|
|
// with no provenance check and no requirement that CDI ever saw the claim
|
|
// before, which is exactly why a controller may stamp it on a claim CDI has
|
|
// never met. It is evaluated before claim adoption in both the validating
|
|
// webhook and the import controller's work check, so it needs neither the
|
|
// DataVolumeClaimAdoption feature gate nor any annotation on the DataVolume —
|
|
// Cozystack's CDI configuration stays untouched.
|
|
//
|
|
// This is not a novel bet: it is the same primitive Cozystack's Velero VM
|
|
// restore path already depends on in production.
|
|
const populatedForAnnotation = "cdi.kubevirt.io/storage.populatedFor"
|
|
|
|
// Helm ownership metadata. The DataVolume is created before the VMDisk that
|
|
// will own it, so it must already look like part of that release or Helm
|
|
// refuses to adopt it and the release collides with its own volume.
|
|
const (
|
|
helmManagedByLabel = "app.kubernetes.io/managed-by"
|
|
helmManagedByValue = "Helm"
|
|
helmReleaseNameAnnotation = "meta.helm.sh/release-name"
|
|
helmReleaseNsAnnotation = "meta.helm.sh/release-namespace"
|
|
)
|
|
|
|
// vmDiskReleasePrefix is how a VMDisk's name becomes its Helm release, and
|
|
// therefore the name of its DataVolume and PVC. The vm-disk chart names its
|
|
// DataVolume after the release, and vm-instance references disks as
|
|
// `vm-disk-<name>`, so this prefix is part of the platform's contract rather
|
|
// than a convention this controller invents.
|
|
const vmDiskReleasePrefix = "vm-disk-"
|
|
|
|
// outputs names what an import produced for one source VM.
|
|
type outputs struct {
|
|
VMInstance string
|
|
Disks []string
|
|
}
|
|
|
|
// fulfill converts a finished transfer into Cozystack objects.
|
|
//
|
|
// Forklift's output is a KubeVirt VirtualMachine over CDI-populated claims,
|
|
// which is not what a Cozystack tenant manages. This turns it into VMDisks and
|
|
// a VMInstance without copying a byte: each transferred volume is re-pointed
|
|
// into the claim its VMDisk expects, and CDI is told the claim is already
|
|
// populated. The VirtualMachine Forklift built is read for the source VM's
|
|
// shape and then discarded — it is never started.
|
|
//
|
|
// Returns nil when more passes are needed, and a validationError for a
|
|
// terminal problem the tenant must fix.
|
|
func (r *VMImportTaskReconciler) fulfill(
|
|
ctx context.Context,
|
|
task *migrationv1alpha1.VMImportTask,
|
|
req *migrationv1alpha1.VMImportRequest,
|
|
) (*outputs, error) {
|
|
logger := log.FromContext(ctx)
|
|
|
|
vm, err := r.findForkliftVM(ctx, task, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if vm == nil {
|
|
// Already cleaned up: the outputs of a previous pass are authoritative.
|
|
if prev := findVMStatus(task.Status.VMs, req.ID); prev != nil && prev.VMInstance != "" {
|
|
return &outputs{VMInstance: prev.VMInstance, Disks: prev.Disks}, nil
|
|
}
|
|
// Status may be behind the cluster: dying between deleting the
|
|
// scaffolding VM and writing the status leaves finished outputs with
|
|
// nothing recording them. They carry this task's markers, so ask the
|
|
// cluster rather than failing an import that in fact succeeded.
|
|
reclaimed, err := r.reclaimOutputs(ctx, task, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if reclaimed != nil {
|
|
logger.Info("reclaimed outputs recorded by no status", "vm", req.ID, "instance", reclaimed.VMInstance)
|
|
return reclaimed, nil
|
|
}
|
|
return nil, &validationError{
|
|
reason: migrationv1alpha1.ReasonTransferFailed,
|
|
message: fmt.Sprintf("the transfer completed but no imported VM was found for %s", req.ID),
|
|
}
|
|
}
|
|
|
|
name := req.Name
|
|
if name == "" {
|
|
name = sanitizeName(vm.GetName())
|
|
}
|
|
|
|
claims := forkliftVMClaims(vm)
|
|
if len(claims) == 0 {
|
|
return nil, &validationError{
|
|
reason: migrationv1alpha1.ReasonTransferFailed,
|
|
message: fmt.Sprintf("the imported VM %s has no volumes to adopt", vm.GetName()),
|
|
}
|
|
}
|
|
|
|
diskNames := make([]string, 0, len(claims))
|
|
for i, claim := range claims {
|
|
diskName := fmt.Sprintf("%s-disk-%d", name, i)
|
|
diskNames = append(diskNames, diskName)
|
|
if err := r.adoptVolume(ctx, task, req.ID, claim, diskName); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// The VMInstance is created only once every disk is in place, so a tenant
|
|
// never sees an instance referencing a disk that does not exist yet.
|
|
if err := r.createVMInstance(ctx, task, req, name, vm, diskNames); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// The scaffolding VM goes last: while it exists, KubeVirt's own garbage
|
|
// collection could still reach the claims through its owner references.
|
|
if err := r.Delete(ctx, vm); err != nil && !apierrors.IsNotFound(err) {
|
|
return nil, err
|
|
}
|
|
logger.Info("imported VM fulfilled", "vm", name, "disks", len(diskNames))
|
|
|
|
return &outputs{VMInstance: name, Disks: diskNames}, nil
|
|
}
|
|
|
|
// findForkliftVM locates the VirtualMachine Forklift created for this source VM.
|
|
//
|
|
// The link is Forklift's `plan` label, and its value is the Plan's **UID**, not
|
|
// its name — so the Plan has to be read to learn what to match against. Getting
|
|
// this wrong does not fail loudly: the lookup simply never matches, the
|
|
// transferred disk is never adopted, and the task sits in Creating forever.
|
|
func (r *VMImportTaskReconciler) findForkliftVM(
|
|
ctx context.Context,
|
|
task *migrationv1alpha1.VMImportTask,
|
|
req *migrationv1alpha1.VMImportRequest,
|
|
) (*unstructured.Unstructured, error) {
|
|
name := planName(task.Name, req.ID)
|
|
|
|
plan := newObject(planGVK)
|
|
err := r.Get(ctx, types.NamespacedName{Namespace: task.Namespace, Name: name}, plan)
|
|
if err != nil {
|
|
if apierrors.IsNotFound(err) || meta.IsNoMatchError(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
planUID := string(plan.GetUID())
|
|
|
|
list := &unstructured.UnstructuredList{}
|
|
list.SetGroupVersionKind(virtualMachineGVK.GroupVersion().WithKind("VirtualMachineList"))
|
|
if err := r.List(ctx, list, client.InNamespace(task.Namespace)); err != nil {
|
|
if apierrors.IsNotFound(err) || meta.IsNoMatchError(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
for i := range list.Items {
|
|
item := &list.Items[i]
|
|
labels := item.GetLabels()
|
|
if planUID != "" && (labels["plan"] == planUID || labels["migration"] == planUID) {
|
|
return item, nil
|
|
}
|
|
// Some Forklift versions stamp the name instead; accept either rather
|
|
// than depending on which one this release happens to use.
|
|
if labels["plan"] == name || labels["migration"] == name {
|
|
return item, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// forkliftVMClaims returns the claim names backing the imported VM, in the
|
|
// source VM's own disk order — which is the order the guest's firmware expects
|
|
// to boot from, so it must be preserved rather than sorted.
|
|
func forkliftVMClaims(vm *unstructured.Unstructured) []string {
|
|
volumes, _, _ := unstructured.NestedSlice(vm.Object, "spec", "template", "spec", "volumes")
|
|
claims := make([]string, 0, len(volumes))
|
|
for _, raw := range volumes {
|
|
vol, ok := raw.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
if name, found, _ := unstructured.NestedString(vol, "dataVolume", "name"); found && name != "" {
|
|
claims = append(claims, name)
|
|
continue
|
|
}
|
|
if name, found, _ := unstructured.NestedString(vol, "persistentVolumeClaim", "claimName"); found && name != "" {
|
|
claims = append(claims, name)
|
|
}
|
|
}
|
|
return claims
|
|
}
|
|
|
|
// sourceFirmware translates the boot configuration of the VM Forklift built
|
|
// into the vm-instance `firmware` block, or returns nil when the source says
|
|
// nothing and the instance profile's own default should stand.
|
|
//
|
|
// Forklift copies the source's firmware onto the VirtualMachine it creates, so
|
|
// this is the source's boot mode as the migration engine understood it — read
|
|
// from the object the handoff already holds rather than from a second inventory
|
|
// lookup. Carrying it across is not cosmetic: a UEFI guest rendered with a BIOS
|
|
// bootloader imports "successfully" and then does not boot, which is why the
|
|
// vm-instance firmware field (cozystack/cozystack#3002) is a merge-order
|
|
// dependency of this controller.
|
|
func sourceFirmware(vm *unstructured.Unstructured) map[string]interface{} {
|
|
base := []string{"spec", "template", "spec", "domain", "firmware", "bootloader"}
|
|
|
|
if efi, found, _ := unstructured.NestedMap(vm.Object, append(base, "efi")...); found {
|
|
fw := map[string]interface{}{"bootloader": "uefi"}
|
|
// secureBoot is reported by the source; persistent NVRAM is not, and is
|
|
// deliberately left to the tenant — it pins the VM to a node.
|
|
if sb, ok := efi["secureBoot"].(bool); ok && sb {
|
|
fw["secureBoot"] = true
|
|
}
|
|
return fw
|
|
}
|
|
// `efi: {}` renders as an empty map, but a malformed value under the key
|
|
// still means EFI was asked for; treat presence as the answer.
|
|
if _, present, _ := unstructured.NestedFieldNoCopy(vm.Object, append(base, "efi")...); present {
|
|
return map[string]interface{}{"bootloader": "uefi"}
|
|
}
|
|
if _, present, _ := unstructured.NestedFieldNoCopy(vm.Object, append(base, "bios")...); present {
|
|
return map[string]interface{}{"bootloader": "bios"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ahciPorts is how many disks the SATA bus can carry: the q35 machine type
|
|
// KubeVirt renders gives its AHCI controller six ports, no more.
|
|
const ahciPorts = 6
|
|
|
|
// importedDiskBus picks the bus for one imported disk by its position.
|
|
//
|
|
// The import copies the guest verbatim (skipGuestConversion in createPlan), so
|
|
// it arrives with the drivers it had under VMware and no virtio among them. On
|
|
// a virtio disk such a guest does not boot at all: Linux stalls in the initramfs
|
|
// with no root device, Windows stops at 0x7B. AHCI is inbox everywhere, so the
|
|
// disk that has to be readable before the guest's own modules are available
|
|
// goes on SATA.
|
|
//
|
|
// Past the AHCI port count the rest fall back to virtio. That is a deliberate
|
|
// trade rather than a failure: only the boot disk must be reachable from the
|
|
// initramfs, and once Linux has mounted the root it loads virtio_blk like any
|
|
// other module. A Windows guest instead sees those disks appear once virtio-win
|
|
// is installed, which is recoverable, where an unbootable import is not.
|
|
//
|
|
// When guest conversion arrives as its own option it should hand back virtio
|
|
// for converted VMs, since virt-v2v installs the very drivers this works around.
|
|
func importedDiskBus(index int) string {
|
|
if index < ahciPorts {
|
|
return "sata"
|
|
}
|
|
return "virtio"
|
|
}
|
|
|
|
// reclaimOutputs finds this task's finished outputs for one VM when its status
|
|
// does not name them, and returns nil when there are none.
|
|
//
|
|
// The window it covers is narrow and real: the scaffolding VirtualMachine is
|
|
// deleted before the status write that records what was produced, so a crash
|
|
// between the two leaves a healthy VMInstance and its disks with nothing
|
|
// pointing at them. Without this the next pass reports Failed on a machine that
|
|
// works. The markers make the lookup exact — the task's UID and the source VM
|
|
// ID — so this can never adopt a tenant's object or another task's.
|
|
func (r *VMImportTaskReconciler) reclaimOutputs(
|
|
ctx context.Context,
|
|
task *migrationv1alpha1.VMImportTask,
|
|
req *migrationv1alpha1.VMImportRequest,
|
|
) (*outputs, error) {
|
|
markers := client.MatchingLabels(outputMarkers(task, req.ID))
|
|
|
|
instances := &unstructured.UnstructuredList{}
|
|
instances.SetGroupVersionKind(vmInstanceGVK)
|
|
if err := r.List(ctx, instances, client.InNamespace(task.Namespace), markers); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(instances.Items) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
disks := &unstructured.UnstructuredList{}
|
|
disks.SetGroupVersionKind(vmDiskGVK)
|
|
if err := r.List(ctx, disks, client.InNamespace(task.Namespace), markers); err != nil {
|
|
return nil, err
|
|
}
|
|
names := make([]string, 0, len(disks.Items))
|
|
for i := range disks.Items {
|
|
names = append(names, disks.Items[i].GetName())
|
|
}
|
|
// Disk order is what the VMInstance's own disk list records, and a label
|
|
// query returns no order at all, so sort for a stable status.
|
|
sort.Strings(names)
|
|
|
|
return &outputs{VMInstance: instances.Items[0].GetName(), Disks: names}, nil
|
|
}
|
|
|
|
// adoptVolume moves one transferred volume under the name a VMDisk expects,
|
|
// then creates the VMDisk over it. No data is copied: the PersistentVolume is
|
|
// retained and re-bound, which is the whole point of the exercise.
|
|
func (r *VMImportTaskReconciler) adoptVolume(
|
|
ctx context.Context,
|
|
task *migrationv1alpha1.VMImportTask,
|
|
vmID string,
|
|
sourceClaim string,
|
|
diskName string,
|
|
) error {
|
|
target := vmDiskReleasePrefix + diskName
|
|
|
|
// Idempotency, but only over this task's own work. A claim of the right
|
|
// name that this task did not create is a tenant's disk standing where an
|
|
// output goes: treating it as a finished pass would attach stale data to
|
|
// the instance and leave the freshly transferred claim owned by the
|
|
// scaffolding VM, to be collected the moment that VM is deleted.
|
|
existing := &corev1.PersistentVolumeClaim{}
|
|
err := r.Get(ctx, types.NamespacedName{Namespace: task.Namespace, Name: target}, existing)
|
|
if err == nil {
|
|
if !isOwnOutput(existing, task, vmID) {
|
|
return &validationError{
|
|
reason: migrationv1alpha1.ReasonOutputExists,
|
|
message: fmt.Sprintf(
|
|
"a PersistentVolumeClaim named %q already exists in this namespace and was not created by this import; "+
|
|
"the import will not overwrite it — choose another name for VM %s", target, vmID),
|
|
}
|
|
}
|
|
return r.ensureVMDisk(ctx, task, vmID, diskName, existing)
|
|
}
|
|
if !apierrors.IsNotFound(err) {
|
|
return err
|
|
}
|
|
|
|
pvc := &corev1.PersistentVolumeClaim{}
|
|
if err := r.Get(ctx, types.NamespacedName{Namespace: task.Namespace, Name: sourceClaim}, pvc); err != nil {
|
|
return err
|
|
}
|
|
if pvc.Spec.VolumeName == "" {
|
|
return &validationError{
|
|
reason: migrationv1alpha1.ReasonTransferFailed,
|
|
message: fmt.Sprintf("transferred claim %q is not bound to a volume", sourceClaim),
|
|
}
|
|
}
|
|
|
|
pv := &corev1.PersistentVolume{}
|
|
if err := r.Get(ctx, types.NamespacedName{Name: pvc.Spec.VolumeName}, pv); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Order matters here, and getting it wrong destroys data rather than
|
|
// merely wasting time.
|
|
//
|
|
// First, orphan the claim. Forklift drives the transfer through CDI's
|
|
// volume populator, so the transferred disk arrives as a *populated claim*
|
|
// with no DataVolume of its own — and Forklift puts an ownerReference to
|
|
// the VirtualMachine it built on that claim, deliberately, so an abandoned
|
|
// migration cleans itself up. The consequence is that anything deleting
|
|
// that VM takes the disk with it, and garbage collection is fast enough to
|
|
// win any race against a copy: on the original implementation it removed
|
|
// the claim 357 ms after the VM went, long before a clone could finish.
|
|
// Clearing the reference unconditionally is what makes the rest of this
|
|
// function safe to reorder.
|
|
if err := clearOwnerReferences(ctx, r.Client, task.Namespace, sourceClaim); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Then remove any DataVolume that does own the claim. Deleting a claim
|
|
// while its DataVolume still exists makes CDI recreate the claim and
|
|
// provision a second volume, re-running the entire import — the exact
|
|
// duplicate copy this design exists to remove. Observed on a live cluster.
|
|
if err := r.deleteOwningDataVolume(ctx, task.Namespace, sourceClaim); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Retain is what protects the data during the swap, and it stays on
|
|
// permanently: CDI takes a controller owner reference on an adopted claim,
|
|
// so deleting the DataVolume garbage-collects the claim, and only the
|
|
// reclaim policy keeps the disk. That policy is part of the contract.
|
|
if pv.Spec.PersistentVolumeReclaimPolicy != corev1.PersistentVolumeReclaimRetain {
|
|
pv.Spec.PersistentVolumeReclaimPolicy = corev1.PersistentVolumeReclaimRetain
|
|
if err := r.Update(ctx, pv); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// volumeMode and accessModes are copied from the volume rather than
|
|
// defaulted. Pre-binding through volumeName bypasses the volumeMode match
|
|
// check, so a claim that assumes Filesystem against a Block volume binds
|
|
// anyway and fails much later, at mount time, with a message that points
|
|
// nowhere near here. Also observed live.
|
|
replacement := &corev1.PersistentVolumeClaim{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: target,
|
|
Namespace: task.Namespace,
|
|
Labels: func() map[string]string {
|
|
l := map[string]string{
|
|
migrationv1alpha1.ManagedByLabel: migrationv1alpha1.ManagedByValue,
|
|
}
|
|
for k, v := range outputMarkers(task, vmID) {
|
|
l[k] = v
|
|
}
|
|
return l
|
|
}(),
|
|
Annotations: map[string]string{
|
|
populatedForAnnotation: target,
|
|
},
|
|
},
|
|
Spec: corev1.PersistentVolumeClaimSpec{
|
|
AccessModes: pv.Spec.AccessModes,
|
|
VolumeMode: pv.Spec.VolumeMode,
|
|
VolumeName: pv.Name,
|
|
StorageClassName: &pv.Spec.StorageClassName,
|
|
Resources: corev1.VolumeResourceRequirements{
|
|
Requests: corev1.ResourceList{
|
|
corev1.ResourceStorage: pv.Spec.Capacity[corev1.ResourceStorage],
|
|
},
|
|
},
|
|
},
|
|
}
|
|
if err := r.Create(ctx, replacement); err != nil && !apierrors.IsAlreadyExists(err) {
|
|
return err
|
|
}
|
|
|
|
// Release the old claim, then hand the volume to the new one. Rewriting
|
|
// claimRef including the UID is what makes this atomic: there is no window
|
|
// in which the volume is Available for an unrelated claim to grab.
|
|
old := &corev1.PersistentVolumeClaim{}
|
|
if err := r.Get(ctx, types.NamespacedName{Namespace: task.Namespace, Name: sourceClaim}, old); err == nil {
|
|
if err := r.Delete(ctx, old); err != nil && !apierrors.IsNotFound(err) {
|
|
return err
|
|
}
|
|
} else if !apierrors.IsNotFound(err) {
|
|
return err
|
|
}
|
|
|
|
bound := &corev1.PersistentVolumeClaim{}
|
|
if err := r.Get(ctx, types.NamespacedName{Namespace: task.Namespace, Name: target}, bound); err != nil {
|
|
return err
|
|
}
|
|
if err := r.Get(ctx, types.NamespacedName{Name: pv.Name}, pv); err != nil {
|
|
return err
|
|
}
|
|
pv.Spec.ClaimRef = &corev1.ObjectReference{
|
|
Kind: "PersistentVolumeClaim",
|
|
APIVersion: "v1",
|
|
Namespace: bound.Namespace,
|
|
Name: bound.Name,
|
|
UID: bound.UID,
|
|
}
|
|
if err := r.Update(ctx, pv); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := r.createDataVolume(ctx, task, diskName, bound); err != nil {
|
|
return err
|
|
}
|
|
return r.ensureVMDisk(ctx, task, vmID, diskName, bound)
|
|
}
|
|
|
|
// deleteOwningDataVolume removes the DataVolume that owns a claim, and waits
|
|
// for it to actually be gone before the caller touches the claim.
|
|
func (r *VMImportTaskReconciler) deleteOwningDataVolume(ctx context.Context, namespace, claim string) error {
|
|
dv := newObject(dataVolumeGVK)
|
|
err := r.Get(ctx, types.NamespacedName{Namespace: namespace, Name: claim}, dv)
|
|
if apierrors.IsNotFound(err) || meta.IsNoMatchError(err) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Orphan the claim first: without this, deleting the DataVolume takes the
|
|
// claim with it through the controller owner reference CDI set.
|
|
if err := clearOwnerReferences(ctx, r.Client, namespace, claim); err != nil {
|
|
return err
|
|
}
|
|
if err := r.Delete(ctx, dv); err != nil && !apierrors.IsNotFound(err) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func clearOwnerReferences(ctx context.Context, c client.Client, namespace, name string) error {
|
|
pvc := &corev1.PersistentVolumeClaim{}
|
|
if err := c.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, pvc); err != nil {
|
|
if apierrors.IsNotFound(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if len(pvc.OwnerReferences) == 0 {
|
|
return nil
|
|
}
|
|
pvc.OwnerReferences = nil
|
|
return c.Update(ctx, pvc)
|
|
}
|
|
|
|
// createDataVolume creates the DataVolume the VMDisk release will adopt. Its
|
|
// spec is deliberately minimal and its source is blank: the claim beside it
|
|
// already carries the data and the populatedFor annotation, so CDI short
|
|
// circuits population instead of fetching anything.
|
|
func (r *VMImportTaskReconciler) createDataVolume(
|
|
ctx context.Context,
|
|
task *migrationv1alpha1.VMImportTask,
|
|
diskName string,
|
|
pvc *corev1.PersistentVolumeClaim,
|
|
) error {
|
|
name := vmDiskReleasePrefix + diskName
|
|
existing := newObject(dataVolumeGVK)
|
|
err := r.Get(ctx, types.NamespacedName{Namespace: task.Namespace, Name: name}, existing)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if !apierrors.IsNotFound(err) {
|
|
return err
|
|
}
|
|
|
|
size := pvc.Spec.Resources.Requests[corev1.ResourceStorage]
|
|
dv := newObject(dataVolumeGVK)
|
|
dv.SetName(name)
|
|
dv.SetNamespace(task.Namespace)
|
|
dv.SetLabels(map[string]string{
|
|
// Both of these matter: the instance label is what the vm-disk chart
|
|
// stamps, and the Helm label is what lets the release adopt the object
|
|
// instead of colliding with it.
|
|
"app.kubernetes.io/instance": name,
|
|
helmManagedByLabel: helmManagedByValue,
|
|
})
|
|
dv.SetAnnotations(map[string]string{
|
|
helmReleaseNameAnnotation: name,
|
|
helmReleaseNsAnnotation: task.Namespace,
|
|
"vm-disk.cozystack.io/optical": "false",
|
|
})
|
|
spec := map[string]interface{}{
|
|
"contentType": "kubevirt",
|
|
"source": map[string]interface{}{
|
|
"blank": map[string]interface{}{},
|
|
},
|
|
"storage": map[string]interface{}{
|
|
"resources": map[string]interface{}{
|
|
"requests": map[string]interface{}{
|
|
"storage": size.String(),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
if pvc.Spec.StorageClassName != nil && *pvc.Spec.StorageClassName != "" {
|
|
storage, _, _ := unstructured.NestedMap(spec, "storage")
|
|
storage["storageClassName"] = *pvc.Spec.StorageClassName
|
|
spec["storage"] = storage
|
|
}
|
|
if err := unstructured.SetNestedMap(dv.Object, spec, "spec"); err != nil {
|
|
return err
|
|
}
|
|
if err := r.Create(ctx, dv); err != nil && !apierrors.IsAlreadyExists(err) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ensureVMDisk creates the tenant-facing VMDisk over an adopted volume. It
|
|
// carries no owner reference to the task on purpose: this object is the point
|
|
// of the whole operation and must outlive the operation that produced it.
|
|
func (r *VMImportTaskReconciler) ensureVMDisk(
|
|
ctx context.Context,
|
|
task *migrationv1alpha1.VMImportTask,
|
|
vmID string,
|
|
diskName string,
|
|
pvc *corev1.PersistentVolumeClaim,
|
|
) error {
|
|
existing := newObject(vmDiskGVK)
|
|
err := r.Get(ctx, types.NamespacedName{Namespace: task.Namespace, Name: diskName}, existing)
|
|
if err == nil {
|
|
if !isOwnOutput(existing, task, vmID) {
|
|
return &validationError{
|
|
reason: migrationv1alpha1.ReasonOutputExists,
|
|
message: fmt.Sprintf(
|
|
"a VMDisk named %q already exists in this namespace and was not created by this import; "+
|
|
"the import will not overwrite it — choose another name for VM %s", diskName, vmID),
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
if !apierrors.IsNotFound(err) {
|
|
return err
|
|
}
|
|
|
|
size := pvc.Spec.Resources.Requests[corev1.ResourceStorage]
|
|
obj := newObject(vmDiskGVK)
|
|
obj.SetName(diskName)
|
|
obj.SetNamespace(task.Namespace)
|
|
stampOutput(obj, task, vmID)
|
|
spec := map[string]interface{}{
|
|
"storage": size.String(),
|
|
"optical": false,
|
|
}
|
|
if pvc.Spec.StorageClassName != nil && *pvc.Spec.StorageClassName != "" {
|
|
spec["storageClass"] = *pvc.Spec.StorageClassName
|
|
}
|
|
if err := unstructured.SetNestedMap(obj.Object, spec, "spec"); err != nil {
|
|
return err
|
|
}
|
|
if err := r.Create(ctx, obj); err != nil && !apierrors.IsAlreadyExists(err) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// createVMInstance builds the tenant-facing VM over the imported disks.
|
|
//
|
|
// When the tenant named no instance type the source VM's own CPU and memory are
|
|
// carried across as explicit resources, so an import never silently resizes a
|
|
// guest to fit a catalog size.
|
|
func (r *VMImportTaskReconciler) createVMInstance(
|
|
ctx context.Context,
|
|
task *migrationv1alpha1.VMImportTask,
|
|
req *migrationv1alpha1.VMImportRequest,
|
|
name string,
|
|
vm *unstructured.Unstructured,
|
|
diskNames []string,
|
|
) error {
|
|
existing := newObject(vmInstanceGVK)
|
|
err := r.Get(ctx, types.NamespacedName{Namespace: task.Namespace, Name: name}, existing)
|
|
if err == nil {
|
|
// Only this task's own instance counts as a finished pass. Anything
|
|
// else of that name belongs to the tenant, and an import never
|
|
// overwrites or silently adopts one.
|
|
if !isOwnOutput(existing, task, req.ID) {
|
|
return &validationError{
|
|
reason: migrationv1alpha1.ReasonOutputExists,
|
|
message: fmt.Sprintf(
|
|
"a VMInstance named %q already exists in this namespace and was not created by this import; "+
|
|
"the import will not overwrite it — choose another name for VM %s", name, req.ID),
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
if !apierrors.IsNotFound(err) {
|
|
return err
|
|
}
|
|
|
|
disks := make([]interface{}, 0, len(diskNames))
|
|
for i, d := range diskNames {
|
|
disks = append(disks, map[string]interface{}{"name": d, "bus": importedDiskBus(i)})
|
|
}
|
|
|
|
spec := map[string]interface{}{
|
|
"disks": disks,
|
|
// Halted, not Always: a freshly imported guest may need its network or
|
|
// drivers adjusted, and booting it the instant the import finishes can
|
|
// put a second copy of a still-running production machine on the
|
|
// network. Starting it is one click, and it is the tenant's call.
|
|
"runStrategy": "Halted",
|
|
}
|
|
if req.InstanceType != "" {
|
|
spec["instanceType"] = req.InstanceType
|
|
} else {
|
|
cores, sockets, memory := sourceVMResources(vm)
|
|
resources := map[string]interface{}{}
|
|
if cores != "" {
|
|
resources["cpu"] = cores
|
|
}
|
|
if sockets != "" {
|
|
resources["sockets"] = sockets
|
|
}
|
|
if memory != "" {
|
|
resources["memory"] = memory
|
|
}
|
|
if len(resources) > 0 {
|
|
spec["resources"] = resources
|
|
// An empty instance type is what tells the chart to use the
|
|
// explicit resources instead of a catalog size.
|
|
spec["instanceType"] = ""
|
|
}
|
|
}
|
|
if req.InstanceProfile != "" {
|
|
spec["instanceProfile"] = req.InstanceProfile
|
|
}
|
|
|
|
// Carry the source's boot mode across. Without it a UEFI guest is rendered
|
|
// with the profile's default bootloader and does not boot.
|
|
if fw := sourceFirmware(vm); fw != nil {
|
|
spec["firmware"] = fw
|
|
}
|
|
|
|
obj := newObject(vmInstanceGVK)
|
|
obj.SetName(name)
|
|
obj.SetNamespace(task.Namespace)
|
|
stampOutput(obj, task, req.ID)
|
|
if err := unstructured.SetNestedMap(obj.Object, spec, "spec"); err != nil {
|
|
return err
|
|
}
|
|
if err := r.Create(ctx, obj); err != nil && !apierrors.IsAlreadyExists(err) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sourceVMResources reads the CPU topology and memory Forklift derived from the
|
|
// source VM's inventory record.
|
|
//
|
|
// Cores and sockets are carried across separately rather than multiplied into
|
|
// one number: vm-instance maps `resources.cpu` to domain.cpu.cores and
|
|
// `resources.sockets` to domain.cpu.sockets, and refuses to render at all
|
|
// unless both are set alongside memory. Collapsing the topology would both
|
|
// misreport it to the guest and fail the chart.
|
|
func sourceVMResources(vm *unstructured.Unstructured) (cores, sockets, memory string) {
|
|
base := []string{"spec", "template", "spec", "domain", "cpu"}
|
|
if n, found, _ := unstructured.NestedInt64(vm.Object, append(base, "cores")...); found && n > 0 {
|
|
cores = fmt.Sprintf("%d", n)
|
|
}
|
|
if n, found, _ := unstructured.NestedInt64(vm.Object, append(base, "sockets")...); found && n > 0 {
|
|
sockets = fmt.Sprintf("%d", n)
|
|
}
|
|
// A source that reports cores but no socket count is a single-socket
|
|
// machine; saying so explicitly is what lets the chart render.
|
|
if cores != "" && sockets == "" {
|
|
sockets = "1"
|
|
}
|
|
for _, path := range [][]string{
|
|
{"spec", "template", "spec", "domain", "memory", "guest"},
|
|
{"spec", "template", "spec", "domain", "resources", "requests", "memory"},
|
|
} {
|
|
if value, found, _ := unstructured.NestedString(vm.Object, path...); found && value != "" {
|
|
if _, err := resource.ParseQuantity(value); err == nil {
|
|
memory = value
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return cores, sockets, memory
|
|
}
|