Files
Aleksei Sviridkin 7a0aab2bdc refactor: apply the Go 1.27 modernizers
Run go fix over every first-party module: interface{} becomes any,
hand-written loops become slices, maps, min/max and range-over-int,
errors.As becomes errors.AsType, pointer helpers become new(expr) and
are inlined at their call sites, and so on. Every rewrite keeps
behaviour.

Three rewrites stay out. embedlit flattens embedded-field literals
such as ObjectMeta, syntax only Go 1.27 accepts, which would break
backports to release branches still on Go 1.26. omitzero drops
omitempty from struct fields, and openapi-gen reads omitempty as
optional, so metadata, spec and status would turn required in the
published schemas. And errorsastype rewrote an errors.As whose
target, apierrors.APIStatus, does not implement error, which
errors.AsType rejects at compile time.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-09-24 17:23:18 +03:00

619 lines
23 KiB
Go

// SPDX-License-Identifier: Apache-2.0
package migrationcontroller
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"
storagev1 "k8s.io/api/storage/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
migrationv1alpha1 "github.com/cozystack/cozystack/api/migration/v1alpha1"
)
// TransferRequeue is the polling interval while disks are moving. Forklift
// updates its Migration status as the transfer progresses and this controller
// mirrors it; a watch on Migration drives most updates, and this is the
// backstop for the case where the watch is not established (Forklift absent at
// startup, CRDs installed later).
const TransferRequeue = 20 * time.Second
// defaultStorageClassAnnotation marks the cluster's default StorageClass.
const defaultStorageClassAnnotation = "storageclass.kubernetes.io/is-default-class"
// VMImportTaskReconciler drives one import operation: it renders the Forklift
// objects that perform the transfer, mirrors their progress onto the task, and
// converts each finished transfer into a Cozystack VMDisk and VMInstance.
//
// The scaffolding it creates (maps, Plans, Migrations) is owned by the task and
// disappears with it. The outputs are deliberately not owned by anything, which
// is what lets a tenant delete a finished task without losing the machines it
// imported.
type VMImportTaskReconciler struct {
client.Client
Scheme *runtime.Scheme
Recorder record.EventRecorder
// Inventory reads the source's topology from Forklift's inventory service.
// The network and datastore IDs a Plan's maps need appear on no Forklift
// custom resource, so there is no way to build those maps without it.
Inventory *inventoryClient
}
func (r *VMImportTaskReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
task := &migrationv1alpha1.VMImportTask{}
if err := r.Get(ctx, req.NamespacedName, task); err != nil {
if apierrors.IsNotFound(err) {
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
}
if !task.DeletionTimestamp.IsZero() {
return r.finalize(ctx, task)
}
// Claimed before any scaffolding is created, so a task deleted mid-transfer
// still gets its volumes cleaned up.
if !controllerutil.ContainsFinalizer(task, migrationv1alpha1.TaskFinalizer) {
controllerutil.AddFinalizer(task, migrationv1alpha1.TaskFinalizer)
if err := r.Update(ctx, task); err != nil {
if apierrors.IsConflict(err) {
return ctrl.Result{Requeue: true}, nil
}
return ctrl.Result{}, err
}
}
// A finished task is a record, not a workload. Re-running validation or
// re-creating scaffolding for it would resurrect Forklift objects a tenant
// has already seen disappear.
if task.Status.Phase.IsTerminal() {
return ctrl.Result{}, nil
}
src := &migrationv1alpha1.VMImportSource{}
err := r.Get(ctx, types.NamespacedName{Namespace: task.Namespace, Name: task.Spec.SourceRef.Name}, src)
if apierrors.IsNotFound(err) {
return r.pending(ctx, task, migrationv1alpha1.ReasonSourceNotReady,
fmt.Sprintf("VMImportSource %q does not exist in this namespace", task.Spec.SourceRef.Name))
}
if err != nil {
return ctrl.Result{}, err
}
if !meta.IsStatusConditionTrue(src.Status.Conditions, migrationv1alpha1.SourceReadyCondition) {
reason, message := sourceNotReadyDetail(src)
return r.pending(ctx, task, reason, message)
}
// Resolve and vet the storage class before anything is created. A
// WaitForFirstConsumer class does not fail the import, it hangs it: nothing
// consumes the claim while CDI populates it, so the transfer waits forever.
// Failing here costs a tenant one edit; not failing here costs them a
// support ticket.
storageClass, err := r.resolveStorageClass(ctx, task)
if err != nil {
var verr *validationError
if asValidationError(err, &verr) {
return r.terminalFail(ctx, task, verr.reason, verr.message)
}
return ctrl.Result{}, err
}
if task.Status.StartedAt == nil {
now := metav1.Now()
task.Status.StartedAt = &now
}
// A conflict here is two passes racing on the same map, not a failure: the
// next pass reads the newer object and re-applies. Returning it as an error
// re-queues with backoff and logs a stack trace for something that is
// ordinary optimistic concurrency.
if err := r.ensureMaps(ctx, task, src, storageClass); apierrors.IsConflict(err) {
return ctrl.Result{Requeue: true}, nil
} else if err != nil {
// Forklift's CRDs can vanish under a running task — the operand is
// removed, or this is the window before the Source's recheck notices.
// reconcileVM already reports that as Pending; without the same here
// the reconcile returns an error and hot-loops with nothing written for
// a tenant to read.
if meta.IsNoMatchError(err) {
return r.pending(ctx, task, migrationv1alpha1.ReasonForkliftNotInstalled,
"Forklift is not installed in this cluster")
}
return ctrl.Result{}, err
}
statuses := make([]migrationv1alpha1.VMImportStatus, 0, len(task.Spec.VMs))
requeue := false
for i := range task.Spec.VMs {
vmStatus, again, err := r.reconcileVM(ctx, task, src, &task.Spec.VMs[i])
if err != nil {
return ctrl.Result{}, err
}
requeue = requeue || again
statuses = append(statuses, vmStatus)
}
task.Status.VMs = statuses
task.Status.Phase = aggregatePhase(statuses)
task.Status.ObservedGeneration = task.Generation
if task.Status.Phase.IsTerminal() && task.Status.CompletedAt == nil {
now := metav1.Now()
task.Status.CompletedAt = &now
task.Status.Message = terminalMessage(statuses)
if task.Status.Phase == migrationv1alpha1.VMImportTaskPhaseSucceeded {
r.Recorder.Eventf(task, "Normal", "Imported", "imported %d VM(s)", len(statuses))
} else {
r.Recorder.Eventf(task, "Warning", "ImportFailed", "%s", task.Status.Message)
}
}
if err := r.Status().Update(ctx, task); err != nil {
if apierrors.IsConflict(err) {
return ctrl.Result{Requeue: true}, nil
}
return ctrl.Result{}, err
}
logger.V(1).Info("reconciled VMImportTask", "phase", task.Status.Phase, "vms", len(statuses))
if requeue {
return ctrl.Result{RequeueAfter: TransferRequeue}, nil
}
return ctrl.Result{}, nil
}
// reconcileVM advances one VM of the task: it creates the Plan and Migration
// that move its disks, mirrors their progress, and once the transfer is done
// converts the result into Cozystack objects. The bool reports whether this VM
// still needs watching.
func (r *VMImportTaskReconciler) reconcileVM(
ctx context.Context,
task *migrationv1alpha1.VMImportTask,
src *migrationv1alpha1.VMImportSource,
req *migrationv1alpha1.VMImportRequest,
) (migrationv1alpha1.VMImportStatus, bool, error) {
status := migrationv1alpha1.VMImportStatus{
ID: req.ID,
Name: req.Name,
}
// Carry forward what a previous pass established, so a completed VM keeps
// its output names even after its Forklift objects are gone.
if prev := findVMStatus(task.Status.VMs, req.ID); prev != nil {
status = *prev
if status.Phase.IsTerminal() {
return status, false, nil
}
}
// Refuse to overwrite anything a tenant already has, and refuse it before
// spending a transfer on it. Checked every pass rather than once, because a
// name can be taken while a transfer runs, and per VM rather than per task:
// one VM whose name is occupied does not stop its siblings from finishing.
if collision, err := r.outputCollision(ctx, task, req); err != nil {
return status, false, err
} else if collision != "" {
status.Phase = migrationv1alpha1.VMImportTaskPhaseFailed
status.Reason = migrationv1alpha1.ReasonOutputExists
status.Message = collision
return status, false, nil
}
plan := newObject(planGVK)
name := planName(task.Name, req.ID)
err := r.Get(ctx, types.NamespacedName{Namespace: task.Namespace, Name: name}, plan)
if apierrors.IsNotFound(err) {
if err := r.createPlan(ctx, task, src, req); err != nil {
return status, false, err
}
status.Phase = migrationv1alpha1.VMImportTaskPhaseValidating
status.Message = "waiting for Forklift to validate the source VM"
return status, true, nil
}
if err != nil {
if meta.IsNoMatchError(err) {
status.Phase = migrationv1alpha1.VMImportTaskPhasePending
status.Message = "Forklift is not installed in this cluster"
return status, true, nil
}
return status, false, err
}
// Forklift validates the VM reference, the maps and the provider for us and
// says what is wrong in its own words. Surfacing that verbatim beats
// re-implementing its checks: the inventory this controller queries answers
// what a VM uses, never whether Forklift will accept the Plan.
if critical := planCriticalCondition(plan); critical != "" {
status.Phase = migrationv1alpha1.VMImportTaskPhaseFailed
status.Reason = migrationv1alpha1.ReasonPlanRejected
status.Message = critical
return status, false, nil
}
// Fill the maps from this VM's actual topology, read out of Forklift's
// inventory. This cannot be done from the Plan's own conditions: those name
// the VMs that are unmapped, never the networks or datastores they use.
// Scoped to this VM so a bad reference fails it alone (see sourceTopology).
if learned, err := r.populateMaps(ctx, task, src, req); err != nil {
if nf, ok := errors.AsType[*notFoundError](err); ok {
status.Phase = migrationv1alpha1.VMImportTaskPhaseFailed
status.Reason = migrationv1alpha1.ReasonVMNotFound
status.Message = nf.Error()
return status, false, nil
}
return status, false, err
} else if learned {
status.Phase = migrationv1alpha1.VMImportTaskPhaseValidating
status.Message = "completing the network and storage maps from the source inventory"
return status, true, nil
}
if !planReady(plan) {
status.Phase = migrationv1alpha1.VMImportTaskPhaseValidating
status.Message = "waiting for Forklift to validate the plan"
return status, true, nil
}
migration, err := r.ensureMigration(ctx, task, name)
if err != nil {
return status, false, err
}
done, progress, message := migrationProgress(migration)
status.Progress = progress
switch {
case message != "" && !done:
status.Phase = migrationv1alpha1.VMImportTaskPhaseFailed
status.Reason = migrationv1alpha1.ReasonTransferFailed
status.Message = message
return status, false, nil
case !done:
status.Phase = migrationv1alpha1.VMImportTaskPhaseTransferring
status.Message = ""
return status, true, nil
}
// The transfer is finished. Everything from here is local: adopt the
// volumes into VMDisks, build a VMInstance over them, and discard the
// KubeVirt VM Forklift made along the way.
status.Phase = migrationv1alpha1.VMImportTaskPhaseCreating
outputs, err := r.fulfill(ctx, task, req)
if err != nil {
var verr *validationError
if asValidationError(err, &verr) {
status.Phase = migrationv1alpha1.VMImportTaskPhaseFailed
status.Reason = verr.reason
status.Message = verr.message
return status, false, nil
}
return status, false, err
}
if outputs == nil {
status.Message = "creating the imported disks and instance"
return status, true, nil
}
status.Phase = migrationv1alpha1.VMImportTaskPhaseSucceeded
status.Progress = 100
status.VMInstance = outputs.VMInstance
status.Disks = outputs.Disks
status.Message = ""
return status, false, nil
}
// resolveStorageClass returns the class every disk of this task is imported
// onto, and rejects one that cannot complete an import.
func (r *VMImportTaskReconciler) resolveStorageClass(ctx context.Context, task *migrationv1alpha1.VMImportTask) (string, error) {
name := task.Spec.StorageClass
origin := "spec.storageClass"
if name == "" {
list := &storagev1.StorageClassList{}
if err := r.List(ctx, list); err != nil {
return "", err
}
for i := range list.Items {
if list.Items[i].Annotations[defaultStorageClassAnnotation] == "true" {
name = list.Items[i].Name
origin = "the cluster default StorageClass"
break
}
}
if name == "" {
return "", &validationError{
reason: migrationv1alpha1.ReasonStorageClassNotImmediate,
message: "no storageClass was given and this cluster has no default StorageClass",
}
}
}
sc := &storagev1.StorageClass{}
if err := r.Get(ctx, types.NamespacedName{Name: name}, sc); err != nil {
if apierrors.IsNotFound(err) {
return "", &validationError{
reason: migrationv1alpha1.ReasonStorageClassNotImmediate,
message: fmt.Sprintf("StorageClass %q (%s) does not exist", name, origin),
}
}
return "", err
}
if sc.VolumeBindingMode != nil && *sc.VolumeBindingMode == storagev1.VolumeBindingWaitForFirstConsumer {
return "", &validationError{
reason: migrationv1alpha1.ReasonStorageClassNotImmediate,
message: fmt.Sprintf(
"StorageClass %q (%s) binds WaitForFirstConsumer, which deadlocks an import: "+
"nothing consumes the volume while it is being populated. Create a new task "+
"naming a class with volumeBindingMode Immediate in spec.storageClass — this "+
"task is terminal and spec.storageClass is immutable, so editing it will not "+
"restart the import", name, origin),
}
}
return name, nil
}
// outputCollision reports a tenant object standing where this VM's output goes,
// or "" when the name is free or already holds this task's own earlier output.
//
// The distinction is the whole point. Without it every resume is a guess, and
// both guesses are destructive: treating a tenant's object as its own attaches
// stale data while the fresh transfer is collected with the scaffolding VM,
// and treating its own output as a collision terminally fails an import that
// actually succeeded. The marker isOwnOutput reads is written by stampOutput on
// everything this controller creates.
func (r *VMImportTaskReconciler) outputCollision(
ctx context.Context,
task *migrationv1alpha1.VMImportTask,
req *migrationv1alpha1.VMImportRequest,
) (string, error) {
if req.Name == "" {
// The instance is named after the imported VM, which is not known
// until the transfer finishes; the handoff checks it then.
return "", nil
}
existing := newObject(vmInstanceGVK)
err := r.Get(ctx, types.NamespacedName{Namespace: task.Namespace, Name: req.Name}, existing)
if err == nil {
if isOwnOutput(existing, task, req.ID) {
return "", nil
}
return 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", req.Name, req.ID), nil
}
if !apierrors.IsNotFound(err) && !meta.IsNoMatchError(err) {
return "", err
}
return "", nil
}
func (r *VMImportTaskReconciler) pending(ctx context.Context, task *migrationv1alpha1.VMImportTask, reason, message string) (ctrl.Result, error) {
task.Status.Phase = migrationv1alpha1.VMImportTaskPhasePending
task.Status.Message = message
task.Status.ObservedGeneration = task.Generation
meta.SetStatusCondition(&task.Status.Conditions, metav1.Condition{
Type: migrationv1alpha1.TaskValidatedCondition,
Status: metav1.ConditionFalse,
Reason: reason,
Message: message,
ObservedGeneration: task.Generation,
})
if err := r.Status().Update(ctx, task); err != nil && !apierrors.IsConflict(err) {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: ConnectionRequeue}, nil
}
// finalize removes the transfer volumes the engine created for this task, then
// releases the task for deletion.
//
// The engine creates its DataVolumes with no owner references — it tracks them
// by label instead — so deleting the task takes the Plan with it and leaves the
// volume behind, still retrying the transfer against the source. Nothing else
// ever collects it.
//
// Only the engine's own volumes match: they carry its `plan` label, while the
// disks an import produces are created by this controller with Helm metadata
// and no such label. A failure here releases the task anyway, because a task
// that cannot be deleted is worse than a volume that has to be removed by hand,
// and the leftover is named in the log either way.
func (r *VMImportTaskReconciler) finalize(ctx context.Context, task *migrationv1alpha1.VMImportTask) (ctrl.Result, error) {
logger := log.FromContext(ctx)
if controllerutil.ContainsFinalizer(task, migrationv1alpha1.TaskFinalizer) {
if err := r.deleteTransferVolumes(ctx, task); err != nil {
logger.Error(err, "could not remove every transfer volume; releasing the task regardless",
"task", task.Name, "namespace", task.Namespace)
}
controllerutil.RemoveFinalizer(task, migrationv1alpha1.TaskFinalizer)
if err := r.Update(ctx, task); err != nil {
if apierrors.IsConflict(err) || apierrors.IsNotFound(err) {
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil
}
// deleteTransferVolumes removes the DataVolumes the engine created for this
// task's Plans. The Plans are still present: they are owned by the task, and
// garbage collection only reaches them once the task itself is gone.
func (r *VMImportTaskReconciler) deleteTransferVolumes(ctx context.Context, task *migrationv1alpha1.VMImportTask) error {
logger := log.FromContext(ctx)
for i := range task.Spec.VMs {
plan := newObject(planGVK)
err := r.Get(ctx, types.NamespacedName{
Namespace: task.Namespace, Name: planName(task.Name, task.Spec.VMs[i].ID),
}, plan)
if err != nil {
if apierrors.IsNotFound(err) || meta.IsNoMatchError(err) {
continue
}
return err
}
uid := string(plan.GetUID())
if uid == "" {
continue
}
list := &unstructured.UnstructuredList{}
list.SetGroupVersionKind(dataVolumeGVK.GroupVersion().WithKind("DataVolumeList"))
if err := r.List(ctx, list,
client.InNamespace(task.Namespace),
client.MatchingLabels{"plan": uid},
); err != nil {
if meta.IsNoMatchError(err) {
return nil
}
return err
}
for j := range list.Items {
dv := &list.Items[j]
if err := r.Delete(ctx, dv); err != nil && !apierrors.IsNotFound(err) {
return err
}
logger.Info("removed the engine's transfer volume", "dataVolume", dv.GetName(), "vm", task.Spec.VMs[i].ID)
}
}
return nil
}
func (r *VMImportTaskReconciler) terminalFail(ctx context.Context, task *migrationv1alpha1.VMImportTask, reason, message string) (ctrl.Result, error) {
now := metav1.Now()
task.Status.Phase = migrationv1alpha1.VMImportTaskPhaseFailed
task.Status.Message = message
task.Status.CompletedAt = &now
task.Status.ObservedGeneration = task.Generation
meta.SetStatusCondition(&task.Status.Conditions, metav1.Condition{
Type: migrationv1alpha1.TaskValidatedCondition,
Status: metav1.ConditionFalse,
Reason: reason,
Message: message,
ObservedGeneration: task.Generation,
})
r.Recorder.Eventf(task, "Warning", reason, "%s", message)
if err := r.Status().Update(ctx, task); err != nil && !apierrors.IsConflict(err) {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
// aggregatePhase folds per-VM phases into the task's own. A task is only
// Succeeded when every VM is, and Failed only once no VM can still progress —
// one failed VM does not stop its siblings from finishing.
func aggregatePhase(statuses []migrationv1alpha1.VMImportStatus) migrationv1alpha1.VMImportTaskPhase {
if len(statuses) == 0 {
return migrationv1alpha1.VMImportTaskPhasePending
}
var succeeded, failed int
phase := migrationv1alpha1.VMImportTaskPhasePending
rank := map[migrationv1alpha1.VMImportTaskPhase]int{
migrationv1alpha1.VMImportTaskPhasePending: 0,
migrationv1alpha1.VMImportTaskPhaseValidating: 1,
migrationv1alpha1.VMImportTaskPhaseTransferring: 2,
migrationv1alpha1.VMImportTaskPhaseCreating: 3,
}
for _, s := range statuses {
switch s.Phase {
case migrationv1alpha1.VMImportTaskPhaseSucceeded:
succeeded++
case migrationv1alpha1.VMImportTaskPhaseFailed:
failed++
default:
if rank[s.Phase] >= rank[phase] {
phase = s.Phase
}
}
}
switch {
case succeeded == len(statuses):
return migrationv1alpha1.VMImportTaskPhaseSucceeded
case succeeded+failed == len(statuses):
return migrationv1alpha1.VMImportTaskPhaseFailed
default:
return phase
}
}
func terminalMessage(statuses []migrationv1alpha1.VMImportStatus) string {
var failed []string
for _, s := range statuses {
if s.Phase == migrationv1alpha1.VMImportTaskPhaseFailed {
failed = append(failed, fmt.Sprintf("%s: %s", s.ID, s.Message))
}
}
if len(failed) == 0 {
return fmt.Sprintf("imported %d VM(s)", len(statuses))
}
sort.Strings(failed)
return fmt.Sprintf("%d of %d VM(s) failed — %s", len(failed), len(statuses), strings.Join(failed, "; "))
}
func findVMStatus(statuses []migrationv1alpha1.VMImportStatus, id string) *migrationv1alpha1.VMImportStatus {
for i := range statuses {
if statuses[i].ID == id {
return &statuses[i]
}
}
return nil
}
func sourceNotReadyDetail(src *migrationv1alpha1.VMImportSource) (string, string) {
cond := meta.FindStatusCondition(src.Status.Conditions, migrationv1alpha1.SourceReadyCondition)
if cond == nil {
return migrationv1alpha1.ReasonSourceNotReady,
fmt.Sprintf("VMImportSource %q has not been reconciled yet", src.Name)
}
return migrationv1alpha1.ReasonSourceNotReady,
fmt.Sprintf("VMImportSource %q is not ready (%s): %s", src.Name, cond.Reason, cond.Message)
}
// validationError is a terminal, tenant-visible misconfiguration: retrying it
// unchanged cannot help, so the task fails with the reason attached instead of
// requeueing forever.
type validationError struct {
reason string
message string
}
func (e *validationError) Error() string { return e.message }
func asValidationError(err error, target **validationError) bool {
return errors.As(err, target)
}
func (r *VMImportTaskReconciler) SetupWithManager(mgr ctrl.Manager) error {
// Deliberately no watch on Forklift's Migration, even though it is what
// drives a task's progress.
//
// Forklift is an optional package. A watch on a kind whose CRD is absent
// never syncs its cache, and controller-runtime will not start a controller
// whose caches have not synced — so on a cluster without Forklift the whole
// task controller stays down and the manager never reports ready. Observed
// on a live cluster: the pod sat at 0/1 indefinitely, logging "if kind is a
// CRD, it should be installed before calling Start" every ten seconds.
//
// Polling instead costs at most TransferRequeue of latency on a transfer
// measured in minutes, and it works whether Forklift is installed before
// this controller, after it, or never.
return ctrl.NewControllerManagedBy(mgr).
For(&migrationv1alpha1.VMImportTask{}).
Named("vmimporttask").
Complete(r)
}