feat(migration): tenant self-service VM import from VMware vSphere

Hidora's customers are leaving VMware now, and a tenant has to be able to
import their own machines without a platform administrator. The chart
approach could not express that: a tenant can neither create the Secret
holding vCenter credentials nor name the VDDK image, so both had to come
from somewhere they cannot reach.

Two CRDs in a new `forklift.cozystack.io` group, split by lifecycle. A
VMImportSource is a long-lived connection -- type, endpoint, credentials,
readiness -- and a VMImportTask is the one-shot operation that names
machines and produces VMDisks and VMInstances. The outputs carry no owner
reference back to the task, so deleting a finished import leaves the
machines alone. Konveyor Forklift does the transfer underneath; the
controller renders its objects, mirrors its verdicts, and hands each
finished volume into a VMDisk without copying it.

What a live vCenter migration forced out, and no amount of source reading
would have:

- A guest copied verbatim keeps the drivers it had under VMware and no
  virtio, so on a virtio disk it does not fail the import -- it succeeds
  and then does not boot. Imported disks go on SATA.
- VDDK connects straight to the ESXi host at the address vCenter
  advertises, which is routinely unreachable, and on the first cluster
  collided with the Service CIDR. `spec.hosts` redirects per host.
- Firmware must be carried across or a UEFI guest imports as BIOS and
  never boots (stacked on #3002).
- Deleting a task mid-transfer orphaned its DataVolume; a finalizer now
  removes the engine's volumes and leaves the outputs.

The console gains a Migration section: connections are editable, and the
VM field is a picker built from a machine list the controller publishes,
since the aggregated API cannot reach the inventory and should not hold a
source's credentials to try. Two general mechanisms fall out of that --
an option source may take an argument, and one named in a CRD annotation
may reference a sibling field.

Design: cozystack/community#62. Docs: cozystack/website#673.

Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
This commit is contained in:
Andrei Kvapil committed 2026-09-07 17:03:44 +02:00
1 parent d000d02b99
commit 54621bc046
78 files changed
+16647 -52

No files matched your search

+1
View File
@@ -149,6 +149,7 @@ jobs:
'cdi': 'area/virtualization',
'vmi': 'area/virtualization',
'vm-import': 'area/virtualization',
'migration': 'area/virtualization',
'virtual-machine': 'area/virtualization',
'hami': 'area/virtualization',
'gpu-operator': 'area/virtualization',
+12
View File
@@ -53,6 +53,9 @@ type ConfigSpec struct {
// Model specifies the CPU model inside the VMI. List of available models https://github.com/libvirt/libvirt/tree/master/src/cpu_map
// +kubebuilder:default:=""
CpuModel string `json:"cpuModel"`
// Firmware and boot configuration (UEFI/BIOS selection, Secure Boot, persistent EFI NVRAM).
// +kubebuilder:default:={}
Firmware Firmware `json:"firmware,omitempty"`
// Resource configuration for the virtual machine.
// +kubebuilder:default:={}
Resources Resources `json:"resources,omitempty"`
@@ -74,6 +77,15 @@ type Disk struct {
Name string `json:"name"`
}
type Firmware struct {
// Bootloader to boot the VM with: "uefi" (OVMF) or "bios" (SeaBIOS). Empty inherits the instanceProfile default.
Bootloader string `json:"bootloader,omitempty"`
// Persist EFI NVRAM (e.g. enrolled Secure Boot keys, such as an updated Microsoft UEFI CA) across reboots. Only applies when bootloader is "uefi". On default RWO storage the VM is node-pinned (no live-migration).
EfiPersistent bool `json:"efiPersistent,omitempty"`
// Enable UEFI Secure Boot. Only applies when bootloader is "uefi".
SecureBoot bool `json:"secureBoot,omitempty"`
}
type GPU struct {
// The name of the GPU resource to attach.
Name string `json:"name"`
+16
View File
@@ -78,6 +78,7 @@ func (in *ConfigSpec) DeepCopyInto(out *ConfigSpec) {
*out = make([]GPU, len(*in))
copy(*out, *in)
}
out.Firmware = in.Firmware
in.Resources.DeepCopyInto(&out.Resources)
if in.SshKeys != nil {
in, out := &in.SshKeys, &out.SshKeys
@@ -111,6 +112,21 @@ func (in *Disk) DeepCopy() *Disk {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Firmware) DeepCopyInto(out *Firmware) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Firmware.
func (in *Firmware) DeepCopy() *Firmware {
if in == nil {
return nil
}
out := new(Firmware)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *GPU) DeepCopyInto(out *GPU) {
*out = *in
@@ -0,0 +1,42 @@
/*
Copyright 2026.
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.
*/
// Package v1alpha1 contains API Schema definitions for the v1alpha1 API group.
// +kubebuilder:object:generate=true
// +groupName=forklift.cozystack.io
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
)
const (
thisGroup = "forklift.cozystack.io"
thisVersion = "v1alpha1"
)
var (
GroupVersion = schema.GroupVersion{Group: thisGroup, Version: thisVersion}
SchemeBuilder = runtime.NewSchemeBuilder(addGroupVersion)
AddToScheme = SchemeBuilder.AddToScheme
)
func addGroupVersion(scheme *runtime.Scheme) error {
metav1.AddToGroupVersion(scheme, GroupVersion)
return nil
}
@@ -0,0 +1,224 @@
// SPDX-License-Identifier: Apache-2.0
// Package v1alpha1 defines forklift.cozystack.io API types.
//
// Group: forklift.cozystack.io
// Version: v1alpha1
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
func init() {
SchemeBuilder.Register(func(s *runtime.Scheme) error {
s.AddKnownTypes(GroupVersion,
&VMImportSource{},
&VMImportSourceList{},
)
return nil
})
}
const (
// ManagedByLabel marks the objects the migration controller owns, so a
// projected Secret can never overwrite one the controller did not create.
ManagedByLabel = "apps.cozystack.io/managed-by"
// ManagedByValue is the value ManagedByLabel carries on our objects.
ManagedByValue = "cozystack-migration"
// SourceReadyCondition reports whether the provider endpoint answered and
// everything the platform must supply for this provider type is in place.
SourceReadyCondition = "Ready"
)
// Condition reasons carried on a VMImportSource.
const (
// ReasonConnected means the provider endpoint answered an authenticated call.
ReasonConnected = "Connected"
// ReasonConnectionFailed means the endpoint was unreachable or rejected the credentials.
ReasonConnectionFailed = "ConnectionFailed"
// ReasonCredentialsMissing means the credentials Secret could not be projected.
ReasonCredentialsMissing = "CredentialsMissing"
// ReasonVDDKNotConfigured means a vSphere source was created on a cluster
// whose administrator has not supplied a VDDK image. The VMware path is
// unavailable rather than broken: see the platform's migration settings.
ReasonVDDKNotConfigured = "VDDKNotConfigured"
// ReasonForkliftNotInstalled means the migration engine's CRDs are absent.
// The controller ships independently of Forklift, so this is a normal state
// on a cluster that has enabled one package and not the other.
ReasonForkliftNotInstalled = "ForkliftNotInstalled"
)
// InventoryTruncatedAnnotation marks a published VM list that was cut short
// because the source holds more machines than one object should carry. A picker
// missing an entry then has a visible reason rather than looking broken.
const InventoryTruncatedAnnotation = thisGroup + "/inventory-truncated"
// The contract between the controller that publishes a source's machine list
// and the aggregated API that serves it to the console's picker. It lives here
// because both sides must agree on it and neither owns the other.
const (
// inventoryConfigMapPrefix makes the object's name derivable from the
// source alone, so a reader addresses it without a lookup.
inventoryConfigMapPrefix = "vmimport-inventory-"
// InventoryVMsKey holds the JSON array of PublishedVM.
InventoryVMsKey = "vms"
)
// InventoryConfigMapName is where a source's published machine list lives.
func InventoryConfigMapName(source string) string {
return inventoryConfigMapPrefix + source
}
// PublishedVM is one entry of that list: deliberately the smallest thing a
// picker needs, a value to write and a name to show.
type PublishedVM struct {
// ID is the managed-object reference, which is what a task records.
ID string `json:"id"`
// Name is the machine's name as the source knows it.
Name string `json:"name"`
// Path distinguishes two machines sharing a name in different folders.
Path string `json:"path,omitempty"`
}
// ProviderType names a source virtualization platform.
// +kubebuilder:validation:Enum=vsphere
type ProviderType string
const (
// ProviderVSphere is VMware vSphere, reached through its vCenter SDK endpoint.
ProviderVSphere ProviderType = "vsphere"
)
// ProviderCredentials holds the account the platform uses to read the source
// inventory and the disk data. The controller materializes these into a Secret;
// a tenant never creates a Kubernetes Secret itself.
type ProviderCredentials struct {
// Username is the account used to authenticate against the provider
// (e.g. `migration@vsphere.local`).
// +kubebuilder:validation:MinLength=1
Username string `json:"username"`
// Password is the account's password.
// +kubebuilder:validation:MinLength=1
Password string `json:"password"`
// CACert is the PEM-encoded certificate authority that signed the
// endpoint's TLS certificate. Required unless insecureSkipVerify is set:
// the migration engine refuses a vSphere connection that can neither
// verify the endpoint nor was told not to.
//
// This is a CA certificate, not a fingerprint. A SHA-1 thumbprint is what
// the engine wants for a direct ESXi host connection, which v1 does not
// expose, and supplying one here does not satisfy the check.
// +optional
CACert string `json:"caCert,omitempty"`
// InsecureSkipVerify disables verification of the endpoint's TLS
// certificate. Convenient in a lab and a bad idea anywhere else: the
// credentials above travel over that connection. Prefer caCert.
// +optional
InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"`
}
// VMImportSourceSpec describes a connection to a source virtualization platform.
type VMImportSourceSpec struct {
// Type is the source platform this connection speaks to.
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="type is immutable"
Type ProviderType `json:"type"`
// URL is the provider's API endpoint (e.g. `https://vcenter.example.com/sdk`).
// +kubebuilder:validation:MinLength=1
URL string `json:"url"`
// Credentials is the account used to reach the provider.
// +kubebuilder:validation:XValidation:rule="has(self.caCert) || (has(self.insecureSkipVerify) && self.insecureSkipVerify)",message="either caCert must be set or insecureSkipVerify must be true: the migration engine will not open a vSphere connection it can neither verify nor was told to trust"
Credentials ProviderCredentials `json:"credentials"`
// Hosts redirects the disk transfer for individual ESXi hosts.
//
// Disk data does not travel through vCenter. VDDK opens its connection
// straight to the ESXi host holding the VM, at whatever address vCenter
// advertises for it — and that address is frequently one the cluster
// cannot reach: a management network the workers are not on, or, worse,
// one that collides with the cluster's own Service CIDR, where the packet
// is swallowed by service routing and never leaves. The transfer then
// fails late, after validation has passed, with an NBD error naming
// neither the address nor the reason.
//
// An entry here names the host and the address to use instead. Each
// carries its own credentials because the ESXi host authenticates the
// connection itself rather than honouring the vCenter session, and the
// engine connection-tests them before the transfer starts.
//
// +optional
// +listType=map
// +listMapKey=id
Hosts []HostOverride `json:"hosts,omitempty"`
}
// HostOverride redirects the disk transfer for one ESXi host.
type HostOverride struct {
// ID is the host's managed-object reference in the source inventory
// (e.g. `host-10`). It is what the VM's inventory record names as its
// host, not the hostname.
// +kubebuilder:validation:MinLength=1
ID string `json:"id"`
// Address is the address the cluster should use to reach this host for the
// disk transfer, replacing the one vCenter advertises.
// +kubebuilder:validation:MinLength=1
Address string `json:"address"`
// Credentials is the account on the ESXi host itself. The host
// authenticates the transfer connection directly, so the provider's
// vCenter account does not carry over.
// +kubebuilder:validation:XValidation:rule="has(self.caCert) || (has(self.insecureSkipVerify) && self.insecureSkipVerify)",message="either caCert must be set or insecureSkipVerify must be true: the migration engine will not open a connection to the host it can neither verify nor was told to trust"
Credentials ProviderCredentials `json:"credentials"`
}
// VMImportSourceStatus reports whether the connection is usable.
type VMImportSourceStatus struct {
// ObservedGeneration is the .metadata.generation the conditions were computed from.
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// Conditions represents the latest available observations of the connection's state.
// The Ready condition is the connection's readiness: it is true only once the
// endpoint has answered and every platform prerequisite for this provider
// type is satisfied.
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:shortName=vmis;vmimportsrc
// +kubebuilder:printcolumn:name="Type",type="string",JSONPath=".spec.type",priority=0
// +kubebuilder:printcolumn:name="URL",type="string",JSONPath=".spec.url",priority=0
// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status",priority=0
// +kubebuilder:printcolumn:name="Reason",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].reason",priority=1
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",priority=0
// +kubebuilder:selectablefield:JSONPath=`.spec.type`
// VMImportSource registers a reusable connection to a source virtualization
// platform. It is long-lived: VMImportTasks reference it, and deleting it
// deregisters the connection without touching anything already imported.
type VMImportSource struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec VMImportSourceSpec `json:"spec,omitempty"`
Status VMImportSourceStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
// VMImportSourceList contains a list of VMImportSources.
type VMImportSourceList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []VMImportSource `json:"items"`
}
@@ -0,0 +1,276 @@
// SPDX-License-Identifier: Apache-2.0
// Package v1alpha1 defines forklift.cozystack.io API types.
//
// Group: forklift.cozystack.io
// Version: v1alpha1
package v1alpha1
import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
func init() {
SchemeBuilder.Register(func(s *runtime.Scheme) error {
s.AddKnownTypes(GroupVersion,
&VMImportTask{},
&VMImportTaskList{},
)
return nil
})
}
const (
// OwningTaskNameLabel and OwningTaskNamespaceLabel mark the Forklift objects
// a task owns, so the controller can find its own scaffolding without
// trusting a label an unrelated object might carry.
OwningTaskNameLabel = thisGroup + "/owned-by.VMImportTaskName"
OwningTaskNamespaceLabel = thisGroup + "/owned-by.VMImportTaskNamespace"
// OutputTaskUIDLabel and OutputVMIDLabel mark what a task produced: the
// VMDisks, the VMInstances and the claims behind them. They are how the
// controller tells its own earlier output from a tenant object that happens
// to carry the same name, which every resume has to decide and cannot
// otherwise know. Mistaking a tenant's disk for its own silently attaches
// stale data while the fresh transfer is collected with the scaffolding VM;
// mistaking its own output for a collision terminally fails an import that
// in fact succeeded.
//
// Deliberately labels, not owner references: the outputs of a task outlive
// the task, so nothing here may create a garbage-collection edge back to it.
// The UID rather than the name, because names are reused.
OutputTaskUIDLabel = thisGroup + "/output-of.TaskUID"
OutputVMIDLabel = thisGroup + "/output-of.VMID"
// TaskFinalizer holds a deleted task open long enough to remove the
// transfer volumes the engine created for it.
//
// Everything else a task owns goes with it through owner references, but
// the engine's own DataVolumes carry none: it manages them by label, and
// when the Plan they name is deleted there is nothing left to collect them.
// A failed import that is then deleted therefore leaves a DataVolume
// retrying the transfer against the source forever — observed live, still
// running 37 minutes and 8 restarts after its task was gone.
//
// This finalizer does not contradict the outputs-outlive-the-task property:
// it removes scaffolding, and never touches a VMDisk, VMInstance or the
// claims behind them.
TaskFinalizer = thisGroup + "/cleanup-transfer-volumes"
// TaskValidatedCondition reports whether the task's inputs resolved: the
// source is ready, every named VM exists in the inventory, and the storage
// class can actually complete an import.
TaskValidatedCondition = "Validated"
)
// Condition reasons carried on a VMImportTask.
const (
// ReasonSourceNotReady means the referenced VMImportSource is absent or unusable.
ReasonSourceNotReady = "SourceNotReady"
// ReasonVMNotFound means a named VM is absent from the source inventory.
ReasonVMNotFound = "VMNotFound"
// ReasonStorageClassNotImmediate means the resolved StorageClass binds
// WaitForFirstConsumer, which deadlocks an import: nothing consumes the
// claim while it is being populated.
ReasonStorageClassNotImmediate = "StorageClassNotImmediate"
// ReasonOutputExists means an output object of the requested name is
// already present. The controller never overwrites tenant objects.
ReasonOutputExists = "OutputExists"
// ReasonTransferFailed means Forklift reported the migration as failed.
ReasonTransferFailed = "TransferFailed"
// ReasonPlanRejected means Forklift refused the Plan before any transfer
// began — a reference it could not resolve, or a provider it could not use.
// The message carries Forklift's own words.
ReasonPlanRejected = "PlanRejected"
// ReasonCompleted means every VM of the task was imported.
ReasonCompleted = "Completed"
)
// VMImportTaskPhase represents the lifecycle phase of a VMImportTask.
// +kubebuilder:validation:Enum="";Pending;Validating;Transferring;Creating;Succeeded;Failed
type VMImportTaskPhase string
const (
VMImportTaskPhaseEmpty VMImportTaskPhase = ""
// VMImportTaskPhasePending means the task has not started: usually its
// source is not ready yet.
VMImportTaskPhasePending VMImportTaskPhase = "Pending"
// VMImportTaskPhaseValidating means inputs are being resolved against the
// source inventory. Nothing has been created yet.
VMImportTaskPhaseValidating VMImportTaskPhase = "Validating"
// VMImportTaskPhaseTransferring means disk data is moving.
VMImportTaskPhaseTransferring VMImportTaskPhase = "Transferring"
// VMImportTaskPhaseCreating means transfers finished and the Cozystack
// objects are being created over the transferred volumes.
VMImportTaskPhaseCreating VMImportTaskPhase = "Creating"
// VMImportTaskPhaseSucceeded means every VM was imported.
VMImportTaskPhaseSucceeded VMImportTaskPhase = "Succeeded"
// VMImportTaskPhaseFailed means the task stopped with at least one VM unimported.
VMImportTaskPhaseFailed VMImportTaskPhase = "Failed"
)
// IsTerminal reports whether a phase is an end state that will not change again.
func (p VMImportTaskPhase) IsTerminal() bool {
return p == VMImportTaskPhaseSucceeded || p == VMImportTaskPhaseFailed
}
// VMImportRequest names one source VM to import and what to call the result.
type VMImportRequest struct {
// ID is the VM's managed-object reference in the source inventory
// (e.g. `vm-1234` on vSphere). It appears in the vSphere client's URL, and
// `govc ls -i` prints it.
// +kubebuilder:validation:MinLength=1
ID string `json:"id"`
// Name is the name of the VMInstance to create. Its disks are named after
// it. Must be a valid Kubernetes object name; defaults to the source VM's
// name normalized to one when omitted.
//
// Capped at 46 rather than the usual 63 because the name is not used alone:
// each disk becomes `vm-disk-<name>-disk-<i>`, and that whole string is
// stamped as the DataVolume's `app.kubernetes.io/instance` label value,
// which Kubernetes limits to 63 characters. The fixed parts cost 14
// (`vm-disk-` plus `-disk-`), leaving 49 to share between the name and the
// disk index; 46 keeps a VM with up to a thousand disks inside the limit.
// Overflowing it does not fail cleanly — the DataVolume is simply rejected
// after the transfer has already run.
// +optional
// +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`
// +kubebuilder:validation:MaxLength=46
Name string `json:"name,omitempty"`
// InstanceType is the Cozystack instance type for the created VMInstance.
// When omitted the controller sets explicit resources matching the source
// VM's CPU and memory, so an import never silently resizes a guest.
// +optional
InstanceType string `json:"instanceType,omitempty"`
// InstanceProfile is the Cozystack instance profile (guest preference set)
// for the created VMInstance.
// +optional
InstanceProfile string `json:"instanceProfile,omitempty"`
}
// VMImportTaskSpec describes a one-shot import of one or more VMs.
type VMImportTaskSpec struct {
// SourceRef names the VMImportSource to import from. It must live in this
// task's own namespace.
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="sourceRef is immutable"
SourceRef corev1.LocalObjectReference `json:"sourceRef"`
// VMs lists the source VMs to import. Each produces one VMInstance over
// one VMDisk per source disk.
// +kubebuilder:validation:MinItems=1
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="vms is immutable"
VMs []VMImportRequest `json:"vms"`
// StorageClass is the StorageClass every disk of this task is imported
// onto. It must bind Immediate: a WaitForFirstConsumer class deadlocks the
// import, because nothing consumes the claim while it is populated. Empty
// uses the cluster default, which is validated the same way.
// +optional
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="storageClass is immutable"
StorageClass string `json:"storageClass,omitempty"`
}
// VMImportStatus reports the progress of one VM within a task.
type VMImportStatus struct {
// ID is the source VM's managed-object reference, matching the request.
ID string `json:"id"`
// Name is the name of the VMInstance being created for this VM.
// +optional
Name string `json:"name,omitempty"`
// Phase is this VM's own lifecycle phase; a task's phase aggregates these.
// +optional
Phase VMImportTaskPhase `json:"phase,omitempty"`
// Progress is the percentage of disk data transferred, 0-100.
// +optional
// +kubebuilder:validation:Minimum=0
// +kubebuilder:validation:Maximum=100
Progress int32 `json:"progress,omitempty"`
// VMInstance is the name of the created VMInstance, once it exists.
// +optional
VMInstance string `json:"vmInstance,omitempty"`
// Disks lists the names of the created VMDisks, in the source VM's disk order.
// +optional
Disks []string `json:"disks,omitempty"`
// Reason is a machine-readable cause for this VM's phase, from the same set
// the task's conditions use. Message says the same thing to a person; a
// caller deciding whether a failure is worth retrying should read this.
// +optional
Reason string `json:"reason,omitempty"`
// Message explains this VM's current phase when it needs explaining.
// +optional
Message string `json:"message,omitempty"`
}
// VMImportTaskStatus represents the observed state of a VMImportTask.
type VMImportTaskStatus struct {
// Phase is a high-level summary of the task's state.
// +optional
Phase VMImportTaskPhase `json:"phase,omitempty"`
// VMs reports per-VM progress, one entry per spec.vms entry.
// +optional
VMs []VMImportStatus `json:"vms,omitempty"`
// StartedAt is the time the task began validating.
// +optional
StartedAt *metav1.Time `json:"startedAt,omitempty"`
// CompletedAt is the time the task reached a terminal phase.
// +optional
CompletedAt *metav1.Time `json:"completedAt,omitempty"`
// Message is a human-readable message about the current phase.
// +optional
Message string `json:"message,omitempty"`
// ObservedGeneration is the .metadata.generation the status was computed from.
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// Conditions represents the latest available observations of the task's state.
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:shortName=vmit;vmimporttask
// +kubebuilder:printcolumn:name="Source",type="string",JSONPath=".spec.sourceRef.name",priority=0
// +kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase",priority=0
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",priority=0
// +kubebuilder:printcolumn:name="Message",type="string",JSONPath=".status.message",priority=1
// +kubebuilder:selectablefield:JSONPath=`.spec.sourceRef.name`
// +kubebuilder:selectablefield:JSONPath=`.status.phase`
// +kubebuilder:metadata:annotations={"options.cozystack.io/source.sourceRef.name=vmimportsource","options.cozystack.io/source.storageClass=storageclass","options.cozystack.io/source.vms.instanceType=instancetype","options.cozystack.io/source.vms.instanceProfile=instanceprofile","options.cozystack.io/source.vms.id=vmimportvm.{sourceRef.name}"}
// +kubebuilder:validation:XValidation:rule="self.metadata.name.size() <= 63",message="metadata.name must be at most 63 characters: the task's name is carried as a label value on the Forklift objects it owns, and Kubernetes limits label values to 63"
// VMImportTask runs a one-shot import of VMs from a registered source. It
// produces VMDisks and VMInstances that outlive it: deleting the task removes
// the migration machinery and leaves everything already imported in place.
type VMImportTask struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec VMImportTaskSpec `json:"spec,omitempty"`
Status VMImportTaskStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
// VMImportTaskList contains a list of VMImportTasks.
type VMImportTaskList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []VMImportTask `json:"items"`
}
+326
View File
@@ -0,0 +1,326 @@
//go:build !ignore_autogenerated
/*
Copyright 2025 The Cozystack Authors.
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 controller-gen. DO NOT EDIT.
package v1alpha1
import (
"k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *HostOverride) DeepCopyInto(out *HostOverride) {
*out = *in
out.Credentials = in.Credentials
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostOverride.
func (in *HostOverride) DeepCopy() *HostOverride {
if in == nil {
return nil
}
out := new(HostOverride)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ProviderCredentials) DeepCopyInto(out *ProviderCredentials) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderCredentials.
func (in *ProviderCredentials) DeepCopy() *ProviderCredentials {
if in == nil {
return nil
}
out := new(ProviderCredentials)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PublishedVM) DeepCopyInto(out *PublishedVM) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PublishedVM.
func (in *PublishedVM) DeepCopy() *PublishedVM {
if in == nil {
return nil
}
out := new(PublishedVM)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *VMImportRequest) DeepCopyInto(out *VMImportRequest) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VMImportRequest.
func (in *VMImportRequest) DeepCopy() *VMImportRequest {
if in == nil {
return nil
}
out := new(VMImportRequest)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *VMImportSource) DeepCopyInto(out *VMImportSource) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VMImportSource.
func (in *VMImportSource) DeepCopy() *VMImportSource {
if in == nil {
return nil
}
out := new(VMImportSource)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *VMImportSource) 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 *VMImportSourceList) DeepCopyInto(out *VMImportSourceList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]VMImportSource, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VMImportSourceList.
func (in *VMImportSourceList) DeepCopy() *VMImportSourceList {
if in == nil {
return nil
}
out := new(VMImportSourceList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *VMImportSourceList) 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 *VMImportSourceSpec) DeepCopyInto(out *VMImportSourceSpec) {
*out = *in
out.Credentials = in.Credentials
if in.Hosts != nil {
in, out := &in.Hosts, &out.Hosts
*out = make([]HostOverride, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VMImportSourceSpec.
func (in *VMImportSourceSpec) DeepCopy() *VMImportSourceSpec {
if in == nil {
return nil
}
out := new(VMImportSourceSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *VMImportSourceStatus) DeepCopyInto(out *VMImportSourceStatus) {
*out = *in
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VMImportSourceStatus.
func (in *VMImportSourceStatus) DeepCopy() *VMImportSourceStatus {
if in == nil {
return nil
}
out := new(VMImportSourceStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *VMImportStatus) DeepCopyInto(out *VMImportStatus) {
*out = *in
if in.Disks != nil {
in, out := &in.Disks, &out.Disks
*out = make([]string, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VMImportStatus.
func (in *VMImportStatus) DeepCopy() *VMImportStatus {
if in == nil {
return nil
}
out := new(VMImportStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *VMImportTask) DeepCopyInto(out *VMImportTask) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VMImportTask.
func (in *VMImportTask) DeepCopy() *VMImportTask {
if in == nil {
return nil
}
out := new(VMImportTask)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *VMImportTask) 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 *VMImportTaskList) DeepCopyInto(out *VMImportTaskList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]VMImportTask, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VMImportTaskList.
func (in *VMImportTaskList) DeepCopy() *VMImportTaskList {
if in == nil {
return nil
}
out := new(VMImportTaskList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *VMImportTaskList) 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 *VMImportTaskSpec) DeepCopyInto(out *VMImportTaskSpec) {
*out = *in
out.SourceRef = in.SourceRef
if in.VMs != nil {
in, out := &in.VMs, &out.VMs
*out = make([]VMImportRequest, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VMImportTaskSpec.
func (in *VMImportTaskSpec) DeepCopy() *VMImportTaskSpec {
if in == nil {
return nil
}
out := new(VMImportTaskSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *VMImportTaskStatus) DeepCopyInto(out *VMImportTaskStatus) {
*out = *in
if in.VMs != nil {
in, out := &in.VMs, &out.VMs
*out = make([]VMImportStatus, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
if in.StartedAt != nil {
in, out := &in.StartedAt, &out.StartedAt
*out = (*in).DeepCopy()
}
if in.CompletedAt != nil {
in, out := &in.CompletedAt, &out.CompletedAt
*out = (*in).DeepCopy()
}
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VMImportTaskStatus.
func (in *VMImportTaskStatus) DeepCopy() *VMImportTaskStatus {
if in == nil {
return nil
}
out := new(VMImportTaskStatus)
in.DeepCopyInto(out)
return out
}
+203
View File
@@ -0,0 +1,203 @@
/*
Copyright 2026.
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.
*/
// Command migration-controller reconciles the forklift.cozystack.io API group:
// VMImportSource connections and the VMImportTask operations that run over them.
package main
import (
"crypto/tls"
"flag"
"fmt"
"os"
corev1 "k8s.io/api/core/v1"
storagev1 "k8s.io/api/storage/v1"
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
"sigs.k8s.io/controller-runtime/pkg/webhook"
migrationv1alpha1 "github.com/cozystack/cozystack/api/migration/v1alpha1"
"github.com/cozystack/cozystack/internal/migrationcontroller"
)
var (
scheme = runtime.NewScheme()
setupLog = ctrl.Log.WithName("setup")
)
func init() {
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
utilruntime.Must(migrationv1alpha1.AddToScheme(scheme))
// Forklift, KubeVirt and CDI objects are handled as unstructured, so their
// types are deliberately absent from the scheme: Forklift is an optional
// package and this controller must start cleanly on a cluster where its
// CRDs are not installed at all.
}
// envOr returns the environment variable's value, or a fallback when unset.
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func main() {
var metricsAddr string
var probeAddr string
var enableLeaderElection bool
var secureMetrics bool
var enableHTTP2 bool
var vddkImage string
var forkliftNamespace string
var inventoryURL string
var tlsOpts []func(*tls.Config)
flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+
"Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.")
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.")
flag.BoolVar(&enableLeaderElection, "leader-elect", false,
"Enable leader election for controller manager. "+
"Enabling this will ensure there is only one active controller manager.")
flag.BoolVar(&secureMetrics, "metrics-secure", true,
"If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.")
flag.BoolVar(&enableHTTP2, "enable-http2", false,
"If set, HTTP/2 will be enabled for the metrics and webhook servers")
flag.StringVar(&vddkImage, "vddk-image", os.Getenv("MIGRATION_VDDK_IMAGE"),
"Container image carrying the VMware Virtual Disk Development Kit. Platform "+
"configuration, never a tenant value: the image is built from a proprietary "+
"SDK that Cozystack cannot ship, so an operator who holds a licence builds it "+
"and names it here. Empty means the vSphere import path is unavailable, which "+
"vSphere sources report on their own status.")
flag.StringVar(&forkliftNamespace, "forklift-namespace", envOr("MIGRATION_FORKLIFT_NAMESPACE", "cozy-forklift"),
"Namespace Forklift runs in. The controller reads the inventory service's CA "+
"from its serving-certificate secret there.")
flag.StringVar(&inventoryURL, "inventory-url", os.Getenv("MIGRATION_INVENTORY_URL"),
"Base URL of Forklift's inventory REST service. Defaults to "+
"https://forklift-inventory.<forklift-namespace>.svc:8443. The controller "+
"reads the source's networks and datastores from it: those identifiers "+
"appear on no Forklift custom resource, so the Plan's maps cannot be built "+
"without it.")
opts := zap.Options{Development: false}
opts.BindFlags(flag.CommandLine)
flag.Parse()
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
// HTTP/2 stays off by default: it carries the Stream Cancellation and Rapid
// Reset CVEs and nothing here needs it.
if !enableHTTP2 {
tlsOpts = append(tlsOpts, func(c *tls.Config) {
setupLog.Info("disabling http/2")
c.NextProtos = []string{"http/1.1"}
})
}
metricsServerOptions := metricsserver.Options{
BindAddress: metricsAddr,
SecureServing: secureMetrics,
TLSOpts: tlsOpts,
}
config := ctrl.GetConfigOrDie()
config.QPS = 50.0
config.Burst = 100
mgr, err := ctrl.NewManager(config, ctrl.Options{
Scheme: scheme,
Metrics: metricsServerOptions,
WebhookServer: webhook.NewServer(webhook.Options{TLSOpts: tlsOpts}),
HealthProbeBindAddress: probeAddr,
LeaderElection: enableLeaderElection,
LeaderElectionID: "forklift.cozystack.io",
Client: client.Options{
// Secrets and volumes are read and written point-wise across
// arbitrary tenant namespaces. A cluster-wide informer for them
// would need list/watch RBAC this package deliberately does not
// grant, and would hold every tenant's Secret in memory. Direct
// apiserver calls keep the blast radius to the namespaces actually
// being reconciled.
Cache: &client.CacheOptions{
DisableFor: []client.Object{
&corev1.Secret{},
&corev1.PersistentVolume{},
&corev1.PersistentVolumeClaim{},
&storagev1.StorageClass{},
},
},
},
})
if err != nil {
setupLog.Error(err, "unable to start manager")
os.Exit(1)
}
if vddkImage == "" {
setupLog.Info("no VDDK image configured; the vSphere import path will report itself unavailable")
}
if inventoryURL == "" {
inventoryURL = fmt.Sprintf("https://forklift-inventory.%s.svc:8443", forkliftNamespace)
}
// One client for both reconcilers: the task reads a VM's topology, the
// source publishes the machine list the console's picker offers.
inventory := migrationcontroller.NewInventoryClient(
inventoryURL, forkliftNamespace, mgr.GetAPIReader())
if err = (&migrationcontroller.VMImportSourceReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Recorder: mgr.GetEventRecorderFor("migration-controller"),
VDDKImage: vddkImage,
Inventory: inventory,
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "VMImportSource")
os.Exit(1)
}
if err = (&migrationcontroller.VMImportTaskReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Recorder: mgr.GetEventRecorderFor("migration-controller"),
Inventory: inventory,
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "VMImportTask")
os.Exit(1)
}
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
setupLog.Error(err, "unable to set up health check")
os.Exit(1)
}
if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
setupLog.Error(err, "unable to set up ready check")
os.Exit(1)
}
setupLog.Info("starting manager")
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
setupLog.Error(err, "problem running manager")
os.Exit(1)
}
}
+3 -1
View File
@@ -52,6 +52,7 @@ COZY_CONTROLLER_CRDDIR=packages/system/cozystack-controller/definitions
COZY_RD_CRDDIR=packages/system/application-definition-crd/definition
BACKUPS_CORE_CRDDIR=packages/system/backup-controller/definitions
BACKUPSTRATEGY_CRDDIR=packages/system/backupstrategy-controller/definitions
MIGRATION_CRDDIR=packages/system/migration-controller/definitions
trap 'rm -rf ${TMPDIR}' EXIT
@@ -91,7 +92,7 @@ kube::codegen::gen_client \
"${SCRIPT_ROOT}/pkg/apis"
$CONTROLLER_GEN object:headerFile="hack/boilerplate.go.txt" paths="./api/..."
$CONTROLLER_GEN rbac:roleName=manager-role crd paths="./api/v1alpha1/..." paths="./api/backups/..." paths="./api/gateway/..." paths="./api/internalapi/..." output:crd:artifacts:config=${TMPDIR}
$CONTROLLER_GEN rbac:roleName=manager-role crd paths="./api/v1alpha1/..." paths="./api/backups/..." paths="./api/gateway/..." paths="./api/internalapi/..." paths="./api/migration/..." output:crd:artifacts:config=${TMPDIR}
mv ${TMPDIR}/cozystack.io_packages.yaml ${OPERATOR_CRDDIR}/cozystack.io_packages.yaml
mv ${TMPDIR}/cozystack.io_packagesources.yaml ${OPERATOR_CRDDIR}/cozystack.io_packagesources.yaml
@@ -101,6 +102,7 @@ mv ${TMPDIR}/cozystack.io_applicationdefinitions.yaml \
mv ${TMPDIR}/backups.cozystack.io*.yaml ${BACKUPS_CORE_CRDDIR}/
mv ${TMPDIR}/strategy.backups.cozystack.io*.yaml ${BACKUPSTRATEGY_CRDDIR}/
mv ${TMPDIR}/forklift.cozystack.io*.yaml ${MIGRATION_CRDDIR}/
mv ${TMPDIR}/*.yaml ${COZY_CONTROLLER_CRDDIR}/
@@ -0,0 +1,137 @@
// SPDX-License-Identifier: Apache-2.0
package migrationcontroller
import (
"context"
"fmt"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
migrationv1alpha1 "github.com/cozystack/cozystack/api/migration/v1alpha1"
)
// ProjectionError is a typed error so the reconciler can tell a misconfiguration
// that needs a human (a Secret owned by someone else) from an API hiccup that
// will clear on its own.
type ProjectionError struct {
Reason string
Message string
}
func (e *ProjectionError) Error() string { return e.Message }
// projectCredentials writes a Source's credentials into the Secret shape
// Forklift's Provider consumes, and returns the Secret's name.
//
// Tenants cannot create Secrets in Cozystack (core.cozystack.io/tenantsecrets is
// read-only at every access level), so a credentialsSecretRef field would be
// unsatisfiable for the audience this API is for: the credentials arrive on the
// Source's spec and the controller materializes them. The exposure class is the
// same one every managed database already accepts for `users[].password`.
//
// The projected Secret is labelled as ours and the projector refuses to write a
// pre-existing Secret that is not, so a name collision can never clobber a
// tenant's own object — it surfaces as a terminal condition instead.
func projectCredentials(ctx context.Context, c client.Client, src *migrationv1alpha1.VMImportSource) (string, error) {
return projectSecret(ctx, c, src, credentialsSecretName(src.Name), src.Spec.Credentials)
}
// projectSecret materializes one credential set into a Secret of the given
// name. Shared by the provider connection and by each host override, which
// carries its own ESXi account for the same reason: the engine authenticates
// that connection on the host itself.
func projectSecret(
ctx context.Context,
c client.Client,
src *migrationv1alpha1.VMImportSource,
name string,
creds migrationv1alpha1.ProviderCredentials,
) (string, error) {
desired := map[string][]byte{
"user": []byte(creds.Username),
"password": []byte(creds.Password),
}
// The engine's vSphere validation wants `user`, `password`, and then either
// a `cacert` or `insecureSkipVerify` parseable as a boolean. Without one of
// the latter two it marks the provider's secret invalid and never attempts
// a connection — a state that looks like a credentials problem and is not.
// Verified against the vendored engine's own validation on a live cluster.
if creds.CACert != "" {
desired["cacert"] = []byte(creds.CACert)
}
if creds.InsecureSkipVerify {
desired["insecureSkipVerify"] = []byte("true")
}
existing := &corev1.Secret{}
err := c.Get(ctx, types.NamespacedName{Namespace: src.Namespace, Name: name}, existing)
switch {
case apierrors.IsNotFound(err):
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: src.Namespace,
Labels: map[string]string{
migrationv1alpha1.ManagedByLabel: migrationv1alpha1.ManagedByValue,
},
// Forklift matches a provider's Secret by this label when it
// re-reads credentials after a Provider edit.
Annotations: map[string]string{
"createdForProviderType": string(src.Spec.Type),
},
OwnerReferences: []metav1.OwnerReference{
ownerRef(migrationv1alpha1.GroupVersion.WithKind("VMImportSource"), src.Name, src.UID),
},
},
Type: corev1.SecretTypeOpaque,
Data: desired,
}
if err := c.Create(ctx, secret); err != nil {
if apierrors.IsAlreadyExists(err) {
// Lost a race with another reconcile; the next pass adopts it.
return name, &ProjectionError{Reason: migrationv1alpha1.ReasonCredentialsMissing, Message: "credentials Secret is being created"}
}
return "", err
}
return name, nil
case err != nil:
return "", err
}
if existing.Labels[migrationv1alpha1.ManagedByLabel] != migrationv1alpha1.ManagedByValue {
return "", &ProjectionError{
Reason: migrationv1alpha1.ReasonCredentialsMissing,
Message: fmt.Sprintf(
"Secret %q already exists in this namespace and is not managed by the migration controller; "+
"rename the VMImportSource or remove the Secret", name),
}
}
if secretDataEqual(existing.Data, desired) {
return name, nil
}
existing.Data = desired
if err := c.Update(ctx, existing); err != nil {
return "", err
}
return name, nil
}
func secretDataEqual(a, b map[string][]byte) bool {
if len(a) != len(b) {
return false
}
for k, av := range a {
bv, ok := b[k]
if !ok || string(av) != string(bv) {
return false
}
}
return true
}
+156
View File
@@ -0,0 +1,156 @@
// SPDX-License-Identifier: Apache-2.0
// Package migrationcontroller reconciles the forklift.cozystack.io API group:
// VMImportSource connections and the VMImportTask operations that run over them.
//
// Forklift, KubeVirt and CDI objects are handled as unstructured rather than
// through generated types. That is deliberate: those projects' Go modules are
// heavy transitive dependencies, Cozystack drives only a handful of their
// fields, and Forklift in particular is an optional package that may not be
// installed at all — an unstructured client degrades to a clean NotFound
// instead of forcing the scheme to carry types for CRDs that are absent.
package migrationcontroller
import (
"strings"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
)
// Forklift's API group. Cozystack pins the operator package to v2.11.x, which
// serves v1beta1 for every kind used here.
const (
forkliftGroup = "forklift.konveyor.io"
forkliftVersion = "v1beta1"
)
// Forklift kinds this controller creates or reads.
var (
providerGVK = forkliftGVK("Provider")
hostGVK = forkliftGVK("Host")
planGVK = forkliftGVK("Plan")
migrationGVK = forkliftGVK("Migration")
networkMapGVK = forkliftGVK("NetworkMap")
storageMapGVK = forkliftGVK("StorageMap")
)
// KubeVirt and CDI kinds this controller reads and cleans up. The
// VirtualMachine Forklift creates is never started: it is read for the source
// VM's shape (CPU, memory, firmware, disk order) and then discarded. The one
// thing it does not carry is which networks and datastores the VM uses, which
// is why the maps are built from the inventory instead (see sourceTopology).
var (
virtualMachineGVK = schema.GroupVersionKind{Group: "kubevirt.io", Version: "v1", Kind: "VirtualMachine"}
dataVolumeGVK = schema.GroupVersionKind{Group: "cdi.kubevirt.io", Version: "v1beta1", Kind: "DataVolume"}
)
// Cozystack application kinds the controller produces. These are projections
// over HelmReleases: creating one through the aggregated API is exactly what a
// tenant does in the dashboard, so the controller takes the same path rather
// than writing HelmReleases behind the projection's back.
var (
vmDiskGVK = appsGVK("VMDisk")
vmInstanceGVK = appsGVK("VMInstance")
)
func forkliftGVK(kind string) schema.GroupVersionKind {
return schema.GroupVersionKind{Group: forkliftGroup, Version: forkliftVersion, Kind: kind}
}
func appsGVK(kind string) schema.GroupVersionKind {
return schema.GroupVersionKind{Group: "apps.cozystack.io", Version: "v1alpha1", Kind: kind}
}
// newForkliftObject returns an empty unstructured object of the given kind,
// ready to Get into or to fill in and Create.
func newObject(gvk schema.GroupVersionKind) *unstructured.Unstructured {
u := &unstructured.Unstructured{}
u.SetGroupVersionKind(gvk)
return u
}
// sourceProviderName is the name of the Forklift Provider that stands for a
// VMImportSource. It is derived from the Source rather than random so a
// reconcile after a controller restart finds the same object.
func sourceProviderName(sourceName string) string {
return "vmimport-" + sourceName
}
// destinationProviderName is the name of the Forklift Provider that stands for
// this cluster. Forklift models the local cluster as an "openshift"-type
// provider with an empty URL; the type name is Forklift's, not a statement
// about what Cozystack runs on.
func destinationProviderName(sourceName string) string {
return "vmimport-" + sourceName + "-destination"
}
// credentialsSecretName is the name of the Secret the controller projects a
// Source's credentials into for Forklift to consume.
func credentialsSecretName(sourceName string) string {
return "vmimport-" + sourceName + "-credentials"
}
// hostOverrideName is the name of the Forklift Host that redirects the disk
// transfer for one ESXi host, and of the Secret carrying that host's account.
func hostOverrideName(sourceName, hostID string) string {
return "vmimport-" + sourceName + "-host-" + sanitizeName(hostID)
}
// planName is the name of the Forklift Plan that migrates one source VM for a
// task. One Plan per VM keeps a single VM's failure from stalling its siblings.
func planName(taskName, vmID string) string {
return taskName + "-" + sanitizeName(vmID)
}
// mapName is the name of the Network/StorageMap a task owns. Both maps are
// per-task: their content is identical for every VM in the task.
func mapName(taskName string) string {
return "vmimport-" + taskName
}
// sanitizeName turns a provider-side identifier (`vm-1234`, and on other
// providers a UUID or a path) into something usable as a Kubernetes name
// segment. Managed-object references are already lowercase alphanumerics with
// dashes, but the API must not depend on that.
func sanitizeName(in string) string {
out := make([]rune, 0, len(in))
for _, r := range in {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
out = append(out, r)
case r >= 'A' && r <= 'Z':
out = append(out, r+('a'-'A'))
default:
out = append(out, '-')
}
}
// A leading or trailing dash is not a valid name segment.
trimmed := strings.Trim(string(out), "-")
if trimmed == "" {
return "unnamed"
}
if len(trimmed) > 40 {
trimmed = strings.Trim(trimmed[:40], "-")
}
return trimmed
}
// ownerRef returns a controller owner reference for the given object. Every
// piece of migration scaffolding carries one, so deleting the owning Source or
// Task garbage-collects it; the imported VMDisks and VMInstances deliberately
// carry none, which is what makes them outlive the task that produced them.
func ownerRef(gvk schema.GroupVersionKind, name string, uid types.UID) metav1.OwnerReference {
controller := true
blockOwnerDeletion := true
return metav1.OwnerReference{
APIVersion: gvk.GroupVersion().String(),
Kind: gvk.Kind,
Name: name,
UID: uid,
Controller: &controller,
BlockOwnerDeletion: &blockOwnerDeletion,
}
}
@@ -0,0 +1,522 @@
// SPDX-License-Identifier: Apache-2.0
package migrationcontroller
import (
"context"
"fmt"
"strings"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
migrationv1alpha1 "github.com/cozystack/cozystack/api/migration/v1alpha1"
)
// taskOwnership returns the labels and owner reference every piece of a task's
// Forklift scaffolding carries. The labels let the controller watch Migrations
// and map them back without trusting a label an unrelated object might set; the
// owner reference is what makes deleting the task take the scaffolding with it.
func taskOwnership(task *migrationv1alpha1.VMImportTask) (map[string]string, metav1.OwnerReference) {
return map[string]string{
migrationv1alpha1.ManagedByLabel: migrationv1alpha1.ManagedByValue,
migrationv1alpha1.OwningTaskNameLabel: task.Name,
migrationv1alpha1.OwningTaskNamespaceLabel: task.Namespace,
}, ownerRef(
migrationv1alpha1.GroupVersion.WithKind("VMImportTask"), task.Name, task.UID)
}
// outputMarkers are the labels every object a task produces carries: which
// task made it, and for which source VM. Labels rather than owner references,
// because outputs are meant to outlive the task that created them.
func outputMarkers(task *migrationv1alpha1.VMImportTask, vmID string) map[string]string {
return map[string]string{
migrationv1alpha1.OutputTaskUIDLabel: string(task.UID),
migrationv1alpha1.OutputVMIDLabel: sanitizeName(vmID),
}
}
// stampOutput writes the markers onto an object being created, preserving any
// labels already set on it.
func stampOutput(obj metav1.Object, task *migrationv1alpha1.VMImportTask, vmID string) {
labels := obj.GetLabels()
if labels == nil {
labels = map[string]string{}
}
for k, v := range outputMarkers(task, vmID) {
labels[k] = v
}
obj.SetLabels(labels)
}
// isOwnOutput reports whether an existing object is this task's own earlier
// output for this source VM, rather than something that merely shares its name.
// Absence of the marker is the answer that matters: it means a tenant object is
// standing where an output would go, which is a collision and never a resume.
func isOwnOutput(obj metav1.Object, task *migrationv1alpha1.VMImportTask, vmID string) bool {
labels := obj.GetLabels()
if labels == nil {
return false
}
return labels[migrationv1alpha1.OutputTaskUIDLabel] == string(task.UID) &&
labels[migrationv1alpha1.OutputVMIDLabel] == sanitizeName(vmID)
}
// providerRefs is the source/destination pair every Forklift object in a task
// points at. Both live in the task's namespace, because a Source does.
func providerRefs(task *migrationv1alpha1.VMImportTask, src *migrationv1alpha1.VMImportSource) map[string]interface{} {
return map[string]interface{}{
"source": map[string]interface{}{
"name": sourceProviderName(src.Name),
"namespace": task.Namespace,
},
"destination": map[string]interface{}{
"name": destinationProviderName(src.Name),
"namespace": task.Namespace,
},
}
}
// ensureMaps creates the NetworkMap and StorageMap a Plan requires. Both start
// empty — Forklift will not validate a Plan without them — and populateMaps
// fills them in from what the inventory says each VM actually uses, one VM at a
// time as its reconcile reaches this point.
func (r *VMImportTaskReconciler) ensureMaps(
ctx context.Context,
task *migrationv1alpha1.VMImportTask,
src *migrationv1alpha1.VMImportSource,
storageClass string,
) error {
labels, owner := taskOwnership(task)
name := mapName(task.Name)
for _, gvk := range []struct {
gvk schema.GroupVersionKind
}{{networkMapGVK}, {storageMapGVK}} {
existing := newObject(gvk.gvk)
err := r.Get(ctx, types.NamespacedName{Namespace: task.Namespace, Name: name}, existing)
if err == nil {
continue
}
if !apierrors.IsNotFound(err) {
return err
}
obj := newObject(gvk.gvk)
obj.SetName(name)
obj.SetNamespace(task.Namespace)
obj.SetLabels(labels)
obj.SetOwnerReferences([]metav1.OwnerReference{owner})
spec := map[string]interface{}{
"provider": providerRefs(task, src),
"map": []interface{}{},
}
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
}
}
// The storage class is not part of the map until a datastore is known, but
// it must be recorded somewhere the next pass can read it: the map's own
// annotation is the natural place, and it keeps the class immutable for the
// task's lifetime even if the cluster default changes mid-import.
sm := newObject(storageMapGVK)
if err := r.Get(ctx, types.NamespacedName{Namespace: task.Namespace, Name: name}, sm); err != nil {
return err
}
annotations := sm.GetAnnotations()
if annotations == nil {
annotations = map[string]string{}
}
if annotations[storageClassAnnotation] == storageClass {
return nil
}
annotations[storageClassAnnotation] = storageClass
sm.SetAnnotations(annotations)
return r.Update(ctx, sm)
}
// storageClassAnnotation records the resolved StorageClass on the task's
// StorageMap, so mappings learned later all land on the same class.
const storageClassAnnotation = "forklift.cozystack.io/storage-class"
// forkliftPowerStateOff is Forklift's `off` target power state
// (plan.TargetPowerStateOff upstream). Spelled out rather than imported: the
// controller deliberately builds Forklift objects as unstructured data and
// takes no compile-time dependency on the Forklift API module.
const forkliftPowerStateOff = "off"
// createPlan renders the Forklift Plan that migrates one VM.
//
// Raw copy (skipGuestConversion) is the only mode v1 offers, and it is the one
// that needs no privileged pod anywhere: Forklift emits a CDI DataVolume with a
// VDDK source and CDI's own importer — which satisfies the restricted Pod
// Security Standard — moves the bytes. Guest conversion needs a node-level
// seccomp profile and arrives as a separate, additive option.
func (r *VMImportTaskReconciler) createPlan(
ctx context.Context,
task *migrationv1alpha1.VMImportTask,
src *migrationv1alpha1.VMImportSource,
req *migrationv1alpha1.VMImportRequest,
) error {
labels, owner := taskOwnership(task)
obj := newObject(planGVK)
obj.SetName(planName(task.Name, req.ID))
obj.SetNamespace(task.Namespace)
obj.SetLabels(labels)
obj.SetOwnerReferences([]metav1.OwnerReference{owner})
spec := map[string]interface{}{
"skipGuestConversion": true,
"provider": providerRefs(task, src),
"map": map[string]interface{}{
"network": map[string]interface{}{
"name": mapName(task.Name),
"namespace": task.Namespace,
},
"storage": map[string]interface{}{
"name": mapName(task.Name),
"namespace": task.Namespace,
},
},
// The destination is always the task's own namespace. There is no
// field for anything else: an import lands where the object that
// asked for it lives, which removes the whole class of
// cross-namespace coercion bugs.
"targetNamespace": task.Namespace,
"vms": []interface{}{
map[string]interface{}{
"id": req.ID,
// Keep the machine Forklift builds powered off. Left unset,
// Forklift matches the source's pre-migration power state, so
// migrating a running VM — every VM in a production cutover —
// yields a target that boots the moment the transfer finishes.
// That is a duplicate of a live machine on the network, and it
// starts a guest on the very volume the handoff is about to
// re-point. The VMInstance this controller builds is what
// starts the imported guest, on the tenant's terms.
"targetPowerState": forkliftPowerStateOff,
},
},
}
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
}
// ensureMigration creates the Migration that runs a validated Plan. A Plan
// describes the work; a Migration is the instruction to do it.
func (r *VMImportTaskReconciler) ensureMigration(
ctx context.Context,
task *migrationv1alpha1.VMImportTask,
plan string,
) (*unstructured.Unstructured, error) {
existing := newObject(migrationGVK)
err := r.Get(ctx, types.NamespacedName{Namespace: task.Namespace, Name: plan}, existing)
if err == nil {
return existing, nil
}
if !apierrors.IsNotFound(err) {
return nil, err
}
labels, owner := taskOwnership(task)
obj := newObject(migrationGVK)
obj.SetName(plan)
obj.SetNamespace(task.Namespace)
obj.SetLabels(labels)
obj.SetOwnerReferences([]metav1.OwnerReference{owner})
spec := map[string]interface{}{
"plan": map[string]interface{}{
"name": plan,
"namespace": task.Namespace,
},
}
if err := unstructured.SetNestedMap(obj.Object, spec, "spec"); err != nil {
return nil, err
}
if err := r.Create(ctx, obj); err != nil && !apierrors.IsAlreadyExists(err) {
return nil, err
}
// Return the object as created; its status appears on a later pass.
return obj, nil
}
// populateMaps adds one VM's networks and datastores to the task's maps.
//
// The topology comes from Forklift's inventory, because it exists nowhere else:
// a Plan's validation conditions name the VMs that are unmapped, never what
// they are unmapped to. The maps accumulate across the task's VMs — each pass
// adds only what is missing — so the task ends up with one entry per distinct
// network and datastore however many machines share them.
//
// Networks all map to the pod network. That is not a placeholder: the map
// shapes the interfaces of the KubeVirt VM Forklift creates, and this
// controller discards that VM unstarted — the imported machine's networking
// comes from the VMInstance it builds instead, which attaches to the pod
// network. When a richer network placement design lands, this is where a
// tenant-specified mapping would be honoured.
//
// Returns true when it changed something, so the caller waits for Forklift to
// re-validate rather than racing ahead.
func (r *VMImportTaskReconciler) populateMaps(
ctx context.Context,
task *migrationv1alpha1.VMImportTask,
src *migrationv1alpha1.VMImportSource,
req *migrationv1alpha1.VMImportRequest,
) (bool, error) {
networks, datastores, err := r.sourceTopology(ctx, task, src, req)
if err != nil {
return false, err
}
if len(networks) == 0 && len(datastores) == 0 {
return false, nil
}
name := mapName(task.Name)
changed := false
if len(networks) > 0 {
nm := newObject(networkMapGVK)
if err := r.Get(ctx, types.NamespacedName{Namespace: task.Namespace, Name: name}, nm); err != nil {
return false, err
}
entries, _, _ := unstructured.NestedSlice(nm.Object, "spec", "map")
added := false
for _, id := range networks {
if hasSourceID(entries, id) {
continue
}
entries = append(entries, map[string]interface{}{
"source": map[string]interface{}{"id": id},
"destination": map[string]interface{}{"type": "pod"},
})
added = true
}
if added {
if err := unstructured.SetNestedSlice(nm.Object, entries, "spec", "map"); err != nil {
return false, err
}
if err := r.Update(ctx, nm); err != nil {
return false, err
}
changed = true
}
}
if len(datastores) > 0 {
sm := newObject(storageMapGVK)
if err := r.Get(ctx, types.NamespacedName{Namespace: task.Namespace, Name: name}, sm); err != nil {
return false, err
}
storageClass := sm.GetAnnotations()[storageClassAnnotation]
if storageClass == "" {
return false, fmt.Errorf("StorageMap %s/%s carries no resolved storage class", task.Namespace, name)
}
entries, _, _ := unstructured.NestedSlice(sm.Object, "spec", "map")
added := false
for _, id := range datastores {
if hasSourceID(entries, id) {
continue
}
entries = append(entries, map[string]interface{}{
"source": map[string]interface{}{"id": id},
"destination": map[string]interface{}{"storageClass": storageClass},
})
added = true
}
if added {
if err := unstructured.SetNestedSlice(sm.Object, entries, "spec", "map"); err != nil {
return false, err
}
if err := r.Update(ctx, sm); err != nil {
return false, err
}
changed = true
}
}
return changed, nil
}
// sourceTopology asks Forklift's inventory which networks and datastores one
// VM actually uses.
//
// This is the only place the answer exists. Forklift's Plan conditions name the
// VMs that are unmapped, not what they are unmapped to, and no Forklift custom
// resource carries a VM's network or datastore IDs — see the comment on
// inventoryClient for the source references.
//
// Scoped to a single VM on purpose. Reading the whole task here would tie every
// VM's fate to every other one: a single mistyped managed-object reference — the
// likeliest error on this API — would fail the VM being reconciled with a
// message naming a different machine, where a missing VM is meant to fail alone
// while its siblings proceed. It is also what keeps the cost linear: one GET per
// VM per pass rather than one per VM *for* every VM, repeated on every requeue
// while anything is still transferring.
func (r *VMImportTaskReconciler) sourceTopology(
ctx context.Context,
task *migrationv1alpha1.VMImportTask,
src *migrationv1alpha1.VMImportSource,
req *migrationv1alpha1.VMImportRequest,
) (networks, datastores []string, err error) {
if r.Inventory == nil {
return nil, nil, fmt.Errorf("no inventory client configured; the network and storage maps cannot be built")
}
// The inventory addresses providers by UID, so the Provider object this
// task's Source produced has to be read for it.
provider := newObject(providerGVK)
if err := r.Get(ctx, types.NamespacedName{
Namespace: task.Namespace, Name: sourceProviderName(src.Name),
}, provider); err != nil {
return nil, nil, err
}
uid := string(provider.GetUID())
if uid == "" {
return nil, nil, fmt.Errorf("source Provider %s/%s has no UID yet", task.Namespace, sourceProviderName(src.Name))
}
vm, err := r.Inventory.VM(ctx, uid, req.ID)
if err != nil {
return nil, nil, err
}
return vm.networkIDs(), vm.datastoreIDs(), nil
}
func hasSourceID(entries []interface{}, id string) bool {
for _, raw := range entries {
entry, ok := raw.(map[string]interface{})
if !ok {
continue
}
existing, _, _ := unstructured.NestedString(entry, "source", "id")
if existing == id {
return true
}
name, _, _ := unstructured.NestedString(entry, "source", "name")
if name == id {
return true
}
}
return false
}
// planCriticalCondition returns Forklift's own words for why a Plan cannot run,
// or empty if nothing is critically wrong. Unmapped references are excluded:
// those are not failures, they are what populateMaps resolves.
func planCriticalCondition(plan *unstructured.Unstructured) string {
conditions, _, _ := unstructured.NestedSlice(plan.Object, "status", "conditions")
var critical []string
for _, raw := range conditions {
cond, ok := raw.(map[string]interface{})
if !ok {
continue
}
status, _, _ := unstructured.NestedString(cond, "status")
category, _, _ := unstructured.NestedString(cond, "category")
ctype, _, _ := unstructured.NestedString(cond, "type")
message, _, _ := unstructured.NestedString(cond, "message")
if status != "True" || category != "Critical" {
continue
}
if strings.Contains(ctype, "NotMapped") {
continue
}
critical = append(critical, fmt.Sprintf("%s: %s", ctype, message))
}
if len(critical) == 0 {
return ""
}
return strings.Join(critical, "; ")
}
// planReady reports whether Forklift considers the Plan runnable.
func planReady(plan *unstructured.Unstructured) bool {
conditions, _, _ := unstructured.NestedSlice(plan.Object, "status", "conditions")
for _, raw := range conditions {
cond, ok := raw.(map[string]interface{})
if !ok {
continue
}
ctype, _, _ := unstructured.NestedString(cond, "type")
status, _, _ := unstructured.NestedString(cond, "status")
if ctype == "Ready" && status == "True" {
return true
}
}
return false
}
// migrationProgress reads a Migration's status: whether the transfer finished,
// how far along it is, and the failure text if it failed.
func migrationProgress(migration *unstructured.Unstructured) (done bool, progress int32, failure string) {
vms, _, _ := unstructured.NestedSlice(migration.Object, "status", "vms")
if len(vms) == 0 {
return false, 0, ""
}
vm, ok := vms[0].(map[string]interface{})
if !ok {
return false, 0, ""
}
if reasons, found, _ := unstructured.NestedStringSlice(vm, "error", "reasons"); found && len(reasons) > 0 {
return false, 0, strings.Join(reasons, "; ")
}
phase, _, _ := unstructured.NestedString(vm, "phase")
if phase == "Failed" {
return false, 0, "the migration engine reported the transfer as failed"
}
// Progress is a completed/total pair per pipeline step, counted in MiB.
// Prefer the disk-transfer step, because averaging a four-step pipeline
// reads 25% before a byte has moved — but fall back to the sum over every
// step that reports a total, since the step names are not part of any
// contract and a release that renames them would otherwise peg progress at
// zero for the whole transfer.
var completed, total int64
var namedCompleted, namedTotal int64
pipeline, _, _ := unstructured.NestedSlice(vm, "pipeline")
for _, raw := range pipeline {
step, ok := raw.(map[string]interface{})
if !ok {
continue
}
c, _, _ := unstructured.NestedInt64(step, "progress", "completed")
t, _, _ := unstructured.NestedInt64(step, "progress", "total")
if t <= 0 {
continue
}
completed += c
total += t
if name, _, _ := unstructured.NestedString(step, "name"); strings.Contains(strings.ToLower(name), "disktransfer") {
namedCompleted += c
namedTotal += t
}
}
switch {
case namedTotal > 0:
progress = int32(namedCompleted * 100 / namedTotal)
case total > 0:
progress = int32(completed * 100 / total)
}
// Either signal means the transfer is over: the phase the engine settles
// on, or the completion timestamp it stamps.
if phase == "Completed" {
return true, 100, ""
}
if ts, found, _ := unstructured.NestedString(vm, "completed"); found && ts != "" {
return true, 100, ""
}
return false, progress, ""
}
+745
View File
@@ -0,0 +1,745 @@
// 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
}
@@ -0,0 +1,111 @@
// SPDX-License-Identifier: Apache-2.0
package migrationcontroller
import (
"context"
"testing"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
migrationv1alpha1 "github.com/cozystack/cozystack/api/migration/v1alpha1"
)
// hostOverride builds the Host and Secret pair the controller creates for one
// entry, as an earlier reconcile would have left them.
func hostOverride(src *migrationv1alpha1.VMImportSource, id string) (*unstructured.Unstructured, *corev1.Secret) {
name := hostOverrideName(src.Name, id)
owner := ownerRef(migrationv1alpha1.GroupVersion.WithKind("VMImportSource"), src.Name, src.UID)
host := newObject(hostGVK)
host.SetName(name)
host.SetNamespace(src.Namespace)
host.SetLabels(map[string]string{migrationv1alpha1.ManagedByLabel: migrationv1alpha1.ManagedByValue})
host.SetOwnerReferences([]metav1.OwnerReference{owner})
secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: src.Namespace,
Labels: map[string]string{migrationv1alpha1.ManagedByLabel: migrationv1alpha1.ManagedByValue},
OwnerReferences: []metav1.OwnerReference{owner},
}}
return host, secret
}
// A Source is editable, so an override can be dropped after it was created. A
// Host left behind is not inert — Forklift keeps routing that ESXi host's
// transfers through the address it names — and its credentials would otherwise
// outlive the decision to stop using them.
func TestEditingASourceRemovesTheHostsItNoLongerLists(t *testing.T) {
s := testScheme(t)
src := vsphereSource("vcenter", "tenant-foo")
src.Spec.Hosts = []migrationv1alpha1.HostOverride{{
ID: "host-10",
Address: "10.0.30.29",
Credentials: migrationv1alpha1.ProviderCredentials{
Username: "root", Password: "x", InsecureSkipVerify: true,
},
}}
keptHost, keptSecret := hostOverride(src, "host-10")
goneHost, goneSecret := hostOverride(src, "host-99")
c := clientfake.NewClientBuilder().WithScheme(s).
WithObjects(src, keptHost, keptSecret, goneHost, goneSecret).Build()
r := &VMImportSourceReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
if err := r.pruneHosts(context.Background(), src); err != nil {
t.Fatalf("pruneHosts: %v", err)
}
gone := types.NamespacedName{Namespace: "tenant-foo", Name: hostOverrideName("vcenter", "host-99")}
if err := c.Get(context.Background(), gone, newObject(hostGVK)); !apierrors.IsNotFound(err) {
t.Errorf("the dropped Host survived (err %v); it would keep redirecting transfers", err)
}
if err := c.Get(context.Background(), gone, &corev1.Secret{}); !apierrors.IsNotFound(err) {
t.Errorf("the dropped Host's credentials survived (err %v)", err)
}
kept := types.NamespacedName{Namespace: "tenant-foo", Name: hostOverrideName("vcenter", "host-10")}
if err := c.Get(context.Background(), kept, newObject(hostGVK)); err != nil {
t.Errorf("the still-listed Host was removed: %v", err)
}
if err := c.Get(context.Background(), kept, &corev1.Secret{}); err != nil {
t.Errorf("the still-listed Host's credentials were removed: %v", err)
}
}
// The label says this controller made the object, not that it made it for this
// Source. Two Sources in one namespace must not delete each other's Hosts.
func TestPruningLeavesAnotherSourcesHostsAlone(t *testing.T) {
s := testScheme(t)
mine := vsphereSource("vcenter", "tenant-foo")
theirs := vsphereSource("other", "tenant-foo")
theirs.UID = types.UID("other-uid")
theirHost, theirSecret := hostOverride(theirs, "host-10")
c := clientfake.NewClientBuilder().WithScheme(s).
WithObjects(mine, theirs, theirHost, theirSecret).Build()
r := &VMImportSourceReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
// `mine` lists no hosts at all, so an ownership-blind prune would take the
// other Source's Host with it.
if err := r.pruneHosts(context.Background(), mine); err != nil {
t.Fatalf("pruneHosts: %v", err)
}
name := types.NamespacedName{Namespace: "tenant-foo", Name: hostOverrideName("other", "host-10")}
if err := c.Get(context.Background(), name, newObject(hostGVK)); err != nil {
t.Errorf("another Source's Host was deleted: %v", err)
}
if err := c.Get(context.Background(), name, &corev1.Secret{}); err != nil {
t.Errorf("another Source's credentials were deleted: %v", err)
}
}
+262
View File
@@ -0,0 +1,262 @@
// SPDX-License-Identifier: Apache-2.0
package migrationcontroller
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"net/http"
"os"
"sync"
"time"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
)
// The controller reads the source's topology from Forklift's inventory service
// rather than from Forklift's custom resources, because the resources do not
// carry it.
//
// The tempting shortcut is to let Forklift tell you what is missing: a Plan
// whose maps do not cover a VM publishes VMNetworksNotMapped/VMStorageNotMapped
// with the offenders in `items`. But those items are the *VM* references, not
// the networks or datastores — `pkg/controller/plan/validation.go:940,960`
// appends `ref.String()` of the VM, rendered `id:vm-1234 name:'web-01'`
// (`pkg/apis/forklift/v1beta1/ref/ref.go:29` at v2.11.5). The network and
// datastore IDs a map entry needs appear on no Forklift CR at all, and map
// resolution is an exact-ID lookup with no wildcard to fall back on
// (`adapter/vsphere/validator.go:80,116`). A map built by parsing that text
// resolves to nothing and the task waits in Validating forever.
//
// So this is a real HTTP client with a bearer token. It stays inside the "no
// second vSphere client" boundary: it never talks to vCenter, only to Forklift,
// and what it reads is Forklift's own view of the source.
const (
// inventoryTokenFile is the projected ServiceAccount token the inventory
// service authenticates. Re-read per call: it rotates.
inventoryTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token" // #nosec G101 -- path, not a credential
// inventoryCASecret holds the CA that signed the inventory's serving
// certificate. Verifying against it is what lets this client run without
// InsecureSkipVerify: the certificate carries the service DNS names as SANs.
inventoryCASecret = "forklift-inventory-serving-cert"
inventoryCAKey = "ca.crt"
)
// inventoryRef is Forklift's reference shape: a kind and the provider's own ID.
type inventoryRef struct {
Kind string `json:"kind"`
ID string `json:"id"`
}
// inventoryVM is the subset of a vSphere VM record this controller consumes.
type inventoryVM struct {
ID string `json:"id"`
Name string `json:"name"`
Firmware string `json:"firmware"`
PowerState string `json:"powerState"`
Networks []inventoryRef `json:"networks"`
Disks []struct {
Datastore inventoryRef `json:"datastore"`
} `json:"disks"`
}
// datastoreIDs returns the distinct datastores backing the VM's disks.
func (v *inventoryVM) datastoreIDs() []string {
seen := map[string]bool{}
var out []string
for _, d := range v.Disks {
if d.Datastore.ID == "" || seen[d.Datastore.ID] {
continue
}
seen[d.Datastore.ID] = true
out = append(out, d.Datastore.ID)
}
return out
}
// networkIDs returns the distinct networks the VM's interfaces attach to.
func (v *inventoryVM) networkIDs() []string {
seen := map[string]bool{}
var out []string
for _, n := range v.Networks {
if n.ID == "" || seen[n.ID] {
continue
}
seen[n.ID] = true
out = append(out, n.ID)
}
return out
}
// NewInventoryClient builds the client the task reconciler reads topology with.
// The reader must be uncached — the CA secret lives in Forklift's namespace,
// which this controller does not watch.
func NewInventoryClient(baseURL, namespace string, reader client.Reader) *inventoryClient {
return &inventoryClient{BaseURL: baseURL, Namespace: namespace, Reader: reader}
}
// inventoryClient reads VM records from Forklift's inventory REST service.
type inventoryClient struct {
// BaseURL is the inventory service root, e.g.
// https://forklift-inventory.cozy-forklift.svc:8443
BaseURL string
// Namespace is where the CA secret lives — the Forklift namespace.
Namespace string
// Reader fetches the CA secret. Nil disables verification setup and makes
// every call fail, which is the safe default for a misconfigured client.
Reader client.Reader
// TokenFile overrides the projected token path in tests.
TokenFile string
mu sync.Mutex
http *http.Client
}
// notFoundError marks a VM the provider's inventory does not have, so the
// caller can report it as a tenant-visible "no such VM" rather than retrying.
type notFoundError struct{ vmID string }
func (e *notFoundError) Error() string {
return fmt.Sprintf("VM %q is not present in the source inventory", e.vmID)
}
// inventoryVMRef is the abbreviated record the list endpoint returns: enough to
// name a machine in a picker, without paying for the full record of every VM in
// the source.
type inventoryVMRef struct {
ID string `json:"id"`
Name string `json:"name"`
// Path is the VM's inventory path, which is what distinguishes two machines
// that share a name in different folders.
Path string `json:"path"`
}
// VMs lists every VM the provider's inventory holds.
func (c *inventoryClient) VMs(ctx context.Context, providerUID string) ([]inventoryVMRef, error) {
resp, err := c.get(ctx, fmt.Sprintf("%s/providers/vsphere/%s/vms", c.BaseURL, providerUID))
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Forklift inventory returned %s listing VMs", resp.Status)
}
var vms []inventoryVMRef
if err := json.NewDecoder(resp.Body).Decode(&vms); err != nil {
return nil, fmt.Errorf("decoding the inventory VM list: %w", err)
}
return vms, nil
}
// get issues an authenticated GET. The token is read per call because it
// rotates, and the caller owns the response body.
func (c *inventoryClient) get(ctx context.Context, url string) (*http.Response, error) {
httpClient, err := c.client(ctx)
if err != nil {
return nil, err
}
token, err := c.token()
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("querying Forklift inventory: %w", err)
}
return resp, nil
}
// VM fetches one VM record by the provider's UID and the VM's managed-object ID.
func (c *inventoryClient) VM(ctx context.Context, providerUID, vmID string) (*inventoryVM, error) {
resp, err := c.get(ctx, fmt.Sprintf("%s/providers/vsphere/%s/vms/%s", c.BaseURL, providerUID, vmID))
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
switch resp.StatusCode {
case http.StatusOK:
case http.StatusNotFound:
return nil, &notFoundError{vmID: vmID}
default:
return nil, fmt.Errorf("Forklift inventory returned %s for VM %q", resp.Status, vmID)
}
vm := &inventoryVM{}
if err := json.NewDecoder(resp.Body).Decode(vm); err != nil {
return nil, fmt.Errorf("decoding the inventory record for VM %q: %w", vmID, err)
}
return vm, nil
}
// client builds the HTTPS client, trusting the CA that signed the inventory's
// serving certificate. Built once and reused.
//
// Guarded because one client is shared by both reconcilers, which run on
// separate goroutines: an unsynchronized check-then-assign races on the first
// call, leaking the loser's client and failing under -race. A mutex rather than
// sync.Once deliberately — construction reads a Secret and can fail while
// Forklift is still coming up, and Once would latch that failure forever, where
// this simply builds again on the next call.
func (c *inventoryClient) client(ctx context.Context) (*http.Client, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.http != nil {
return c.http, nil
}
if c.Reader == nil {
return nil, fmt.Errorf("inventory client has no reader for the %s secret", inventoryCASecret)
}
secret := &corev1.Secret{}
if err := c.Reader.Get(ctx, types.NamespacedName{
Namespace: c.Namespace, Name: inventoryCASecret,
}, secret); err != nil {
return nil, fmt.Errorf("reading the Forklift inventory CA: %w", err)
}
ca, ok := secret.Data[inventoryCAKey]
if !ok || len(ca) == 0 {
return nil, fmt.Errorf("secret %s/%s carries no %s", c.Namespace, inventoryCASecret, inventoryCAKey)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(ca) {
return nil, fmt.Errorf("the %s in %s/%s is not a valid PEM bundle", inventoryCAKey, c.Namespace, inventoryCASecret)
}
c.http = &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12},
},
}
return c.http, nil
}
func (c *inventoryClient) token() (string, error) {
path := c.TokenFile
if path == "" {
path = inventoryTokenFile
}
b, err := os.ReadFile(path) // #nosec G304 -- fixed path, or a test override
if err != nil {
return "", fmt.Errorf("reading the ServiceAccount token for the inventory call: %w", err)
}
return string(b), nil
}
@@ -0,0 +1,96 @@
// SPDX-License-Identifier: Apache-2.0
package migrationcontroller
import (
"context"
"encoding/json"
"fmt"
"sort"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
migrationv1alpha1 "github.com/cozystack/cozystack/api/migration/v1alpha1"
)
// The VM picker in the console needs to offer machine names while writing
// managed-object references, and only Forklift's inventory knows the mapping.
// The aggregated API that serves dropdowns lists Kubernetes objects with a
// dynamic client and has no way to speak HTTPS to the inventory, nor should it
// grow one: that would put the source's credentials path in a second component.
// So this controller — which already holds an inventory client — publishes the
// list, and the option provider reads it back as an ordinary object.
//
// The list lives in its own ConfigMap rather than on the Source's status. A
// status carrying thousands of entries is rewritten on every refresh and wakes
// everything watching the Source; a separate object is read only by the thing
// that needs it, and an ownerReference still has it deleted with the Source.
// maxPublishedVMs bounds the object. A vCenter with more machines than this is
// past the point where a dropdown is the right interface anyway, and an
// unbounded write is not something to put in etcd on a timer.
const maxPublishedVMs = 5000
// publishInventory refreshes the source's VM list. A failure here never fails
// the Source: the connection is what the Source reports on, and an inventory
// that is briefly unavailable should degrade the picker, not the status.
func (r *VMImportSourceReconciler) publishInventory(ctx context.Context, src *migrationv1alpha1.VMImportSource) error {
if r.Inventory == nil {
return nil
}
provider := newObject(providerGVK)
if err := r.Get(ctx, types.NamespacedName{
Namespace: src.Namespace, Name: sourceProviderName(src.Name),
}, provider); err != nil {
return err
}
uid := string(provider.GetUID())
if uid == "" {
return fmt.Errorf("source Provider %s/%s has no UID yet", src.Namespace, sourceProviderName(src.Name))
}
vms, err := r.Inventory.VMs(ctx, uid)
if err != nil {
return err
}
// Sorted by name so the dropdown is stable across refreshes and the
// truncation, when it happens, is at least predictable.
sort.Slice(vms, func(i, j int) bool { return vms[i].Name < vms[j].Name })
truncated := len(vms) > maxPublishedVMs
if truncated {
vms = vms[:maxPublishedVMs]
}
published := make([]migrationv1alpha1.PublishedVM, 0, len(vms))
for _, vm := range vms {
published = append(published, migrationv1alpha1.PublishedVM{ID: vm.ID, Name: vm.Name, Path: vm.Path})
}
encoded, err := json.Marshal(published)
if err != nil {
return err
}
cm := &corev1.ConfigMap{}
cm.Name = migrationv1alpha1.InventoryConfigMapName(src.Name)
cm.Namespace = src.Namespace
_, err = controllerutil.CreateOrUpdate(ctx, r.Client, cm, func() error {
if cm.Annotations == nil {
cm.Annotations = map[string]string{}
}
if truncated {
cm.Annotations[migrationv1alpha1.InventoryTruncatedAnnotation] = "true"
} else {
delete(cm.Annotations, migrationv1alpha1.InventoryTruncatedAnnotation)
}
cm.Data = map[string]string{migrationv1alpha1.InventoryVMsKey: string(encoded)}
return controllerutil.SetControllerReference(src, cm, r.Scheme)
})
if err != nil && !apierrors.IsAlreadyExists(err) {
return err
}
return nil
}
@@ -0,0 +1,119 @@
// SPDX-License-Identifier: Apache-2.0
package migrationcontroller
import (
"context"
"encoding/pem"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
migrationv1alpha1 "github.com/cozystack/cozystack/api/migration/v1alpha1"
)
// inventoryHarness stands up a TLS inventory the client will actually verify,
// wiring the server's own certificate in as the CA the controller trusts.
func inventoryHarness(t *testing.T, handler http.HandlerFunc) (*httptest.Server, *corev1.Secret, string) {
t.Helper()
srv := httptest.NewTLSServer(handler)
t.Cleanup(srv.Close)
ca := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srv.Certificate().Raw})
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: inventoryCASecret, Namespace: "cozy-forklift"},
Data: map[string][]byte{inventoryCAKey: ca},
}
tokenFile := filepath.Join(t.TempDir(), "token")
if err := os.WriteFile(tokenFile, []byte("test-token"), 0o600); err != nil {
t.Fatalf("writing the token file: %v", err)
}
return srv, secret, tokenFile
}
// Reading the whole task here would tie every VM's fate to every other one: one
// mistyped managed-object reference is the likeliest error on this API, and it
// would fail the VM being reconciled with a message naming a different machine,
// where a missing VM is meant to fail alone. It is also what keeps the cost
// linear rather than quadratic in the number of VMs, on every pass, for as long
// as anything is still transferring.
func TestSourceTopologyAsksOnlyAboutTheVMBeingReconciled(t *testing.T) {
var mu sync.Mutex
var asked []string
srv, caSecret, tokenFile := inventoryHarness(t, func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
asked = append(asked, r.URL.Path)
mu.Unlock()
if strings.HasSuffix(r.URL.Path, "/vms/vm-2") {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"vm-2","name":"web-02",
"networks":[{"kind":"Network","id":"net-b"}],
"disks":[{"datastore":{"kind":"Datastore","id":"ds-b"}}]}`))
return
}
// Every other VM of the task is absent from the inventory. If the
// implementation walks the whole spec, this is what turns one tenant
// typo into a task-wide failure — so the count assert below is the
// real subject of this test.
w.WriteHeader(http.StatusNotFound)
})
s := testScheme(t)
tk := task("import", "tenant-foo", "vcenter", "replicated",
migrationv1alpha1.VMImportRequest{ID: "vm-1", Name: "web-01"},
migrationv1alpha1.VMImportRequest{ID: "vm-2", Name: "web-02"},
migrationv1alpha1.VMImportRequest{ID: "vm-3", Name: "web-03"},
)
src := readySource("vcenter", "tenant-foo")
provider := newObject(providerGVK)
provider.SetName(sourceProviderName("vcenter"))
provider.SetNamespace("tenant-foo")
provider.SetUID(types.UID("provider-uid"))
c := clientfake.NewClientBuilder().WithScheme(s).
WithObjects(tk, src, provider, caSecret).Build()
r := &VMImportTaskReconciler{
Client: c,
Scheme: s,
Recorder: record.NewFakeRecorder(10),
Inventory: &inventoryClient{
BaseURL: srv.URL, Namespace: "cozy-forklift", Reader: c, TokenFile: tokenFile,
},
}
nets, stores, err := r.sourceTopology(context.Background(), tk, src,
&migrationv1alpha1.VMImportRequest{ID: "vm-2", Name: "web-02"})
if err != nil {
t.Fatalf("sourceTopology: %v", err)
}
if len(nets) != 1 || nets[0] != "net-b" {
t.Errorf("networks = %v, want [net-b]", nets)
}
if len(stores) != 1 || stores[0] != "ds-b" {
t.Errorf("datastores = %v, want [ds-b]", stores)
}
mu.Lock()
defer mu.Unlock()
if len(asked) != 1 {
t.Fatalf("issued %d inventory calls for one VM (%v); a task of N VMs would cost N×N per pass", len(asked), asked)
}
if !strings.HasSuffix(asked[0], "/vms/vm-2") {
t.Errorf("asked about %q, want the VM being reconciled", asked[0])
}
}
@@ -0,0 +1,91 @@
// SPDX-License-Identifier: Apache-2.0
package migrationcontroller
import (
"context"
"testing"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
migrationv1alpha1 "github.com/cozystack/cozystack/api/migration/v1alpha1"
)
// The window this covers: the scaffolding VirtualMachine is deleted before the
// status write that records what the import produced, so dying between the two
// leaves a healthy VMInstance and its disks with nothing pointing at them. The
// old behaviour reported Failed on a machine that works.
func TestReclaimOutputsFindsWhatStatusNeverRecorded(t *testing.T) {
s := testScheme(t)
tk := task("import", "tenant-foo", "vcenter", "replicated",
migrationv1alpha1.VMImportRequest{ID: "vm-1", Name: "web-01"})
tk.UID = types.UID("task-uid")
// Status knows nothing: this is exactly the crash being recovered from.
tk.Status.VMs = nil
instance := newObject(vmInstanceGVK)
instance.SetName("web-01")
instance.SetNamespace("tenant-foo")
instance.SetLabels(outputMarkers(tk, "vm-1"))
disk0 := newObject(vmDiskGVK)
disk0.SetName("web-01-disk-0")
disk0.SetNamespace("tenant-foo")
disk0.SetLabels(outputMarkers(tk, "vm-1"))
disk1 := newObject(vmDiskGVK)
disk1.SetName("web-01-disk-1")
disk1.SetNamespace("tenant-foo")
disk1.SetLabels(outputMarkers(tk, "vm-1"))
// Another task's output of a similar shape must not be reclaimed: the
// markers are what make this a lookup rather than a guess.
otherTask := task("other", "tenant-foo", "vcenter", "replicated")
otherTask.UID = types.UID("other-uid")
stray := newObject(vmInstanceGVK)
stray.SetName("web-99")
stray.SetNamespace("tenant-foo")
stray.SetLabels(outputMarkers(otherTask, "vm-1"))
c := clientfake.NewClientBuilder().WithScheme(s).
WithObjects(tk, instance, disk0, disk1, stray).Build()
r := &VMImportTaskReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
got, err := r.reclaimOutputs(context.Background(), tk,
&migrationv1alpha1.VMImportRequest{ID: "vm-1", Name: "web-01"})
if err != nil {
t.Fatalf("reclaimOutputs: %v", err)
}
if got == nil {
t.Fatal("reclaimed nothing; the import would be reported Failed with its outputs healthy")
}
if got.VMInstance != "web-01" {
t.Errorf("VMInstance = %q, want web-01", got.VMInstance)
}
if len(got.Disks) != 2 || got.Disks[0] != "web-01-disk-0" || got.Disks[1] != "web-01-disk-1" {
t.Errorf("disks = %v, want them in order", got.Disks)
}
}
// With no marked outputs there is nothing to reclaim, and saying so is what
// lets the caller report the real failure instead of inventing a success.
func TestReclaimOutputsFindsNothingWhenThereIsNothing(t *testing.T) {
s := testScheme(t)
tk := task("import", "tenant-foo", "vcenter", "replicated",
migrationv1alpha1.VMImportRequest{ID: "vm-1", Name: "web-01"})
tk.UID = types.UID("task-uid")
c := clientfake.NewClientBuilder().WithScheme(s).WithObjects(tk).Build()
r := &VMImportTaskReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
got, err := r.reclaimOutputs(context.Background(), tk,
&migrationv1alpha1.VMImportRequest{ID: "vm-1", Name: "web-01"})
if err != nil {
t.Fatalf("reclaimOutputs: %v", err)
}
if got != nil {
t.Errorf("reclaimed %+v out of nowhere", got)
}
}
@@ -0,0 +1,439 @@
// SPDX-License-Identifier: Apache-2.0
package migrationcontroller
import (
"context"
"errors"
"fmt"
"strings"
"time"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/equality"
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/log"
migrationv1alpha1 "github.com/cozystack/cozystack/api/migration/v1alpha1"
)
// ConnectionRequeue is how often a Source is re-examined while its Forklift
// Provider has not settled. Short enough that a corrected endpoint or password
// is noticed quickly, long enough that a permanently wrong one does not flood
// the apiserver.
const ConnectionRequeue = 30 * time.Second
// VMImportSourceReconciler turns a VMImportSource into the Forklift objects a
// migration needs: a credentials Secret and the source/destination Provider
// pair. It never contacts the provider itself — Forklift's Provider controller
// performs the connection test and publishes the result, so this reconciler
// mirrors that verdict onto the Source rather than shipping a second vSphere
// client that could disagree with the one doing the actual work.
type VMImportSourceReconciler struct {
client.Client
Scheme *runtime.Scheme
Recorder record.EventRecorder
// VDDKImage is the operator-supplied VMware Virtual Disk Development Kit
// image. It is platform configuration, never a tenant value: naming an
// image the cluster will run is not a tenant's decision, and this one is
// built from a proprietary SDK that Cozystack cannot ship or mirror. Empty
// means the VMware path is unavailable, which a vSphere Source reports on
// its own status instead of failing somewhere deep in a transfer.
VDDKImage string
// Inventory reads Forklift's view of the source so the machine list can be
// published for the console's VM picker. This is still not a second vSphere
// client: it asks Forklift what Forklift already discovered. Nil disables
// publishing, and the picker then simply offers nothing.
Inventory *inventoryClient
}
func (r *VMImportSourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
src := &migrationv1alpha1.VMImportSource{}
if err := r.Get(ctx, req.NamespacedName, src); err != nil {
if apierrors.IsNotFound(err) {
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
}
// A vSphere source is unusable without the VDDK image, and saying so here
// is the whole point: the tenant learns the VMware path is not configured
// when they register the connection, not when a 200 GiB transfer dies.
if src.Spec.Type == migrationv1alpha1.ProviderVSphere && r.VDDKImage == "" {
return r.fail(ctx, src, migrationv1alpha1.ReasonVDDKNotConfigured,
"VMware import requires the platform administrator to configure a VDDK image "+
"(vmImport.vddkImage in the platform values); until then the vSphere import "+
"path is unavailable")
}
secretName, err := projectCredentials(ctx, r.Client, src)
if err != nil {
var perr *ProjectionError
if errors.As(err, &perr) {
return r.fail(ctx, src, perr.Reason, perr.Message)
}
return ctrl.Result{}, err
}
if err := r.ensureProviders(ctx, src, secretName); err != nil {
// Forklift's absence is a state to report, not a failure to retry
// silently. Returning it as an error means the reconcile never reaches
// the status write below, so the Source keeps whatever condition it had
// — which is actively misleading once the operator fixes the thing the
// old condition complained about. Seen on a live cluster: a Source went
// on claiming VDDKNotConfigured long after a VDDK image was configured,
// because every pass died here before saying anything.
if meta.IsNoMatchError(err) {
return r.fail(ctx, src, migrationv1alpha1.ReasonForkliftNotInstalled,
"the migration engine is not installed in this cluster: enable the "+
"forklift-operator and forklift packages")
}
return ctrl.Result{}, err
}
// Host overrides come after the Provider they attach to: Forklift resolves
// a Host against its provider, so creating one first only produces an
// object that cannot validate yet.
if err := r.ensureHosts(ctx, src); err != nil {
var perr *ProjectionError
if errors.As(err, &perr) {
return r.fail(ctx, src, perr.Reason, perr.Message)
}
return ctrl.Result{}, err
}
// Mirror Forklift's own verdict. Until its Provider controller has run, the
// Source stays not-ready with a reason that says so rather than claiming a
// connection nobody has tested.
ready, reason, message, err := r.providerVerdict(ctx, src)
if err != nil {
return ctrl.Result{}, err
}
if ready {
if err := r.setReady(ctx, src, metav1.ConditionTrue, migrationv1alpha1.ReasonConnected, message); err != nil {
return ctrl.Result{}, err
}
logger.V(1).Info("VMImportSource ready", "name", src.Name)
// Refresh the machine list on the same beat. A failure is logged and
// dropped rather than returned: the Source reports on the connection,
// and an inventory that is briefly unavailable should cost the picker
// its contents, not turn a working source not-ready.
if err := r.publishInventory(ctx, src); err != nil {
logger.V(1).Info("could not publish the source inventory", "name", src.Name, "error", err.Error())
}
// Re-check periodically: a connection that worked at registration can
// stop working, and a task that starts then would fail confusingly.
return ctrl.Result{RequeueAfter: 10 * time.Minute}, nil
}
if err := r.setReady(ctx, src, metav1.ConditionFalse, reason, message); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: ConnectionRequeue}, nil
}
// ensureProviders creates or updates the source and destination Providers. The
// destination is Forklift's model of the local cluster: an "openshift"-type
// provider with an empty URL, which is Forklift's own naming and says nothing
// about what Cozystack runs on.
func (r *VMImportSourceReconciler) ensureProviders(ctx context.Context, src *migrationv1alpha1.VMImportSource, secretName string) error {
owner := ownerRef(migrationv1alpha1.GroupVersion.WithKind("VMImportSource"), src.Name, src.UID)
sourceSpec := map[string]interface{}{
"type": string(src.Spec.Type),
"url": src.Spec.URL,
"secret": map[string]interface{}{
"name": secretName,
"namespace": src.Namespace,
},
}
if src.Spec.Type == migrationv1alpha1.ProviderVSphere && r.VDDKImage != "" {
sourceSpec["settings"] = map[string]interface{}{
"vddkInitImage": r.VDDKImage,
}
}
if err := r.applyProvider(ctx, src, sourceProviderName(src.Name), sourceSpec, owner); err != nil {
return err
}
destSpec := map[string]interface{}{
"type": "openshift",
"url": "",
"secret": map[string]interface{}{},
}
return r.applyProvider(ctx, src, destinationProviderName(src.Name), destSpec, owner)
}
// ensureHosts materializes one Forklift Host per spec.hosts entry, redirecting
// the disk transfer away from the address vCenter advertises.
//
// Each host needs its own Secret: the ESXi host authenticates the transfer
// connection itself rather than honouring the vCenter session, and Forklift
// connection-tests the pair before any Plan referencing that host can run.
func (r *VMImportSourceReconciler) ensureHosts(ctx context.Context, src *migrationv1alpha1.VMImportSource) error {
owner := ownerRef(migrationv1alpha1.GroupVersion.WithKind("VMImportSource"), src.Name, src.UID)
for i := range src.Spec.Hosts {
h := &src.Spec.Hosts[i]
name := hostOverrideName(src.Name, h.ID)
secretName, err := projectSecret(ctx, r.Client, src, name, h.Credentials)
if err != nil {
return err
}
spec := map[string]interface{}{
"id": h.ID,
"ipAddress": h.Address,
"provider": map[string]interface{}{
"name": sourceProviderName(src.Name),
"namespace": src.Namespace,
},
"secret": map[string]interface{}{
"name": secretName,
"namespace": src.Namespace,
},
}
existing := newObject(hostGVK)
err = r.Get(ctx, types.NamespacedName{Namespace: src.Namespace, Name: name}, existing)
if apierrors.IsNotFound(err) {
obj := newObject(hostGVK)
obj.SetName(name)
obj.SetNamespace(src.Namespace)
obj.SetLabels(map[string]string{
migrationv1alpha1.ManagedByLabel: migrationv1alpha1.ManagedByValue,
})
obj.SetOwnerReferences([]metav1.OwnerReference{owner})
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
}
continue
}
if err != nil {
return err
}
current, _, _ := unstructured.NestedMap(existing.Object, "spec")
if specEqual(current, spec) {
continue
}
if err := unstructured.SetNestedMap(existing.Object, spec, "spec"); err != nil {
return err
}
if err := r.Update(ctx, existing); err != nil {
return err
}
}
return r.pruneHosts(ctx, src)
}
// pruneHosts removes the Hosts and projected Secrets of overrides the source no
// longer lists.
//
// A Source is editable, so an override can be dropped after it was created.
// Creating and updating alone would leave the old Host in place, and a Host is
// not inert: Forklift keeps routing that ESXi host's transfers through the
// address it names, so a stale one silently redirects traffic to somewhere the
// tenant has removed from the spec — and its credentials outlive the decision
// to stop using them, until the whole Source is deleted.
//
// Ownership is checked rather than assumed. The label alone says this
// controller made the object, not that it made it for this Source, and two
// Sources in one namespace would otherwise delete each other's Hosts.
func (r *VMImportSourceReconciler) pruneHosts(ctx context.Context, src *migrationv1alpha1.VMImportSource) error {
wanted := make(map[string]bool, len(src.Spec.Hosts))
for i := range src.Spec.Hosts {
wanted[hostOverrideName(src.Name, src.Spec.Hosts[i].ID)] = true
}
hosts := &unstructured.UnstructuredList{}
hosts.SetGroupVersionKind(hostGVK)
if err := r.List(ctx, hosts,
client.InNamespace(src.Namespace),
client.MatchingLabels{migrationv1alpha1.ManagedByLabel: migrationv1alpha1.ManagedByValue},
); err != nil {
// Forklift's CRDs can be absent; that is not this reconcile's problem.
if meta.IsNoMatchError(err) {
return nil
}
return err
}
for i := range hosts.Items {
host := &hosts.Items[i]
name := host.GetName()
if wanted[name] || !ownedBy(host, src) {
continue
}
if err := r.Delete(ctx, host); err != nil && !apierrors.IsNotFound(err) {
return err
}
// The projected credentials share the Host's name. They carry an owner
// reference to the Source, so leaving them would keep them alive for as
// long as the Source itself.
secret := &corev1.Secret{}
secret.Name = name
secret.Namespace = src.Namespace
if err := r.Delete(ctx, secret); err != nil && !apierrors.IsNotFound(err) {
return err
}
}
return nil
}
// ownedBy reports whether this Source is the object's owner, by UID rather than
// by name: names are reused, and a recreated Source is a different object.
func ownedBy(obj metav1.Object, src *migrationv1alpha1.VMImportSource) bool {
for _, ref := range obj.GetOwnerReferences() {
if ref.UID == src.UID {
return true
}
}
return false
}
func (r *VMImportSourceReconciler) applyProvider(
ctx context.Context,
src *migrationv1alpha1.VMImportSource,
name string,
spec map[string]interface{},
owner metav1.OwnerReference,
) error {
existing := newObject(providerGVK)
err := r.Get(ctx, types.NamespacedName{Namespace: src.Namespace, Name: name}, existing)
if apierrors.IsNotFound(err) {
obj := newObject(providerGVK)
obj.SetName(name)
obj.SetNamespace(src.Namespace)
obj.SetLabels(map[string]string{
migrationv1alpha1.ManagedByLabel: migrationv1alpha1.ManagedByValue,
})
obj.SetOwnerReferences([]metav1.OwnerReference{owner})
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
}
if err != nil {
return err
}
current, _, _ := unstructured.NestedMap(existing.Object, "spec")
if specEqual(current, spec) {
return nil
}
if err := unstructured.SetNestedMap(existing.Object, spec, "spec"); err != nil {
return err
}
return r.Update(ctx, existing)
}
// providerVerdict reads the source Provider's conditions. Forklift publishes a
// Ready condition once it has authenticated and read the inventory; anything
// else is reported verbatim so a tenant sees Forklift's own words about their
// endpoint rather than a paraphrase.
func (r *VMImportSourceReconciler) providerVerdict(ctx context.Context, src *migrationv1alpha1.VMImportSource) (bool, string, string, error) {
provider := newObject(providerGVK)
err := r.Get(ctx, types.NamespacedName{Namespace: src.Namespace, Name: sourceProviderName(src.Name)}, provider)
if apierrors.IsNotFound(err) {
return false, migrationv1alpha1.ReasonConnectionFailed, "the Forklift Provider has not been created yet", nil
}
if err != nil {
if meta.IsNoMatchError(err) {
return false, migrationv1alpha1.ReasonForkliftNotInstalled,
"the migration engine is not installed in this cluster: enable the " +
"forklift-operator and forklift packages", nil
}
return false, "", "", err
}
conditions, _, _ := unstructured.NestedSlice(provider.Object, "status", "conditions")
var critical []string
for _, raw := range conditions {
cond, ok := raw.(map[string]interface{})
if !ok {
continue
}
ctype, _, _ := unstructured.NestedString(cond, "type")
status, _, _ := unstructured.NestedString(cond, "status")
category, _, _ := unstructured.NestedString(cond, "category")
message, _, _ := unstructured.NestedString(cond, "message")
if ctype == "Ready" && status == "True" {
return true, migrationv1alpha1.ReasonConnected, "the provider endpoint answered and its inventory is readable", nil
}
if status == "True" && (category == "Critical" || category == "Error") {
critical = append(critical, fmt.Sprintf("%s: %s", ctype, message))
}
}
if len(critical) > 0 {
return false, migrationv1alpha1.ReasonConnectionFailed, strings.Join(critical, "; "), nil
}
return false, migrationv1alpha1.ReasonConnectionFailed, "waiting for Forklift to test the connection", nil
}
func (r *VMImportSourceReconciler) fail(ctx context.Context, src *migrationv1alpha1.VMImportSource, reason, message string) (ctrl.Result, error) {
if err := r.setReady(ctx, src, metav1.ConditionFalse, reason, message); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: ConnectionRequeue}, nil
}
func (r *VMImportSourceReconciler) setReady(ctx context.Context, src *migrationv1alpha1.VMImportSource, status metav1.ConditionStatus, reason, message string) error {
cond := metav1.Condition{
Type: migrationv1alpha1.SourceReadyCondition,
Status: status,
Reason: reason,
Message: message,
ObservedGeneration: src.Generation,
}
if !conditionChanged(src.Status.Conditions, cond) && src.Status.ObservedGeneration == src.Generation {
return nil
}
meta.SetStatusCondition(&src.Status.Conditions, cond)
src.Status.ObservedGeneration = src.Generation
return r.Status().Update(ctx, src)
}
// conditionChanged reports whether setting cond would change anything the
// reader can see. Without this the reconciler would write status on every
// requeue, and a not-ready Source would rewrite its condition twice a minute
// forever.
func conditionChanged(existing []metav1.Condition, cond metav1.Condition) bool {
current := meta.FindStatusCondition(existing, cond.Type)
if current == nil {
return true
}
return current.Status != cond.Status ||
current.Reason != cond.Reason ||
current.Message != cond.Message ||
current.ObservedGeneration != cond.ObservedGeneration
}
func specEqual(a, b map[string]interface{}) bool {
return equality.Semantic.DeepEqual(a, b)
}
func (r *VMImportSourceReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&migrationv1alpha1.VMImportSource{}).
Named("vmimportsource").
Complete(r)
}
@@ -0,0 +1,347 @@
// SPDX-License-Identifier: Apache-2.0
package migrationcontroller
import (
"context"
"strings"
"testing"
corev1 "k8s.io/api/core/v1"
"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/kubernetes/scheme"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
migrationv1alpha1 "github.com/cozystack/cozystack/api/migration/v1alpha1"
)
func testScheme(t *testing.T) *runtime.Scheme {
t.Helper()
s := runtime.NewScheme()
if err := scheme.AddToScheme(s); err != nil {
t.Fatalf("add client-go scheme: %v", err)
}
if err := migrationv1alpha1.AddToScheme(s); err != nil {
t.Fatalf("add migration scheme: %v", err)
}
// Forklift, KubeVirt and CDI objects are unstructured at runtime, so the
// fake client needs both the object and list kinds registered to serve them.
s.AddKnownTypeWithName(providerGVK, &unstructured.Unstructured{})
s.AddKnownTypeWithName(providerGVK.GroupVersion().WithKind("ProviderList"), &unstructured.UnstructuredList{})
s.AddKnownTypeWithName(planGVK, &unstructured.Unstructured{})
s.AddKnownTypeWithName(planGVK.GroupVersion().WithKind("PlanList"), &unstructured.UnstructuredList{})
s.AddKnownTypeWithName(migrationGVK, &unstructured.Unstructured{})
s.AddKnownTypeWithName(migrationGVK.GroupVersion().WithKind("MigrationList"), &unstructured.UnstructuredList{})
s.AddKnownTypeWithName(networkMapGVK, &unstructured.Unstructured{})
s.AddKnownTypeWithName(networkMapGVK.GroupVersion().WithKind("NetworkMapList"), &unstructured.UnstructuredList{})
s.AddKnownTypeWithName(storageMapGVK, &unstructured.Unstructured{})
s.AddKnownTypeWithName(storageMapGVK.GroupVersion().WithKind("StorageMapList"), &unstructured.UnstructuredList{})
s.AddKnownTypeWithName(dataVolumeGVK, &unstructured.Unstructured{})
s.AddKnownTypeWithName(dataVolumeGVK.GroupVersion().WithKind("DataVolumeList"), &unstructured.UnstructuredList{})
s.AddKnownTypeWithName(virtualMachineGVK, &unstructured.Unstructured{})
s.AddKnownTypeWithName(virtualMachineGVK.GroupVersion().WithKind("VirtualMachineList"), &unstructured.UnstructuredList{})
s.AddKnownTypeWithName(vmDiskGVK, &unstructured.Unstructured{})
s.AddKnownTypeWithName(vmDiskGVK.GroupVersion().WithKind("VMDiskList"), &unstructured.UnstructuredList{})
s.AddKnownTypeWithName(vmInstanceGVK, &unstructured.Unstructured{})
s.AddKnownTypeWithName(vmInstanceGVK.GroupVersion().WithKind("VMInstanceList"), &unstructured.UnstructuredList{})
return s
}
func vsphereSource(name, namespace string) *migrationv1alpha1.VMImportSource {
return &migrationv1alpha1.VMImportSource{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, UID: types.UID(name + "-uid")},
Spec: migrationv1alpha1.VMImportSourceSpec{
Type: migrationv1alpha1.ProviderVSphere,
URL: "https://vcenter.example.com/sdk",
Credentials: migrationv1alpha1.ProviderCredentials{
Username: "migration@vsphere.local",
Password: "hunter2",
CACert: "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n",
},
},
}
}
// TestSourceWithoutVDDKIsUnavailableNotBroken locks in the policy that a
// missing VDDK image is reported on the object at registration time. The whole
// point is that the tenant learns the VMware path is unconfigured before a
// transfer starts, so this must never silently create a Provider.
func TestSourceWithoutVDDKIsUnavailableNotBroken(t *testing.T) {
s := testScheme(t)
src := vsphereSource("vcenter", "tenant-foo")
c := clientfake.NewClientBuilder().WithScheme(s).
WithObjects(src).WithStatusSubresource(src).Build()
r := &VMImportSourceReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10), VDDKImage: ""}
if _, err := r.Reconcile(context.Background(), reconcile.Request{
NamespacedName: types.NamespacedName{Namespace: "tenant-foo", Name: "vcenter"},
}); err != nil {
t.Fatalf("reconcile: %v", err)
}
got := &migrationv1alpha1.VMImportSource{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-foo", Name: "vcenter"}, got); err != nil {
t.Fatalf("get source: %v", err)
}
cond := meta.FindStatusCondition(got.Status.Conditions, migrationv1alpha1.SourceReadyCondition)
if cond == nil {
t.Fatal("expected a Ready condition")
}
if cond.Status != metav1.ConditionFalse {
t.Errorf("Ready = %s, want False", cond.Status)
}
if cond.Reason != migrationv1alpha1.ReasonVDDKNotConfigured {
t.Errorf("reason = %q, want %q", cond.Reason, migrationv1alpha1.ReasonVDDKNotConfigured)
}
provider := newObject(providerGVK)
err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-foo", Name: sourceProviderName("vcenter")}, provider)
if err == nil {
t.Error("a Provider was created even though no VDDK image is configured")
}
}
// TestSourceProjectsCredentialsAndProviders asserts the happy path materializes
// exactly what Forklift needs, and that the credentials never require the
// tenant to have created a Secret.
func TestSourceProjectsCredentialsAndProviders(t *testing.T) {
s := testScheme(t)
src := vsphereSource("vcenter", "tenant-foo")
c := clientfake.NewClientBuilder().WithScheme(s).
WithObjects(src).WithStatusSubresource(src).Build()
r := &VMImportSourceReconciler{
Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10),
VDDKImage: "registry.example.com/vddk:8.0.3",
}
if _, err := r.Reconcile(context.Background(), reconcile.Request{
NamespacedName: types.NamespacedName{Namespace: "tenant-foo", Name: "vcenter"},
}); err != nil {
t.Fatalf("reconcile: %v", err)
}
secret := &corev1.Secret{}
if err := c.Get(context.Background(), types.NamespacedName{
Namespace: "tenant-foo", Name: credentialsSecretName("vcenter"),
}, secret); err != nil {
t.Fatalf("credentials Secret was not projected: %v", err)
}
if got := string(secret.Data["user"]); got != "migration@vsphere.local" {
t.Errorf("user = %q", got)
}
if got := string(secret.Data["password"]); got != "hunter2" {
t.Errorf("password was not projected")
}
if secret.Labels[migrationv1alpha1.ManagedByLabel] != migrationv1alpha1.ManagedByValue {
t.Error("projected Secret is missing the managed-by label that protects it from being adopted by mistake")
}
provider := newObject(providerGVK)
if err := c.Get(context.Background(), types.NamespacedName{
Namespace: "tenant-foo", Name: sourceProviderName("vcenter"),
}, provider); err != nil {
t.Fatalf("source Provider was not created: %v", err)
}
image, _, _ := unstructured.NestedString(provider.Object, "spec", "settings", "vddkInitImage")
if image != "registry.example.com/vddk:8.0.3" {
t.Errorf("vddkInitImage = %q, want the operator-configured image", image)
}
ns, _, _ := unstructured.NestedString(provider.Object, "spec", "secret", "namespace")
if ns != "tenant-foo" {
t.Errorf("secret namespace = %q, want the source's own namespace", ns)
}
dest := newObject(providerGVK)
if err := c.Get(context.Background(), types.NamespacedName{
Namespace: "tenant-foo", Name: destinationProviderName("vcenter"),
}, dest); err != nil {
t.Fatalf("destination Provider was not created: %v", err)
}
}
// TestProjectorRefusesForeignSecret is the guard that keeps a name collision
// from destroying a tenant's own object: a pre-existing Secret without our
// label is reported, never overwritten.
func TestProjectorRefusesForeignSecret(t *testing.T) {
s := testScheme(t)
src := vsphereSource("vcenter", "tenant-foo")
foreign := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: credentialsSecretName("vcenter"),
Namespace: "tenant-foo",
},
Data: map[string][]byte{"something": []byte("precious")},
}
c := clientfake.NewClientBuilder().WithScheme(s).
WithObjects(src, foreign).WithStatusSubresource(src).Build()
r := &VMImportSourceReconciler{
Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10),
VDDKImage: "registry.example.com/vddk:8.0.3",
}
if _, err := r.Reconcile(context.Background(), reconcile.Request{
NamespacedName: types.NamespacedName{Namespace: "tenant-foo", Name: "vcenter"},
}); err != nil {
t.Fatalf("reconcile: %v", err)
}
kept := &corev1.Secret{}
if err := c.Get(context.Background(), types.NamespacedName{
Namespace: "tenant-foo", Name: credentialsSecretName("vcenter"),
}, kept); err != nil {
t.Fatalf("get secret: %v", err)
}
if string(kept.Data["something"]) != "precious" {
t.Fatal("a pre-existing Secret was overwritten by the projector")
}
got := &migrationv1alpha1.VMImportSource{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-foo", Name: "vcenter"}, got); err != nil {
t.Fatalf("get source: %v", err)
}
cond := meta.FindStatusCondition(got.Status.Conditions, migrationv1alpha1.SourceReadyCondition)
if cond == nil || cond.Status != metav1.ConditionFalse {
t.Fatal("expected the source to report itself not ready")
}
if !strings.Contains(cond.Message, "not managed by the migration controller") {
t.Errorf("message does not explain the collision: %q", cond.Message)
}
}
// TestSourceReportsMissingForkliftInsteadOfLoopingOnAnError locks in a
// live-only finding. When the Forklift CRDs are absent, creating a Provider
// fails with a no-match error. Returning that as a reconcile error means the
// pass never reaches the status write, so the Source keeps whatever condition
// it had last — which on the cluster meant a Source insisting VDDKNotConfigured
// long after a VDDK image had been configured, while the controller hot-looped.
func TestSourceReportsMissingForkliftInsteadOfLoopingOnAnError(t *testing.T) {
s := testScheme(t)
src := vsphereSource("vcenter", "tenant-foo")
// The error has to be the one a real apiserver produces for an absent CRD:
// a RESTMapper NoKindMatchError. Simply leaving the kind out of the fake
// client's scheme yields runtime.notRegisteredErr instead, which
// meta.IsNoMatchError does not recognise — so a test built that way would
// pass against the wrong error and prove nothing about production.
noMatch := &meta.NoKindMatchError{
GroupKind: providerGVK.GroupKind(),
SearchedVersions: []string{providerGVK.Version},
}
c := clientfake.NewClientBuilder().WithScheme(s).
WithObjects(src).WithStatusSubresource(src).
WithInterceptorFuncs(interceptor.Funcs{
Get: func(ctx context.Context, cl client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
if obj.GetObjectKind().GroupVersionKind() == providerGVK {
return noMatch
}
return cl.Get(ctx, key, obj, opts...)
},
}).Build()
r := &VMImportSourceReconciler{
Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10),
VDDKImage: "registry.example.com/vddk:8.0.3",
}
if _, err := r.Reconcile(context.Background(), reconcile.Request{
NamespacedName: types.NamespacedName{Namespace: "tenant-foo", Name: "vcenter"},
}); err != nil {
t.Fatalf("a cluster without Forklift must not produce a reconcile error: %v", err)
}
got := &migrationv1alpha1.VMImportSource{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-foo", Name: "vcenter"}, got); err != nil {
t.Fatalf("get source: %v", err)
}
cond := meta.FindStatusCondition(got.Status.Conditions, migrationv1alpha1.SourceReadyCondition)
if cond == nil {
t.Fatal("expected a Ready condition explaining why the source is unusable")
}
if cond.Reason != migrationv1alpha1.ReasonForkliftNotInstalled {
t.Errorf("reason = %q, want %q", cond.Reason, migrationv1alpha1.ReasonForkliftNotInstalled)
}
}
// TestSourceMirrorsForkliftVerdict asserts the Source reports Forklift's own
// conclusion rather than assuming a connection nobody tested.
func TestSourceMirrorsForkliftVerdict(t *testing.T) {
cases := []struct {
name string
conditions []interface{}
wantReady metav1.ConditionStatus
wantInMsg string
}{
{
name: "no conditions yet",
conditions: nil,
wantReady: metav1.ConditionFalse,
wantInMsg: "waiting for Forklift",
},
{
name: "forklift says ready",
conditions: []interface{}{
map[string]interface{}{"type": "Ready", "status": "True"},
},
wantReady: metav1.ConditionTrue,
},
{
name: "forklift reports a critical problem",
conditions: []interface{}{
map[string]interface{}{
"type": "ConnectionTestFailed", "status": "True",
"category": "Critical", "message": "host is unreachable",
},
},
wantReady: metav1.ConditionFalse,
wantInMsg: "host is unreachable",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := testScheme(t)
src := vsphereSource("vcenter", "tenant-foo")
provider := newObject(providerGVK)
provider.SetName(sourceProviderName("vcenter"))
provider.SetNamespace("tenant-foo")
if tc.conditions != nil {
if err := unstructured.SetNestedSlice(provider.Object, tc.conditions, "status", "conditions"); err != nil {
t.Fatalf("set conditions: %v", err)
}
}
c := clientfake.NewClientBuilder().WithScheme(s).
WithObjects(src, provider).WithStatusSubresource(src).Build()
r := &VMImportSourceReconciler{
Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10),
VDDKImage: "registry.example.com/vddk:8.0.3",
}
if _, err := r.Reconcile(context.Background(), reconcile.Request{
NamespacedName: types.NamespacedName{Namespace: "tenant-foo", Name: "vcenter"},
}); err != nil {
t.Fatalf("reconcile: %v", err)
}
got := &migrationv1alpha1.VMImportSource{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-foo", Name: "vcenter"}, got); err != nil {
t.Fatalf("get source: %v", err)
}
cond := meta.FindStatusCondition(got.Status.Conditions, migrationv1alpha1.SourceReadyCondition)
if cond == nil {
t.Fatal("expected a Ready condition")
}
if cond.Status != tc.wantReady {
t.Errorf("Ready = %s, want %s (message %q)", cond.Status, tc.wantReady, cond.Message)
}
if tc.wantInMsg != "" && !strings.Contains(cond.Message, tc.wantInMsg) {
t.Errorf("message = %q, want it to contain %q", cond.Message, tc.wantInMsg)
}
})
}
}
@@ -0,0 +1,619 @@
// 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 {
var nf *notFoundError
if errors.As(err, &nf) {
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)
}
@@ -0,0 +1,885 @@
// SPDX-License-Identifier: Apache-2.0
package migrationcontroller
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
corev1 "k8s.io/api/core/v1"
storagev1 "k8s.io/api/storage/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"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"
"k8s.io/client-go/tools/record"
clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
migrationv1alpha1 "github.com/cozystack/cozystack/api/migration/v1alpha1"
)
func storageClass(name string, mode storagev1.VolumeBindingMode, isDefault bool) *storagev1.StorageClass {
sc := &storagev1.StorageClass{
ObjectMeta: metav1.ObjectMeta{Name: name},
VolumeBindingMode: &mode,
}
if isDefault {
sc.Annotations = map[string]string{defaultStorageClassAnnotation: "true"}
}
return sc
}
func readySource(name, namespace string) *migrationv1alpha1.VMImportSource {
src := vsphereSource(name, namespace)
src.Status.Conditions = []metav1.Condition{{
Type: migrationv1alpha1.SourceReadyCondition,
Status: metav1.ConditionTrue,
Reason: migrationv1alpha1.ReasonConnected,
Message: "ok",
LastTransitionTime: metav1.Now(),
}}
return src
}
func task(name, namespace, source, class string, vms ...migrationv1alpha1.VMImportRequest) *migrationv1alpha1.VMImportTask {
return &migrationv1alpha1.VMImportTask{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, UID: types.UID(name + "-uid")},
Spec: migrationv1alpha1.VMImportTaskSpec{
SourceRef: corev1.LocalObjectReference{Name: source},
VMs: vms,
StorageClass: class,
},
}
}
// TestTaskRejectsWaitForFirstConsumer is the check that turns a hang into a
// message. A WaitForFirstConsumer class does not fail an import, it deadlocks
// it: nothing consumes the claim while CDI populates it. Failing at validation
// costs one edit; not failing costs a support ticket and a stuck transfer.
func TestTaskRejectsWaitForFirstConsumer(t *testing.T) {
s := testScheme(t)
src := readySource("vcenter", "tenant-foo")
tk := task("import", "tenant-foo", "vcenter", "local",
migrationv1alpha1.VMImportRequest{ID: "vm-1", Name: "web-01"})
c := clientfake.NewClientBuilder().WithScheme(s).
WithObjects(src, tk, storageClass("local", storagev1.VolumeBindingWaitForFirstConsumer, true)).
WithStatusSubresource(src, tk).Build()
r := &VMImportTaskReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
if _, err := r.Reconcile(context.Background(), reconcile.Request{
NamespacedName: types.NamespacedName{Namespace: "tenant-foo", Name: "import"},
}); err != nil {
t.Fatalf("reconcile: %v", err)
}
got := &migrationv1alpha1.VMImportTask{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-foo", Name: "import"}, got); err != nil {
t.Fatalf("get task: %v", err)
}
if got.Status.Phase != migrationv1alpha1.VMImportTaskPhaseFailed {
t.Errorf("phase = %q, want Failed", got.Status.Phase)
}
if !strings.Contains(got.Status.Message, "WaitForFirstConsumer") {
t.Errorf("message does not name the binding mode: %q", got.Status.Message)
}
// Nothing may have been created: a task that cannot succeed must not leave
// Forklift objects behind for a tenant to wonder about.
plan := newObject(planGVK)
if err := c.Get(context.Background(), types.NamespacedName{
Namespace: "tenant-foo", Name: planName("import", "vm-1"),
}, plan); err == nil {
t.Error("a Plan was created for a task that failed validation")
}
}
// TestTaskWaitsForUnreadySource asserts a task does not start work while its
// connection is unusable, and says which connection and why.
func TestTaskWaitsForUnreadySource(t *testing.T) {
s := testScheme(t)
src := vsphereSource("vcenter", "tenant-foo") // no Ready condition
tk := task("import", "tenant-foo", "vcenter", "replicated",
migrationv1alpha1.VMImportRequest{ID: "vm-1", Name: "web-01"})
c := clientfake.NewClientBuilder().WithScheme(s).
WithObjects(src, tk).WithStatusSubresource(src, tk).Build()
r := &VMImportTaskReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
if _, err := r.Reconcile(context.Background(), reconcile.Request{
NamespacedName: types.NamespacedName{Namespace: "tenant-foo", Name: "import"},
}); err != nil {
t.Fatalf("reconcile: %v", err)
}
got := &migrationv1alpha1.VMImportTask{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-foo", Name: "import"}, got); err != nil {
t.Fatalf("get task: %v", err)
}
if got.Status.Phase != migrationv1alpha1.VMImportTaskPhasePending {
t.Errorf("phase = %q, want Pending", got.Status.Phase)
}
if !strings.Contains(got.Status.Message, "vcenter") {
t.Errorf("message does not name the source: %q", got.Status.Message)
}
}
// TestTaskRefusesToOverwriteExistingInstance locks in that the controller never
// takes over a tenant's object. An import that would clobber a running VM must
// stop and say so.
func TestTaskRefusesToOverwriteExistingInstance(t *testing.T) {
s := testScheme(t)
src := readySource("vcenter", "tenant-foo")
tk := task("import", "tenant-foo", "vcenter", "replicated",
migrationv1alpha1.VMImportRequest{ID: "vm-1", Name: "web-01"})
occupied := newObject(vmInstanceGVK)
occupied.SetName("web-01")
occupied.SetNamespace("tenant-foo")
c := clientfake.NewClientBuilder().WithScheme(s).
WithObjects(src, tk, occupied, storageClass("replicated", storagev1.VolumeBindingImmediate, false)).
WithStatusSubresource(src, tk).Build()
r := &VMImportTaskReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
if _, err := r.Reconcile(context.Background(), reconcile.Request{
NamespacedName: types.NamespacedName{Namespace: "tenant-foo", Name: "import"},
}); err != nil {
t.Fatalf("reconcile: %v", err)
}
got := &migrationv1alpha1.VMImportTask{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-foo", Name: "import"}, got); err != nil {
t.Fatalf("get task: %v", err)
}
if got.Status.Phase != migrationv1alpha1.VMImportTaskPhaseFailed {
t.Errorf("phase = %q, want Failed", got.Status.Phase)
}
if !strings.Contains(got.Status.Message, "already exists") {
t.Errorf("message does not explain the collision: %q", got.Status.Message)
}
}
// TestAggregatePhase covers the fold from per-VM phases to the task's own. The
// property that matters: one failed VM must not stop its siblings, and the task
// only reaches a terminal phase when nothing can still progress.
func TestAggregatePhase(t *testing.T) {
p := func(phases ...migrationv1alpha1.VMImportTaskPhase) []migrationv1alpha1.VMImportStatus {
out := make([]migrationv1alpha1.VMImportStatus, 0, len(phases))
for i, ph := range phases {
out = append(out, migrationv1alpha1.VMImportStatus{ID: string(rune('a' + i)), Phase: ph})
}
return out
}
cases := []struct {
name string
in []migrationv1alpha1.VMImportStatus
want migrationv1alpha1.VMImportTaskPhase
}{
{"empty", nil, migrationv1alpha1.VMImportTaskPhasePending},
{"all succeeded", p(migrationv1alpha1.VMImportTaskPhaseSucceeded, migrationv1alpha1.VMImportTaskPhaseSucceeded), migrationv1alpha1.VMImportTaskPhaseSucceeded},
{"all failed", p(migrationv1alpha1.VMImportTaskPhaseFailed), migrationv1alpha1.VMImportTaskPhaseFailed},
{"mixed terminal is failed", p(migrationv1alpha1.VMImportTaskPhaseSucceeded, migrationv1alpha1.VMImportTaskPhaseFailed), migrationv1alpha1.VMImportTaskPhaseFailed},
{
"one failed while another transfers keeps going",
p(migrationv1alpha1.VMImportTaskPhaseFailed, migrationv1alpha1.VMImportTaskPhaseTransferring),
migrationv1alpha1.VMImportTaskPhaseTransferring,
},
{
"furthest-along non-terminal wins",
p(migrationv1alpha1.VMImportTaskPhaseValidating, migrationv1alpha1.VMImportTaskPhaseCreating),
migrationv1alpha1.VMImportTaskPhaseCreating,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := aggregatePhase(tc.in); got != tc.want {
t.Errorf("aggregatePhase = %q, want %q", got, tc.want)
}
})
}
}
// TestAdoptVolumeReplicatesVolumeModeAndAccessModes locks in a constraint that
// only a live cluster revealed. Pre-binding through volumeName bypasses the
// volumeMode match check, so a claim that silently defaults to Filesystem
// against a Block volume *binds anyway* and fails much later at attach time
// with a message pointing nowhere near the cause. The replacement claim must
// therefore copy the volume's own modes rather than assume a default.
func TestAdoptVolumeReplicatesVolumeModeAndAccessModes(t *testing.T) {
block := corev1.PersistentVolumeBlock
s := testScheme(t)
tk := task("import", "tenant-foo", "vcenter", "replicated")
pv := &corev1.PersistentVolume{
ObjectMeta: metav1.ObjectMeta{Name: "pv-1"},
Spec: corev1.PersistentVolumeSpec{
AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany},
VolumeMode: &block,
StorageClassName: "replicated",
PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimDelete,
Capacity: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("20Gi")},
},
}
transferred := &corev1.PersistentVolumeClaim{
ObjectMeta: metav1.ObjectMeta{Name: "forklift-claim", Namespace: "tenant-foo"},
Spec: corev1.PersistentVolumeClaimSpec{
VolumeName: "pv-1",
Resources: corev1.VolumeResourceRequirements{
Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("20Gi")},
},
},
}
c := clientfake.NewClientBuilder().WithScheme(s).WithObjects(pv, transferred, tk).Build()
r := &VMImportTaskReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
if err := r.adoptVolume(context.Background(), tk, "vm-1", "forklift-claim", "web-01-disk-0"); err != nil {
t.Fatalf("adoptVolume: %v", err)
}
adopted := &corev1.PersistentVolumeClaim{}
if err := c.Get(context.Background(), types.NamespacedName{
Namespace: "tenant-foo", Name: "vm-disk-web-01-disk-0",
}, adopted); err != nil {
t.Fatalf("replacement claim was not created: %v", err)
}
if adopted.Spec.VolumeMode == nil || *adopted.Spec.VolumeMode != corev1.PersistentVolumeBlock {
t.Errorf("volumeMode = %v, want Block copied from the volume", adopted.Spec.VolumeMode)
}
if len(adopted.Spec.AccessModes) != 1 || adopted.Spec.AccessModes[0] != corev1.ReadWriteMany {
t.Errorf("accessModes = %v, want ReadWriteMany copied from the volume", adopted.Spec.AccessModes)
}
if adopted.Spec.VolumeName != "pv-1" {
t.Errorf("volumeName = %q, want the claim pre-bound to the retained volume", adopted.Spec.VolumeName)
}
if adopted.Annotations[populatedForAnnotation] != "vm-disk-web-01-disk-0" {
t.Errorf("populatedFor = %q, want it to equal the DataVolume name",
adopted.Annotations[populatedForAnnotation])
}
// Retain is not advice: it is the only thing standing between a routine
// DataVolume deletion and data loss, because CDI takes a controller owner
// reference on the claim it adopts.
gotPV := &corev1.PersistentVolume{}
if err := c.Get(context.Background(), types.NamespacedName{Name: "pv-1"}, gotPV); err != nil {
t.Fatalf("get pv: %v", err)
}
if gotPV.Spec.PersistentVolumeReclaimPolicy != corev1.PersistentVolumeReclaimRetain {
t.Errorf("reclaim policy = %q, want Retain", gotPV.Spec.PersistentVolumeReclaimPolicy)
}
if gotPV.Spec.ClaimRef == nil || gotPV.Spec.ClaimRef.Name != "vm-disk-web-01-disk-0" {
t.Fatalf("claimRef was not re-pointed: %+v", gotPV.Spec.ClaimRef)
}
if gotPV.Spec.ClaimRef.UID != adopted.UID {
t.Error("claimRef carries no UID, leaving a window where another claim could bind the volume")
}
// The DataVolume the VMDisk release will adopt must exist and must not
// re-fetch anything: a blank source is what makes the handoff free.
dv := newObject(dataVolumeGVK)
if err := c.Get(context.Background(), types.NamespacedName{
Namespace: "tenant-foo", Name: "vm-disk-web-01-disk-0",
}, dv); err != nil {
t.Fatalf("DataVolume was not created: %v", err)
}
if _, found, _ := unstructured.NestedMap(dv.Object, "spec", "source", "blank"); !found {
t.Error("DataVolume source is not blank; the handoff would trigger a second copy")
}
if dv.GetAnnotations()[helmReleaseNameAnnotation] != "vm-disk-web-01-disk-0" {
t.Error("DataVolume lacks Helm ownership metadata, so the VMDisk release would collide with it")
}
// And the tenant-facing object, with no owner reference to the task: that
// is what lets a finished task be deleted without losing the disk.
disk := newObject(vmDiskGVK)
if err := c.Get(context.Background(), types.NamespacedName{
Namespace: "tenant-foo", Name: "web-01-disk-0",
}, disk); err != nil {
t.Fatalf("VMDisk was not created: %v", err)
}
if len(disk.GetOwnerReferences()) != 0 {
t.Error("VMDisk carries an owner reference; deleting the task would destroy the imported disk")
}
}
// TestAdoptVolumeRemovesOwningDataVolumeFirst locks in the second live-only
// finding. Deleting a claim while its DataVolume still exists makes CDI
// recreate the claim and provision a second volume, re-running the whole
// import — the exact duplicate copy this design exists to remove.
func TestAdoptVolumeRemovesOwningDataVolumeFirst(t *testing.T) {
s := testScheme(t)
tk := task("import", "tenant-foo", "vcenter", "replicated")
pv := &corev1.PersistentVolume{
ObjectMeta: metav1.ObjectMeta{Name: "pv-1"},
Spec: corev1.PersistentVolumeSpec{
StorageClassName: "replicated",
Capacity: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("10Gi")},
},
}
// The transferred claim as CDI leaves it: owned by its DataVolume.
controller := true
transferred := &corev1.PersistentVolumeClaim{
ObjectMeta: metav1.ObjectMeta{
Name: "forklift-claim",
Namespace: "tenant-foo",
OwnerReferences: []metav1.OwnerReference{{
APIVersion: "cdi.kubevirt.io/v1beta1",
Kind: "DataVolume",
Name: "forklift-claim",
UID: "dv-uid",
Controller: &controller,
}},
},
Spec: corev1.PersistentVolumeClaimSpec{
VolumeName: "pv-1",
Resources: corev1.VolumeResourceRequirements{
Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("10Gi")},
},
},
}
forkliftDV := newObject(dataVolumeGVK)
forkliftDV.SetName("forklift-claim")
forkliftDV.SetNamespace("tenant-foo")
c := clientfake.NewClientBuilder().WithScheme(s).
WithObjects(pv, transferred, forkliftDV, tk).Build()
r := &VMImportTaskReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
if err := r.adoptVolume(context.Background(), tk, "vm-1", "forklift-claim", "web-01-disk-0"); err != nil {
t.Fatalf("adoptVolume: %v", err)
}
gone := newObject(dataVolumeGVK)
err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-foo", Name: "forklift-claim"}, gone)
if err == nil {
t.Fatal("the transferring DataVolume still exists; CDI would recreate the claim and run a second full copy")
}
oldClaim := &corev1.PersistentVolumeClaim{}
if err := c.Get(context.Background(), types.NamespacedName{
Namespace: "tenant-foo", Name: "forklift-claim",
}, oldClaim); err == nil {
t.Error("the original claim was left behind")
}
}
// TestAdoptVolumeIsIdempotent asserts a second pass over an already-adopted
// disk is a no-op rather than a second swap: the controller is re-entered on
// every requeue and after every restart.
func TestAdoptVolumeIsIdempotent(t *testing.T) {
s := testScheme(t)
tk := task("import", "tenant-foo", "vcenter", "replicated")
already := &corev1.PersistentVolumeClaim{
ObjectMeta: metav1.ObjectMeta{
Name: "vm-disk-web-01-disk-0",
Namespace: "tenant-foo",
// The markers are what make this a resume rather than a collision:
// an unmarked claim of the same name belongs to the tenant.
Labels: map[string]string{
migrationv1alpha1.OutputTaskUIDLabel: "import-uid",
migrationv1alpha1.OutputVMIDLabel: "vm-1",
},
},
Spec: corev1.PersistentVolumeClaimSpec{
VolumeName: "pv-1",
Resources: corev1.VolumeResourceRequirements{
Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("20Gi")},
},
},
}
c := clientfake.NewClientBuilder().WithScheme(s).WithObjects(already, tk).Build()
r := &VMImportTaskReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
// The source claim is deliberately absent: a second pass must not need it.
if err := r.adoptVolume(context.Background(), tk, "vm-1", "forklift-claim", "web-01-disk-0"); err != nil {
t.Fatalf("second pass over an adopted disk failed: %v", err)
}
disk := newObject(vmDiskGVK)
if err := c.Get(context.Background(), types.NamespacedName{
Namespace: "tenant-foo", Name: "web-01-disk-0",
}, disk); err != nil {
t.Fatalf("VMDisk was not created on the second pass: %v", err)
}
}
// TestSourceVMResources asserts the source VM's shape is read off the object
// Forklift already built, which is why no inventory client is needed.
func TestSourceVMResources(t *testing.T) {
vm := newObject(virtualMachineGVK)
if err := unstructured.SetNestedMap(vm.Object, map[string]interface{}{
"cores": int64(2),
"sockets": int64(2),
}, "spec", "template", "spec", "domain", "cpu"); err != nil {
t.Fatalf("set cpu: %v", err)
}
if err := unstructured.SetNestedField(vm.Object, "8Gi",
"spec", "template", "spec", "domain", "memory", "guest"); err != nil {
t.Fatalf("set memory: %v", err)
}
// Cores and sockets stay separate. vm-instance maps them to
// domain.cpu.cores and domain.cpu.sockets and refuses to render unless both
// are present, so multiplying them into one number both loses the topology
// and fails the chart — observed live: "Either instanceType or resources
// (cpu, sockets, memory) must be specified".
cores, sockets, memory := sourceVMResources(vm)
if cores != "2" {
t.Errorf("cores = %q, want 2", cores)
}
if sockets != "2" {
t.Errorf("sockets = %q, want 2", sockets)
}
if memory != "8Gi" {
t.Errorf("memory = %q, want 8Gi", memory)
}
}
// A source that reports cores but no socket count is single-socket. Saying so
// explicitly is what lets the chart render at all.
func TestSourceVMResourcesDefaultsSingleSocket(t *testing.T) {
vm := newObject(virtualMachineGVK)
if err := unstructured.SetNestedField(vm.Object, int64(4),
"spec", "template", "spec", "domain", "cpu", "cores"); err != nil {
t.Fatalf("set cores: %v", err)
}
cores, sockets, _ := sourceVMResources(vm)
if cores != "4" || sockets != "1" {
t.Errorf("cores/sockets = %q/%q, want 4/1", cores, sockets)
}
}
// TestMigrationProgressReadsTheTransferStep asserts progress reflects the disk
// transfer rather than an average over every pipeline step, which would report
// a four-step pipeline as 25% done before any data moved.
func TestMigrationProgressReadsTheTransferStep(t *testing.T) {
m := newObject(migrationGVK)
if err := unstructured.SetNestedSlice(m.Object, []interface{}{
map[string]interface{}{
"id": "vm-1",
"pipeline": []interface{}{
map[string]interface{}{
"name": "Initialize",
"progress": map[string]interface{}{"completed": int64(1), "total": int64(1)},
},
map[string]interface{}{
"name": "DiskTransfer",
"progress": map[string]interface{}{"completed": int64(3), "total": int64(10)},
},
},
},
}, "status", "vms"); err != nil {
t.Fatalf("set vms: %v", err)
}
done, progress, failure := migrationProgress(m)
if done {
t.Error("migration reported done with no completion timestamp")
}
if failure != "" {
t.Errorf("unexpected failure: %q", failure)
}
if progress != 30 {
t.Errorf("progress = %d, want 30", progress)
}
}
// TestMigrationProgressFallsBackWhenStepsAreUnnamed guards against pegging
// progress at zero for a whole transfer. The pipeline step names are not part
// of any contract, and a release that renames them would silently break a
// name-only match — so an unrecognised pipeline still has to report something.
func TestMigrationProgressFallsBackWhenStepsAreUnnamed(t *testing.T) {
m := newObject(migrationGVK)
if err := unstructured.SetNestedSlice(m.Object, []interface{}{
map[string]interface{}{
"id": "vm-1",
"pipeline": []interface{}{
map[string]interface{}{
"name": "SomeFutureStepName",
"progress": map[string]interface{}{"completed": int64(4096), "total": int64(16384)},
},
},
},
}, "status", "vms"); err != nil {
t.Fatalf("set vms: %v", err)
}
_, progress, _ := migrationProgress(m)
if progress != 25 {
t.Errorf("progress = %d, want 25 from the unnamed step", progress)
}
}
// TestMigrationProgressAcceptsCompletedPhase asserts the phase the engine
// settles on is treated as terminal. Waiting only on a completion timestamp
// would leave a finished transfer stuck in Transferring.
func TestMigrationProgressAcceptsCompletedPhase(t *testing.T) {
m := newObject(migrationGVK)
if err := unstructured.SetNestedSlice(m.Object, []interface{}{
map[string]interface{}{"id": "vm-1", "phase": "Completed"},
}, "status", "vms"); err != nil {
t.Fatalf("set vms: %v", err)
}
done, progress, failure := migrationProgress(m)
if !done {
t.Error("a Completed phase must count as done")
}
if progress != 100 || failure != "" {
t.Errorf("progress = %d, failure = %q", progress, failure)
}
}
// TestAdoptVolumeOrphansAClaimWithNoDataVolume is the populator path, which is
// the path Forklift actually takes: the transferred disk arrives as a populated
// claim with no DataVolume, carrying an ownerReference to the VirtualMachine
// Forklift built. Anything that later deletes that VM takes the disk with it,
// and garbage collection wins any race against a copy — so the reference has to
// be cleared even though there is no DataVolume to trigger the cleanup.
func TestAdoptVolumeOrphansAClaimWithNoDataVolume(t *testing.T) {
s := testScheme(t)
tk := task("import", "tenant-foo", "vcenter", "replicated")
pv := &corev1.PersistentVolume{
ObjectMeta: metav1.ObjectMeta{Name: "pv-1"},
Spec: corev1.PersistentVolumeSpec{
StorageClassName: "replicated",
Capacity: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("16Gi")},
},
}
blockOwnerDeletion := true
notController := false
populated := &corev1.PersistentVolumeClaim{
ObjectMeta: metav1.ObjectMeta{
// Forklift's own naming: <plan>-vm-<id>-<random>.
Name: "import-vm-1234-pdbdm",
Namespace: "tenant-foo",
OwnerReferences: []metav1.OwnerReference{{
APIVersion: "kubevirt.io/v1",
Kind: "VirtualMachine",
Name: "test-vm",
UID: "vm-uid",
Controller: &notController,
BlockOwnerDeletion: &blockOwnerDeletion,
}},
},
Spec: corev1.PersistentVolumeClaimSpec{
VolumeName: "pv-1",
Resources: corev1.VolumeResourceRequirements{
Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("16Gi")},
},
},
}
// Deliberately no DataVolume: that is what the populator path looks like.
c := clientfake.NewClientBuilder().WithScheme(s).WithObjects(pv, populated, tk).Build()
r := &VMImportTaskReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
if err := r.adoptVolume(context.Background(), tk, "vm-1", "import-vm-1234-pdbdm", "web-01-disk-0"); err != nil {
t.Fatalf("adoptVolume on the populator path: %v", err)
}
adopted := &corev1.PersistentVolumeClaim{}
if err := c.Get(context.Background(), types.NamespacedName{
Namespace: "tenant-foo", Name: "vm-disk-web-01-disk-0",
}, adopted); err != nil {
t.Fatalf("replacement claim was not created: %v", err)
}
if len(adopted.OwnerReferences) != 0 {
t.Error("the adopted claim carries an owner reference; deleting the Forklift VM would destroy the disk")
}
gotPV := &corev1.PersistentVolume{}
if err := c.Get(context.Background(), types.NamespacedName{Name: "pv-1"}, gotPV); err != nil {
t.Fatalf("get pv: %v", err)
}
if gotPV.Spec.PersistentVolumeReclaimPolicy != corev1.PersistentVolumeReclaimRetain {
t.Error("the volume was not retained")
}
}
// TestMigrationProgressSurfacesForkliftErrors asserts a failed transfer carries
// Forklift's own words rather than a paraphrase.
func TestMigrationProgressSurfacesForkliftErrors(t *testing.T) {
m := newObject(migrationGVK)
if err := unstructured.SetNestedSlice(m.Object, []interface{}{
map[string]interface{}{
"id": "vm-1",
"error": map[string]interface{}{
"reasons": []interface{}{"vddk: failed to open disk"},
},
},
}, "status", "vms"); err != nil {
t.Fatalf("set vms: %v", err)
}
done, _, failure := migrationProgress(m)
if done {
t.Error("a failed migration must not report done")
}
if !strings.Contains(failure, "failed to open disk") {
t.Errorf("failure = %q, want Forklift's own reason", failure)
}
}
// The topology comes out of an inventory record, and this is the shape the
// inventory actually returns — verified against a live Forklift v2.11.5
// inventory service on 2026-08-31, where a vSphere VM reported
// networks:[{kind:Network,id:"HaNetwork-..."}] and
// disks:[{...,datastore:{kind:Datastore,id:"61f94272-..."}}].
//
// An earlier revision read these from the Plan's VMNetworksNotMapped /
// VMStorageNotMapped conditions instead. That cannot work: those conditions
// carry the VM's own reference, not the network or datastore, so the map was
// built from parsed VM names and never resolved. The test that covered it
// asserted bare IDs Forklift never emits and so locked in the wrong contract.
func TestInventoryVMYieldsNetworksAndDatastores(t *testing.T) {
const record = `{
"id": "86",
"name": "test-matthieu",
"firmware": "efi",
"powerState": "poweredOff",
"networks": [{"kind": "Network", "id": "HaNetwork-Cluster Hidora tools"}],
"disks": [
{"datastore": {"kind": "Datastore", "id": "61f94272-8f3a3584-b45f-3448edf98902"}},
{"datastore": {"kind": "Datastore", "id": "61f94272-8f3a3584-b45f-3448edf98902"}}
]
}`
vm := &inventoryVM{}
if err := json.Unmarshal([]byte(record), vm); err != nil {
t.Fatalf("decoding the inventory record: %v", err)
}
if vm.Firmware != "efi" || vm.PowerState != "poweredOff" {
t.Errorf("firmware/power = %q/%q", vm.Firmware, vm.PowerState)
}
if got := vm.networkIDs(); len(got) != 1 || got[0] != "HaNetwork-Cluster Hidora tools" {
t.Errorf("networkIDs = %v", got)
}
// Two disks on one datastore yield one map entry, not two.
if got := vm.datastoreIDs(); len(got) != 1 || got[0] != "61f94272-8f3a3584-b45f-3448edf98902" {
t.Errorf("datastoreIDs = %v", got)
}
}
// A Plan that does not pin the target power state lets Forklift match the
// source's: a production cutover migrates running machines, so the imported VM
// would boot the instant the transfer finished — a duplicate of a live machine
// on the network, started on the very volume the handoff is about to re-point.
func TestPlanKeepsTheImportedVMPoweredOff(t *testing.T) {
s := testScheme(t)
tk := task("import", "tenant-foo", "vcenter", "replicated")
src := readySource("vcenter", "tenant-foo")
c := clientfake.NewClientBuilder().WithScheme(s).WithObjects(tk, src).Build()
r := &VMImportTaskReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
req := &migrationv1alpha1.VMImportRequest{ID: "vm-1234"}
if err := r.createPlan(context.Background(), tk, src, req); err != nil {
t.Fatalf("createPlan: %v", err)
}
plan := newObject(planGVK)
if err := c.Get(context.Background(), types.NamespacedName{
Namespace: "tenant-foo", Name: planName("import", "vm-1234"),
}, plan); err != nil {
t.Fatalf("Plan was not created: %v", err)
}
vms, _, _ := unstructured.NestedSlice(plan.Object, "spec", "vms")
if len(vms) != 1 {
t.Fatalf("spec.vms = %d entries, want 1", len(vms))
}
entry, _ := vms[0].(map[string]interface{})
got, _, _ := unstructured.NestedString(entry, "targetPowerState")
if got != "off" {
t.Errorf("targetPowerState = %q, want \"off\" — Forklift would boot the migrated guest before the handoff", got)
}
}
// The source's boot mode has to reach the VMInstance. A UEFI guest rendered
// with a BIOS bootloader imports "successfully" and then does not boot, which
// is the whole reason the vm-instance firmware field is a merge-order
// dependency of this controller.
func TestSourceFirmwareIsCarriedAcross(t *testing.T) {
cases := []struct {
name string
set func(vm *unstructured.Unstructured)
want map[string]interface{}
}{
{"efi", func(vm *unstructured.Unstructured) {
_ = unstructured.SetNestedMap(vm.Object, map[string]interface{}{},
"spec", "template", "spec", "domain", "firmware", "bootloader", "efi")
}, map[string]interface{}{"bootloader": "uefi"}},
{"efi with secureBoot", func(vm *unstructured.Unstructured) {
_ = unstructured.SetNestedField(vm.Object, true,
"spec", "template", "spec", "domain", "firmware", "bootloader", "efi", "secureBoot")
}, map[string]interface{}{"bootloader": "uefi", "secureBoot": true}},
{"bios", func(vm *unstructured.Unstructured) {
_ = unstructured.SetNestedMap(vm.Object, map[string]interface{}{},
"spec", "template", "spec", "domain", "firmware", "bootloader", "bios")
}, map[string]interface{}{"bootloader": "bios"}},
// Nothing on the source means nothing written: the instance profile's
// own default stands rather than being overridden with a guess.
{"no firmware at all", func(vm *unstructured.Unstructured) {}, nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
vm := newObject(virtualMachineGVK)
tc.set(vm)
got := sourceFirmware(vm)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("sourceFirmware = %v, want %v", got, tc.want)
}
})
}
}
// The boot disk of a guest copied verbatim out of VMware has to sit on a bus
// that guest can already read, or the import "succeeds" into a VM that stalls
// in its initramfs with no root device.
func TestImportedDisksPreferTheInboxBus(t *testing.T) {
for i := 0; i < ahciPorts; i++ {
if got := importedDiskBus(i); got != "sata" {
t.Errorf("disk %d: bus = %q, want sata", i, got)
}
}
// Past the controller's port count SATA has nowhere to put the disk, and a
// data disk may fall back: by then the guest is running off its own root
// and can load the driver itself.
if got := importedDiskBus(ahciPorts); got != "virtio" {
t.Errorf("disk %d: bus = %q, want virtio", ahciPorts, got)
}
}
// The failure this marker exists to stop: a first import half-succeeds, the
// tenant deletes the VMInstance but keeps the disks, and retries under the same
// name. Without an identity check the leftover claim reads as this task's own
// finished work, adoptVolume early-returns, and the freshly transferred claim
// keeps its ownerRef to the scaffolding VM — which fulfill then deletes, taking
// the new transfer with it. The import reports success over stale data.
func TestAdoptVolumeRefusesAClaimItDidNotCreate(t *testing.T) {
s := testScheme(t)
tk := task("import", "tenant-foo", "vcenter", "replicated")
foreign := &corev1.PersistentVolumeClaim{
ObjectMeta: metav1.ObjectMeta{Name: "vm-disk-web-01-disk-0", Namespace: "tenant-foo"},
Spec: corev1.PersistentVolumeClaimSpec{
VolumeName: "pv-1",
Resources: corev1.VolumeResourceRequirements{
Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("20Gi")},
},
},
}
c := clientfake.NewClientBuilder().WithScheme(s).WithObjects(foreign, tk).Build()
r := &VMImportTaskReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
err := r.adoptVolume(context.Background(), tk, "vm-1", "forklift-claim", "web-01-disk-0")
if err == nil {
t.Fatal("adoptVolume accepted a claim this task never created; the transfer would be garbage-collected")
}
var verr *validationError
if !asValidationError(err, &verr) {
t.Fatalf("error = %v, want a tenant-visible validationError", err)
}
if verr.reason != migrationv1alpha1.ReasonOutputExists {
t.Errorf("reason = %q, want %q", verr.reason, migrationv1alpha1.ReasonOutputExists)
}
}
// A claim carrying another task's marker is just as foreign as an unmarked one:
// names are reused, so the UID is what decides.
func TestAdoptVolumeRefusesAnotherTasksOutput(t *testing.T) {
s := testScheme(t)
tk := task("import", "tenant-foo", "vcenter", "replicated")
other := &corev1.PersistentVolumeClaim{
ObjectMeta: metav1.ObjectMeta{
Name: "vm-disk-web-01-disk-0",
Namespace: "tenant-foo",
Labels: map[string]string{
migrationv1alpha1.OutputTaskUIDLabel: "some-older-task-uid",
migrationv1alpha1.OutputVMIDLabel: "vm-1",
},
},
Spec: corev1.PersistentVolumeClaimSpec{VolumeName: "pv-1"},
}
c := clientfake.NewClientBuilder().WithScheme(s).WithObjects(other, tk).Build()
r := &VMImportTaskReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
if err := r.adoptVolume(context.Background(), tk, "vm-1", "forklift-claim", "web-01-disk-0"); err == nil {
t.Fatal("adoptVolume adopted an output belonging to a different task")
}
}
// A deleted task must take the engine's transfer volumes with it. They carry no
// owner reference — the engine tracks them by label — so deleting the task
// removes the Plan and leaves the volume behind, still retrying the transfer
// against the source with nothing left to collect it. Observed live: 37 minutes
// and 8 restarts after its task was gone.
//
// The outputs must survive the same sweep, which is the other half of the test:
// they are what the tenant keeps.
func TestFinalizerRemovesTransferVolumesButNotOutputs(t *testing.T) {
s := testScheme(t)
tk := task("import", "tenant-foo", "vcenter", "replicated",
migrationv1alpha1.VMImportRequest{ID: "vm-1", Name: "web-01"})
tk.Finalizers = []string{migrationv1alpha1.TaskFinalizer}
now := metav1.Now()
tk.DeletionTimestamp = &now
plan := newObject(planGVK)
plan.SetName(planName("import", "vm-1"))
plan.SetNamespace("tenant-foo")
plan.SetUID(types.UID("plan-uid-1"))
transfer := newObject(dataVolumeGVK)
transfer.SetName("import-vm-1-abcde")
transfer.SetNamespace("tenant-foo")
transfer.SetLabels(map[string]string{"plan": "plan-uid-1", "vmID": "vm-1"})
// The imported disk: created by this controller, carries Helm metadata and
// no engine label, and must be left alone.
output := newObject(dataVolumeGVK)
output.SetName("vm-disk-web-01-disk-0")
output.SetNamespace("tenant-foo")
output.SetLabels(map[string]string{"app.kubernetes.io/instance": "vm-disk-web-01-disk-0"})
c := clientfake.NewClientBuilder().WithScheme(s).
WithObjects(tk, plan, transfer, output).Build()
r := &VMImportTaskReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
if _, err := r.finalize(context.Background(), tk); err != nil {
t.Fatalf("finalize: %v", err)
}
got := newObject(dataVolumeGVK)
err := c.Get(context.Background(), types.NamespacedName{
Namespace: "tenant-foo", Name: "import-vm-1-abcde",
}, got)
if !apierrors.IsNotFound(err) {
t.Errorf("the engine's transfer volume survived the task, got err %v — it would keep retrying forever", err)
}
if err := c.Get(context.Background(), types.NamespacedName{
Namespace: "tenant-foo", Name: "vm-disk-web-01-disk-0",
}, newObject(dataVolumeGVK)); err != nil {
t.Errorf("the imported disk was removed with the task: %v", err)
}
if controllerutil.ContainsFinalizer(tk, migrationv1alpha1.TaskFinalizer) {
t.Error("the finalizer was not released; the task would never delete")
}
}
// Outputs must carry the marker, or the checks above can never recognise them
// on a later pass and every resume becomes a collision.
func TestAdoptedOutputsCarryTheTaskMarker(t *testing.T) {
tk := task("import", "tenant-foo", "vcenter", "replicated")
obj := newObject(vmDiskGVK)
stampOutput(obj, tk, "vm-1")
if !isOwnOutput(obj, tk, "vm-1") {
t.Error("a freshly stamped output is not recognised as this task's own")
}
if isOwnOutput(obj, tk, "vm-2") {
t.Error("an output for one source VM is recognised as another's")
}
other := task("import", "tenant-foo", "vcenter", "replicated")
other.UID = "different-uid"
if isOwnOutput(obj, other, "vm-1") {
t.Error("an output is recognised as belonging to a different task")
}
}
+30 -26
View File
@@ -36,32 +36,36 @@ virtctl ssh <user>@<vm>
### Common parameters
| Name | Description | Type | Value |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ----------- |
| `external` | Enable external access from outside the cluster. | `bool` | `false` |
| `externalMethod` | Method to pass through traffic to the VM. | `string` | `PortList` |
| `externalPorts` | Ports to forward from outside the cluster. | `[]int` | `[22]` |
| `externalAllowICMP` | Whether to accept ICMP traffic to the VM in PortList mode (preserves ping and PMTU discovery). No effect in WholeIP mode. Default true so ping behaves as users expect even when port filtering is in effect. | `bool` | `true` |
| `runStrategy` | Requested running state of the VirtualMachineInstance | `string` | `Always` |
| `instanceType` | Virtual Machine instance type. | `string` | `u1.medium` |
| `instanceProfile` | Virtual Machine preferences profile. | `string` | `ubuntu` |
| `disks` | List of disks to attach. | `[]object` | `[]` |
| `disks[i].name` | Disk name. | `string` | `""` |
| `disks[i].bus` | Disk bus type (e.g. "sata"). | `string` | `""` |
| `networks` | Networks to attach the VM to. | `[]object` | `[]` |
| `networks[i].name` | Network attachment name. | `string` | `""` |
| `subnets` | Deprecated: use networks instead. | `[]object` | `[]` |
| `subnets[i].name` | Network attachment name. | `string` | `""` |
| `gpus` | List of GPUs to attach (NVIDIA driver requires at least 4 GiB RAM). | `[]object` | `[]` |
| `gpus[i].name` | The name of the GPU resource to attach. | `string` | `""` |
| `cpuModel` | Model specifies the CPU model inside the VMI. List of available models https://github.com/libvirt/libvirt/tree/master/src/cpu_map | `string` | `""` |
| `resources` | Resource configuration for the virtual machine. | `object` | `{}` |
| `resources.cpu` | Number of CPU cores allocated. | `quantity` | `""` |
| `resources.memory` | Amount of memory allocated. | `quantity` | `""` |
| `resources.sockets` | Number of CPU sockets (vCPU topology). | `quantity` | `""` |
| `sshKeys` | List of SSH public keys for authentication. | `[]string` | `[]` |
| `cloudInit` | Cloud-init user data. | `string` | `""` |
| `cloudInitSeed` | Seed string to generate SMBIOS UUID for the VM. | `string` | `""` |
| Name | Description | Type | Value |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- | ----------- |
| `external` | Enable external access from outside the cluster. | `bool` | `false` |
| `externalMethod` | Method to pass through traffic to the VM. | `string` | `PortList` |
| `externalPorts` | Ports to forward from outside the cluster. | `[]int` | `[22]` |
| `externalAllowICMP` | Whether to accept ICMP traffic to the VM in PortList mode (preserves ping and PMTU discovery). No effect in WholeIP mode. Default true so ping behaves as users expect even when port filtering is in effect. | `bool` | `true` |
| `runStrategy` | Requested running state of the VirtualMachineInstance | `string` | `Always` |
| `instanceType` | Virtual Machine instance type. | `string` | `u1.medium` |
| `instanceProfile` | Virtual Machine preferences profile. | `string` | `ubuntu` |
| `disks` | List of disks to attach. | `[]object` | `[]` |
| `disks[i].name` | Disk name. | `string` | `""` |
| `disks[i].bus` | Disk bus type (e.g. "sata"). | `string` | `""` |
| `networks` | Networks to attach the VM to. | `[]object` | `[]` |
| `networks[i].name` | Network attachment name. | `string` | `""` |
| `subnets` | Deprecated: use networks instead. | `[]object` | `[]` |
| `subnets[i].name` | Network attachment name. | `string` | `""` |
| `gpus` | List of GPUs to attach (NVIDIA driver requires at least 4 GiB RAM). | `[]object` | `[]` |
| `gpus[i].name` | The name of the GPU resource to attach. | `string` | `""` |
| `cpuModel` | Model specifies the CPU model inside the VMI. List of available models https://github.com/libvirt/libvirt/tree/master/src/cpu_map | `string` | `""` |
| `firmware` | Firmware and boot configuration (UEFI/BIOS selection, Secure Boot, persistent EFI NVRAM). | `object` | `{}` |
| `firmware.bootloader` | Bootloader to boot the VM with: "uefi" (OVMF) or "bios" (SeaBIOS). Empty inherits the instanceProfile default. | `string` | `""` |
| `firmware.secureBoot` | Enable UEFI Secure Boot. Only applies when bootloader is "uefi". | `bool` | `false` |
| `firmware.efiPersistent` | Persist EFI NVRAM (e.g. enrolled Secure Boot keys, such as an updated Microsoft UEFI CA) across reboots. Only applies when bootloader is "uefi". On default RWO storage the VM is node-pinned (no live-migration). | `bool` | `false` |
| `resources` | Resource configuration for the virtual machine. | `object` | `{}` |
| `resources.cpu` | Number of CPU cores allocated. | `quantity` | `""` |
| `resources.memory` | Amount of memory allocated. | `quantity` | `""` |
| `resources.sockets` | Number of CPU sockets (vCPU topology). | `quantity` | `""` |
| `sshKeys` | List of SSH public keys for authentication. | `[]string` | `[]` |
| `cloudInit` | Cloud-init user data. | `string` | `""` |
| `cloudInitSeed` | Seed string to generate SMBIOS UUID for the VM. | `string` | `""` |
## U Series
@@ -46,6 +46,7 @@ spec:
spec:
domain:
{{- $domainRes := include "virtual-machine.domainResources" . | fromJson -}}
{{- $fw := .Values.firmware | default dict }}
{{- with $domainRes.cpu }}
cpu: {{- . | toYaml | nindent 10 }}
{{- end }}
@@ -54,6 +55,20 @@ spec:
{{- end }}
firmware:
uuid: {{ include "virtual-machine.stableUuid" . }}
{{- if eq (toString $fw.bootloader) "uefi" }}
bootloader:
efi:
secureBoot: {{ $fw.secureBoot | default false }}
persistent: {{ $fw.efiPersistent | default false }}
{{- else if eq (toString $fw.bootloader) "bios" }}
bootloader:
bios: {}
{{- end }}
{{- if and (eq (toString $fw.bootloader) "uefi") ($fw.secureBoot | default false) }}
features:
smm:
enabled: true
{{- end }}
devices:
{{- if .Values.gpus }}
gpus:
@@ -142,6 +142,25 @@
"type": "string",
"default": ""
},
"firmware": {
"description": "Firmware and boot configuration (UEFI/BIOS selection, Secure Boot, persistent EFI NVRAM).",
"type": "object",
"default": {},
"properties": {
"bootloader": {
"description": "Bootloader to boot the VM with: \"uefi\" (OVMF) or \"bios\" (SeaBIOS). Empty inherits the instanceProfile default.",
"type": "string"
},
"efiPersistent": {
"description": "Persist EFI NVRAM (e.g. enrolled Secure Boot keys, such as an updated Microsoft UEFI CA) across reboots. Only applies when bootloader is \"uefi\". On default RWO storage the VM is node-pinned (no live-migration).",
"type": "boolean"
},
"secureBoot": {
"description": "Enable UEFI Secure Boot. Only applies when bootloader is \"uefi\".",
"type": "boolean"
}
}
},
"resources": {
"description": "Resource configuration for the virtual machine.",
"type": "object",
+13
View File
@@ -24,6 +24,11 @@
## @field {quantity} [memory] - Amount of memory allocated.
## @field {quantity} [sockets] - Number of CPU sockets (vCPU topology).
## @typedef {struct} Firmware - Firmware and boot configuration. When left empty, the boot mode is inherited from the instanceProfile.
## @field {string} [bootloader] - Bootloader to boot the VM with: "uefi" (OVMF) or "bios" (SeaBIOS). Empty inherits the instanceProfile default.
## @field {bool} [secureBoot] - Enable UEFI Secure Boot. Only applies when bootloader is "uefi".
## @field {bool} [efiPersistent] - Persist EFI NVRAM (e.g. enrolled Secure Boot keys, such as an updated Microsoft UEFI CA) across reboots. Only applies when bootloader is "uefi". On default RWO storage the VM is node-pinned (no live-migration).
## @param {bool} external - Enable external access from outside the cluster.
external: false
@@ -82,6 +87,14 @@ gpus: []
## @param {string} cpuModel - Model specifies the CPU model inside the VMI. List of available models https://github.com/libvirt/libvirt/tree/master/src/cpu_map
cpuModel: ""
## @param {Firmware} [firmware] - Firmware and boot configuration (UEFI/BIOS selection, Secure Boot, persistent EFI NVRAM).
firmware: {}
## Example:
## firmware:
## bootloader: uefi
## secureBoot: true
## efiPersistent: true
## @param {Resources} [resources] - Resource configuration for the virtual machine.
resources: {}
@@ -34,6 +34,14 @@ spec:
{{- $name := index . 0 -}}
{{- $variant := default "default" (index . 1) -}}
{{- $root := default $ (index . 2) -}}
{{- /* Components are accepted here for the same reason the always-on helper
above accepts them: an opt-in package can need platform configuration
just as much as a default one. Omitting this silently dropped whatever
the caller passed, which is worse than not supporting it at all. */ -}}
{{- $components := dict -}}
{{- if gt (len .) 3 -}}
{{- $components = index . 3 -}}
{{- end -}}
{{- $disabled := default (list) $root.Values.bundles.disabledPackages -}}
{{- $enabled := default (list) $root.Values.bundles.enabledPackages -}}
{{- if and (has $name $enabled) (not (has $name $disabled)) -}}
@@ -46,6 +54,10 @@ metadata:
helm.sh/resource-policy: keep
spec:
variant: {{ $variant }}
{{- if $components }}
components:
{{ toYaml $components | indent 4 }}
{{- end }}
{{- end }}
{{ end }}
@@ -181,4 +181,32 @@
{{include "cozystack.platform.package" (list "cozystack.virtualprivatecloud-application" "kubevirt" $) }}
{{include "cozystack.platform.package" (list "cozystack.vm-disk-application" "kubevirt" $) }}
{{include "cozystack.platform.package" (list "cozystack.vm-instance-application" "kubevirt" $) }}
{{- /*
VM import is opt-in: the Forklift operator ships a large manifest with its own
CRDs and broad RBAC, and the migration controller holds cluster-wide access to
PersistentVolumes so it can hand a transferred volume to a VMDisk without
copying it. That surface is worth it for a cluster migrating off vSphere and
pure cost for every other iaas cluster, so add these three to
bundles.enabledPackages to turn it on.
The VDDK image travels here as configuration rather than living in the tree:
it is built from a proprietary VMware SDK that Cozystack can neither ship nor
mirror, so an operator who holds a licence builds it and names it in
vmImport.vddkImage. Only the reference travels, never a credential. Empty is
the normal state and makes the vSphere path report itself unavailable on each
VMImportSource instead of failing midway through a transfer.
The key is vmImport rather than migration because the platform already has a
top-level `migrations` for its own schema migrations, and two neighbouring
keys differing by one letter is a configuration mistake waiting to happen.
*/ -}}
{{- $migrationComponents := dict -}}
{{- with .Values.vmImport -}}
{{- with .vddkImage -}}
{{- $_ := set $migrationComponents "migration-controller" (dict "values" (dict "vddkImage" .)) -}}
{{- end -}}
{{- end -}}
{{include "cozystack.platform.package.optional.default" (list "cozystack.forklift-operator" $) }}
{{include "cozystack.platform.package.optional.default" (list "cozystack.forklift" $) }}
{{include "cozystack.platform.package.optional" (list "cozystack.migration-controller" "default" $ $migrationComponents) }}
{{- end }}
@@ -0,0 +1,132 @@
suite: bundles.iaas VM import opt-in and VDDK image wiring
templates:
- templates/bundles/iaas.yaml
release:
name: cozystack
namespace: cozy-system
tests:
# The three packages carry a large vendored manifest, their own CRDs, and a
# controller with cluster-wide access to PersistentVolumes. A cluster that is
# not migrating off vSphere must not pay for any of it.
- it: installs nothing for VM import unless the packages are explicitly enabled
set:
bundles:
system:
enabled: true
variant: isp-full
iaas:
enabled: true
asserts:
- containsDocument:
apiVersion: cozystack.io/v1alpha1
kind: Package
name: cozystack.forklift-operator
any: true
not: true
- containsDocument:
apiVersion: cozystack.io/v1alpha1
kind: Package
name: cozystack.forklift
any: true
not: true
- containsDocument:
apiVersion: cozystack.io/v1alpha1
kind: Package
name: cozystack.migration-controller
any: true
not: true
- it: emits all three packages once they are enabled
set:
bundles:
system:
enabled: true
variant: isp-full
iaas:
enabled: true
enabledPackages:
- cozystack.forklift-operator
- cozystack.forklift
- cozystack.migration-controller
asserts:
- containsDocument:
apiVersion: cozystack.io/v1alpha1
kind: Package
name: cozystack.forklift-operator
any: true
- containsDocument:
apiVersion: cozystack.io/v1alpha1
kind: Package
name: cozystack.forklift
any: true
- containsDocument:
apiVersion: cozystack.io/v1alpha1
kind: Package
name: cozystack.migration-controller
any: true
# Empty is the normal state: the vSphere path then reports itself unavailable
# on each VMImportSource. No components block should be emitted at all, so the
# package keeps its own chart default rather than being handed an empty string.
- it: emits no components block when no VDDK image is configured
set:
bundles:
system:
enabled: true
variant: isp-full
iaas:
enabled: true
enabledPackages:
- cozystack.migration-controller
documentSelector:
path: metadata.name
value: cozystack.migration-controller
asserts:
- equal:
path: spec.variant
value: default
- notExists:
path: spec.components
# The regression this guards: the optional-package helper used to ignore a
# components argument entirely, so an operator could set vddkImage, see it
# accepted, and never have it reach the controller.
- it: forwards the operator-configured VDDK image into the controller component values
set:
bundles:
system:
enabled: true
variant: isp-full
iaas:
enabled: true
enabledPackages:
- cozystack.migration-controller
vmImport:
vddkImage: registry.example.com/vddk:8.0.3
documentSelector:
path: metadata.name
value: cozystack.migration-controller
asserts:
- equal:
path: spec.components.migration-controller.values.vddkImage
value: registry.example.com/vddk:8.0.3
# A VDDK image set on a cluster that has not enabled the packages must not
# conjure them into existence: configuration is not consent to install.
- it: does not install the controller just because a VDDK image is set
set:
bundles:
system:
enabled: true
variant: isp-full
iaas:
enabled: true
vmImport:
vddkImage: registry.example.com/vddk:8.0.3
asserts:
- containsDocument:
apiVersion: cozystack.io/v1alpha1
kind: Package
name: cozystack.migration-controller
any: true
not: true
+18
View File
@@ -754,3 +754,21 @@ gpu:
# missing entries).
vgpuDeviceManager:
enabled: false
# VM import from external virtualization platforms (opt-in; see the iaas bundle).
vmImport:
# Container image carrying the VMware Virtual Disk Development Kit, used by
# the raw-copy transfer path. Cozystack neither ships nor mirrors it: it is
# built from a proprietary VMware SDK, so an operator who holds a licence
# builds the image, pushes it somewhere the cluster can pull from, and names
# it here. Only the reference travels to the controller, never a credential.
#
# Empty is the normal state and means the vSphere import path is unavailable
# rather than broken: a vSphere VMImportSource reports VDDKNotConfigured on
# its own status, so a tenant learns at registration time instead of watching
# a transfer die halfway. Non-VMware providers never need it.
#
# Named vmImport, not migration, to keep it clearly distinct from the
# top-level `migrations` key above, which is the platform's own schema
# migrations and has nothing to do with moving virtual machines.
vddkImage: ""
@@ -62,6 +62,19 @@ rules:
- apiGroups: ["apps.cozystack.io"]
resources: ["vmdisks"]
verbs: ["get", "watch", "list"]
# The sourceRef picker on a VMImportTask is an Option provider, which lists
# the sources through the dynamic client under this ServiceAccount. Without
# the group here the provider registers and then answers with an empty list,
# so the form offers no connection to import from.
- apiGroups: ["forklift.cozystack.io"]
resources: ["vmimportsources"]
verbs: ["get", "watch", "list"]
# The VM picker reads the machine list the migration controller publishes for
# each source. Read-only, and only ever fetched by a name derived from the
# source the tenant already chose.
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "watch", "list"]
# The SecurityGroup resource is a projection over CiliumNetworkPolicy: the
# storage CRUDs these objects with the cozystack-api ServiceAccount.
- apiGroups: ["cilium.io"]
@@ -15,9 +15,9 @@ release:
# the API group string exactly.
#
# The last two tests bound the role from two directions, and neither covers the
# other. The count bound pins the list at exactly eighteen: templates/rbac.yaml
# carries no template directives, so anything appended makes rules[18] exist and
# anything dropped takes rules[17] away, whatever the rule is called. It says
# other. The count bound pins the list at exactly twenty: templates/rbac.yaml
# carries no template directives, so anything appended makes rules[20] exist and
# anything dropped takes rules[19] away, whatever the rule is called. It says
# nothing about a grant swapped in for an existing one, because the count does
# not move under a swap. The spelling assert catches that case for the one rule
# it names, and misses every other wording of it, because helm-unittest compares
@@ -80,6 +80,6 @@ tests:
# Both directions. A dropped grant is the failure this suite is about:
# the apiserver returns forbidden and List silently drops the dropdown.
- exists:
path: rules[17]
path: rules[19]
- notExists:
path: rules[18]
path: rules[20]
@@ -1,6 +1,6 @@
import { useEffect, useRef } from "react"
import type { WidgetProps } from "@rjsf/utils"
import { useK8sList } from "@cozystack/k8s-client"
import { useK8sGet, useK8sList } from "@cozystack/k8s-client"
import { useTenantContext } from "../lib/tenant-context.tsx"
/**
@@ -32,26 +32,79 @@ interface OptionObject {
spec?: { items?: OptionItem[] }
}
/**
* Resolves `{path.to.field}` placeholders in an option source against the whole
* form, so a dropdown can be scoped by a choice made elsewhere in it.
*
* Returns null when a placeholder has no value yet — the field it depends on is
* still empty, and asking the server for `vmimportvm.` would only 404.
*/
export function resolveSource(
source: string | undefined,
root: unknown,
): { name: string | null; parameterised: boolean } {
if (!source) return { name: null, parameterised: false }
if (!source.includes("{")) return { name: source, parameterised: false }
let missing = false
const name = source.replace(/\{([^}]+)\}/g, (_, path: string) => {
const resolved = path
.split(".")
.reduce<unknown>(
(acc, key) =>
acc && typeof acc === "object"
? (acc as Record<string, unknown>)[key]
: undefined,
root,
)
if (typeof resolved !== "string" || resolved === "") {
missing = true
return ""
}
return resolved
})
return { name: missing ? null : name, parameterised: true }
}
export function DynamicOptionsWidget(props: WidgetProps) {
const { value, onChange, required, disabled, readonly, schema } = props
const { value, onChange, required, disabled, readonly, schema, registry } = props
const { tenantNamespace } = useTenantContext()
const source = (schema as { "x-cozystack-options"?: { source?: string } })?.[
const rawSource = (schema as { "x-cozystack-options"?: { source?: string } })?.[
"x-cozystack-options"
]?.source
const { data: optionList, isLoading } = useK8sList<OptionObject>(
const rootFormData = (registry?.formContext as { rootFormData?: unknown })?.rootFormData
const { name: source, parameterised } = resolveSource(rawSource, rootFormData)
// A parameterised source is deliberately absent from the Option list — it
// describes nothing without its argument — so it is fetched by name instead.
const { data: optionList, isLoading: listLoading } = useK8sList<OptionObject>(
{
apiGroup: "core.cozystack.io",
apiVersion: "v1alpha1",
plural: "options",
namespace: tenantNamespace ?? undefined,
},
{ enabled: !!tenantNamespace },
{ enabled: !!tenantNamespace && !parameterised },
)
const items: OptionItem[] =
optionList?.items?.find((o) => o.metadata.name === source)?.spec?.items ?? []
const { data: option, isLoading: getLoading } = useK8sGet<OptionObject>(
{
apiGroup: "core.cozystack.io",
apiVersion: "v1alpha1",
plural: "options",
name: source ?? "",
namespace: tenantNamespace ?? "",
},
{ enabled: !!tenantNamespace && parameterised && !!source },
)
const isLoading = parameterised ? getLoading : listLoading
const items: OptionItem[] = parameterised
? (option?.spec?.items ?? [])
: (optionList?.items?.find((o) => o.metadata.name === source)?.spec?.items ?? [])
const currentValue = typeof value === "string" ? value : ""
const hasCurrentInList = items.some((it) => it.value === currentValue)
@@ -72,11 +125,15 @@ export function DynamicOptionsWidget(props: WidgetProps) {
const placeholder = isLoading
? "Loading..."
: items.length === 0
? "No options available"
: required
? "Select an option..."
: "-- None --"
: // A dependent dropdown with nothing to depend on yet is waiting, not
// empty, and saying so points at the field that unblocks it.
parameterised && !source
? "Select a source first..."
: items.length === 0
? "No options available"
: required
? "Select an option..."
: "-- None --"
return (
<select
@@ -342,6 +342,11 @@ export const SchemaForm = forwardRef<SchemaFormHandle, SchemaFormProps>(function
templates={templatesWithoutSubmit}
widgets={customWidgets}
fields={customFields}
// A widget sees only its own value, but a dropdown may be scoped by a
// choice made elsewhere in the form — the VMs of the import source the
// user just picked. Handing the whole object down is what lets an
// option source name reference a sibling field.
formContext={{ rootFormData: formData }}
onChange={(e) => onChange(e.formData)}
liveValidate={false}
showErrorList={false}
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest"
import { resolveSource } from "./DynamicOptionsWidget.tsx"
describe("resolveSource", () => {
it("leaves a plain source alone", () => {
expect(resolveSource("storageclass", {})).toEqual({
name: "storageclass",
parameterised: false,
})
})
it("substitutes a sibling field", () => {
const root = { sourceRef: { name: "vcenter-lab" } }
expect(resolveSource("vmimportvm.{sourceRef.name}", root)).toEqual({
name: "vmimportvm.vcenter-lab",
parameterised: true,
})
})
// The field it depends on is still empty. Asking for `vmimportvm.` would only
// 404, and the widget shows "select a source first" instead.
it("yields no name while the referenced field is unset", () => {
expect(resolveSource("vmimportvm.{sourceRef.name}", {})).toEqual({
name: null,
parameterised: true,
})
expect(resolveSource("vmimportvm.{sourceRef.name}", { sourceRef: {} })).toEqual({
name: null,
parameterised: true,
})
expect(
resolveSource("vmimportvm.{sourceRef.name}", { sourceRef: { name: "" } }),
).toEqual({ name: null, parameterised: true })
})
// A path through something that is not an object must not throw: form data is
// whatever the user has typed so far.
it("survives a path that does not lead anywhere", () => {
expect(resolveSource("vmimportvm.{a.b.c}", { a: "string" })).toEqual({
name: null,
parameterised: true,
})
expect(resolveSource("vmimportvm.{a.b}", null)).toEqual({
name: null,
parameterised: true,
})
})
it("treats a missing source as nothing to resolve", () => {
expect(resolveSource(undefined, {})).toEqual({ name: null, parameterised: false })
})
})
@@ -4,6 +4,7 @@ import { graftOptionSources } from "./crd-option-sources.ts"
interface SchemaNode {
type?: string
properties?: Record<string, SchemaNode>
items?: SchemaNode
"x-cozystack-options"?: { source: string }
}
@@ -125,6 +126,34 @@ describe("graftOptionSources", () => {
expect(spec.properties?.applicationRef?.properties?.kind?.["x-cozystack-options"]).toBeUndefined()
})
it("grafts a source onto a field inside a list (VMImportTask vms)", () => {
const spec: SchemaNode = {
type: "object",
properties: {
vms: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string" },
instanceType: { type: "string" },
},
},
},
},
}
const out = graftOptionSources(spec, {
"options.cozystack.io/source.vms.instanceType": "instancetype",
}) as SchemaNode
expect(
out.properties?.vms?.items?.properties?.instanceType?.["x-cozystack-options"],
).toEqual({ source: "instancetype" })
// The sibling inside the same item is left alone.
expect(out.properties?.vms?.items?.properties?.id?.["x-cozystack-options"]).toBeUndefined()
})
it("returns the schema unchanged when there are no annotations", () => {
const spec: SchemaNode = {
type: "object",
@@ -44,6 +44,14 @@ export function graftOptionSources(
function applySource(specSchema: unknown, path: string[], source: string): void {
let node = specSchema
for (const segment of path) {
// An array is transparent in the annotation path: `vms.instanceType` means
// the field on each item, matching how addDynamicOptionWidgets already
// recurses into `items`. Without this an annotation on a field inside a
// list resolves to nothing and the dropdown silently never appears.
const items = (node as { items?: unknown })?.items
if (items && typeof items === "object" && !(node as { properties?: unknown })?.properties) {
node = items
}
const properties = (node as { properties?: Record<string, unknown> })?.properties
if (!properties || typeof properties !== "object" || !(segment in properties)) {
return
@@ -0,0 +1,85 @@
/**
* Shared shapes and helpers for the forklift.cozystack.io console pages.
*
* The API is a pair split by lifecycle: a VMImportSource is a long-lived
* connection whose readiness is a condition, and a VMImportTask is a one-shot
* operation with a phase and per-machine progress. Deleting a task leaves the
* disks and instances it produced in place.
*/
export const MIGRATION_GROUP = "forklift.cozystack.io"
export const MIGRATION_VERSION = "v1alpha1"
export type MigrationResourceType = "vmimportsources" | "vmimporttasks"
export interface MigrationCondition {
type: string
status: string
reason?: string
message?: string
}
export interface VMImportVMStatus {
id: string
name?: string
phase?: string
progress?: number
vmInstance?: string
disks?: string[]
message?: string
}
export interface MigrationResource {
apiVersion: string
kind: string
metadata: {
name: string
namespace?: string
creationTimestamp?: string
}
spec?: {
// VMImportSource
type?: string
url?: string
// VMImportTask
sourceRef?: { name?: string }
storageClass?: string
vms?: Array<{
id: string
name?: string
instanceType?: string
instanceProfile?: string
}>
}
status?: {
phase?: string
message?: string
startedAt?: string
completedAt?: string
vms?: VMImportVMStatus[]
conditions?: MigrationCondition[]
}
}
export function readyCondition(resource: MigrationResource): MigrationCondition | undefined {
return resource.status?.conditions?.find((c) => c.type === "Ready")
}
/**
* Whole-task progress as the mean over its machines, so a two-machine import
* with one finished disk reads 50% rather than jumping between per-machine
* numbers. Returns null when there is nothing to average yet.
*/
export function taskProgress(resource: MigrationResource): number | null {
const vms = resource.status?.vms
if (!vms || vms.length === 0) return null
const total = vms.reduce((sum, vm) => sum + (vm.progress ?? 0), 0)
return Math.round(total / vms.length)
}
/** Tone for a phase or condition string, shared by the list and detail pages. */
export function phaseTone(phase: string | undefined): "ok" | "error" | "warn" {
if (phase === "Succeeded" || phase === "Ready" || phase === "True") return "ok"
if (phase === "Failed" || phase === "False") return "error"
return "warn"
}
@@ -9,6 +9,11 @@ import { BackupPlanCreatePage } from "./BackupPlanCreatePage.tsx"
import { BackupJobCreatePage } from "./BackupJobCreatePage.tsx"
import { BackupCreatePage } from "./BackupCreatePage.tsx"
import { BackupRestoreJobCreatePage } from "./BackupRestoreJobCreatePage.tsx"
import { MigrationResourceListPage } from "./MigrationResourceListPage.tsx"
import { MigrationSourceCreatePage } from "./MigrationSourceCreatePage.tsx"
import { MigrationSourceEditPage } from "./MigrationSourceEditPage.tsx"
import { MigrationTaskCreatePage } from "./MigrationTaskCreatePage.tsx"
import { MigrationTaskDetailPage } from "./MigrationTaskDetailPage.tsx"
import { ApplicationOrderPage } from "./ApplicationOrderPage.tsx"
export function ConsolePage() {
@@ -63,6 +68,34 @@ export function ConsolePage() {
path="backups/restorejobs/:name/edit"
element={<BackupResourceEditPage resourceType="restorejobs" title="Restore Jobs" />}
/>
{/* Migration routes precede the generic `:plural` catch-alls below, which
would otherwise swallow /console/migration/* as an application kind. */}
<Route
path="migration/vmimportsources"
element={<MigrationResourceListPage resourceType="vmimportsources" title="Sources" />}
/>
<Route
path="migration/vmimportsources/create"
element={<MigrationSourceCreatePage />}
/>
{/* Sources are editable, tasks are not: a task's spec is immutable on
admission, so an edit form would only offer rejected changes. */}
<Route
path="migration/vmimportsources/:name/edit"
element={<MigrationSourceEditPage />}
/>
<Route
path="migration/vmimporttasks"
element={<MigrationResourceListPage resourceType="vmimporttasks" title="Imports" />}
/>
<Route
path="migration/vmimporttasks/create"
element={<MigrationTaskCreatePage />}
/>
<Route
path="migration/vmimporttasks/:name"
element={<MigrationTaskDetailPage />}
/>
<Route path="new/:appName" element={<ApplicationOrderPage />} />
<Route path=":plural/:name/edit" element={<ApplicationEditRoute />} />
<Route path=":plural/:name/*" element={<ApplicationDetailPage />} />
@@ -0,0 +1,219 @@
import { Link, useNavigate } from "react-router"
import { Plus, Trash2, Import, Plug, Pencil } from "lucide-react"
import { Button, Section, Spinner } from "@cozystack/ui"
import { useK8sList, useK8sDelete } from "@cozystack/k8s-client"
import { useTenantContext } from "../lib/tenant-context.tsx"
import { formatAge } from "../lib/status.ts"
import {
MIGRATION_GROUP,
MIGRATION_VERSION,
type MigrationResource,
type MigrationResourceType,
readyCondition,
taskProgress,
} from "../lib/migration.ts"
interface MigrationResourceListPageProps {
resourceType: MigrationResourceType
title: string
}
export function MigrationResourceListPage({ resourceType, title }: MigrationResourceListPageProps) {
const { tenantNamespace } = useTenantContext()
const navigate = useNavigate()
const isTask = resourceType === "vmimporttasks"
const { data, isLoading, error, refetch } = useK8sList<MigrationResource>({
apiGroup: MIGRATION_GROUP,
apiVersion: MIGRATION_VERSION,
plural: resourceType,
namespace: tenantNamespace ?? "",
}, { enabled: !!tenantNamespace })
const deleteMutation = useK8sDelete({
apiGroup: MIGRATION_GROUP,
apiVersion: MIGRATION_VERSION,
plural: resourceType,
namespace: tenantNamespace ?? "",
})
const items = data?.items ?? []
const handleDelete = async (name: string) => {
// Spelling out that the imported machines survive matters here: deleting a
// finished import is the normal way to tidy up, and a tenant who fears it
// will delete the VMs will simply never clean anything up.
const warning = isTask
? `Delete import task "${name}"? Imported disks and instances are not affected.`
: `Delete connection "${name}"? Imports already completed are not affected.`
if (!confirm(warning)) return
try {
await deleteMutation.mutateAsync(name)
refetch()
} catch (err) {
alert(`Failed to delete: ${(err as Error).message}`)
}
}
if (isLoading) {
return (
<div className="flex items-center gap-2 p-6 text-sm text-slate-500">
<Spinner /> Loading…
</div>
)
}
if (error) {
return (
<div className="p-6 text-sm text-red-600">
Failed to load {title}: {(error as Error).message}
</div>
)
}
const Icon = isTask ? Import : Plug
return (
<div className="p-6">
<div className="mb-5 flex items-end justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-md bg-slate-100">
<Icon className="size-6 text-slate-600" />
</div>
<div>
<h1 className="text-lg font-semibold text-slate-900">{title}</h1>
<p className="text-xs text-slate-500">
{items.length} {items.length === 1 ? "item" : "items"}
</p>
</div>
</div>
<Link to={`/console/migration/${resourceType}/create`}>
<Button variant="primary" size="sm">
<Plus className="size-3.5" /> {isTask ? "New import" : "Add connection"}
</Button>
</Link>
</div>
<Section>
{items.length === 0 ? (
<div className="py-12 text-center text-sm text-slate-500">
{isTask
? "No imports yet. Add a connection first, then start an import."
: "No connections yet. Add one to import machines from vSphere."}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200 text-left text-xs font-medium uppercase tracking-wider text-slate-500">
<th className="px-5 py-3">Name</th>
<th className="px-5 py-3">{isTask ? "Source" : "Endpoint"}</th>
<th className="px-5 py-3">{isTask ? "Machines" : "Type"}</th>
<th className="px-5 py-3">Status</th>
<th className="px-5 py-3">Age</th>
<th className="px-5 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{items.map((item) => {
const ready = readyCondition(item)
const statusText = isTask
? (item.status?.phase ?? "Pending")
: ready?.status === "True"
? "Ready"
: (ready?.reason ?? "Pending")
const statusTone =
statusText === "Succeeded" || statusText === "Ready" ? "ok" :
statusText === "Failed" ? "error" :
"warn"
const second = isTask ? item.spec?.sourceRef?.name : item.spec?.url
const third = isTask
? `${item.spec?.vms?.length ?? 0}`
: (item.spec?.type ?? "-")
const progress = isTask ? taskProgress(item) : null
return (
<tr
key={item.metadata.name}
className="border-b border-slate-100 hover:bg-slate-50"
>
<td className="px-5 py-3 text-sm font-medium text-slate-900">
{isTask ? (
<Link
to={`/console/migration/vmimporttasks/${item.metadata.name}`}
className="text-blue-700 hover:underline"
>
{item.metadata.name}
</Link>
) : (
item.metadata.name
)}
</td>
<td className="px-5 py-3 text-sm text-slate-600">{second ?? "-"}</td>
<td className="px-5 py-3 text-sm text-slate-600">{third}</td>
<td className="px-5 py-3">
<div className="flex items-center gap-2">
<span
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
statusTone === "ok"
? "bg-green-100 text-green-800"
: statusTone === "error"
? "bg-red-100 text-red-800"
: "bg-yellow-100 text-yellow-800"
}`}
>
{statusText}
</span>
{progress !== null && progress > 0 && progress < 100 && (
<span className="text-xs text-slate-500">{progress}%</span>
)}
</div>
</td>
<td className="px-5 py-3 text-sm text-slate-600">
{item.metadata.creationTimestamp
? formatAge(item.metadata.creationTimestamp)
: "-"}
</td>
<td className="px-5 py-3 text-right">
<div className="flex items-center justify-end gap-2">
{/* Connections are long-lived and their credentials
rotate, so they are editable. An import task is a
finished record whose spec the API server refuses
to change, so it gets no edit action. */}
{resourceType === "vmimportsources" && (
<Button
variant="outline"
size="sm"
onClick={() =>
navigate(
`/console/migration/vmimportsources/${item.metadata.name}/edit`,
)
}
>
<Pencil className="size-3.5" /> Edit
</Button>
)}
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(item.metadata.name)}
disabled={deleteMutation.isPending}
>
<Trash2 className="size-3.5" /> Delete
</Button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</Section>
</div>
)
}
@@ -0,0 +1,147 @@
import { useRef, useState } from "react"
import { useNavigate } from "react-router"
import { Plug, Save } from "lucide-react"
import { Button, Section, Spinner } from "@cozystack/ui"
import { useK8sCreate } from "@cozystack/k8s-client"
import { useTenantContext } from "../lib/tenant-context.tsx"
import { useCRDSchema } from "../lib/use-crd-schema.ts"
import { SchemaForm, type SchemaFormHandle } from "../components/SchemaForm.tsx"
import { MIGRATION_GROUP, MIGRATION_VERSION } from "../lib/migration.ts"
export function MigrationSourceCreatePage() {
const navigate = useNavigate()
const { tenantNamespace } = useTenantContext()
const [formData, setFormData] = useState<Record<string, unknown>>({})
const [name, setName] = useState("")
const schemaFormRef = useRef<SchemaFormHandle>(null)
const { schema, isLoading: schemaLoading } = useCRDSchema("vmimportsources.forklift.cozystack.io")
const createMutation = useK8sCreate({
apiGroup: MIGRATION_GROUP,
apiVersion: MIGRATION_VERSION,
plural: "vmimportsources",
namespace: tenantNamespace ?? "",
})
const handleSubmit = async () => {
if (!tenantNamespace) {
alert("Tenant namespace is not available. Please refresh.")
return
}
if (!name.trim()) {
alert("Name is required")
return
}
if (schemaFormRef.current && !schemaFormRef.current.validate()) return
const resource = {
apiVersion: `${MIGRATION_GROUP}/${MIGRATION_VERSION}`,
kind: "VMImportSource",
metadata: {
name: name.trim(),
namespace: tenantNamespace,
},
spec: formData,
}
try {
await createMutation.mutateAsync(resource)
navigate("/console/migration/vmimportsources")
} catch (err) {
alert(`Failed to create connection: ${(err as Error).message}`)
}
}
if (schemaLoading) {
return (
<div className="flex items-center gap-2 p-8 text-slate-500">
<Spinner /> Loading schema...
</div>
)
}
if (!schema) {
return (
<div className="p-8 text-red-600">
Failed to load the VMImportSource schema. Is the migration-controller package enabled?
</div>
)
}
return (
<div className="p-6">
<div className="mb-5 flex items-center gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-md bg-slate-100">
<Plug className="size-6 text-slate-600" />
</div>
<div>
<h1 className="text-lg font-semibold text-slate-900">Add source connection</h1>
<p className="text-xs text-slate-500">
Register a vCenter to import machines from. The connection is tested before
anything else happens.
</p>
</div>
</div>
<Section>
<div className="space-y-4 p-5">
<div>
<label htmlFor="source-name" className="mb-1 block text-sm font-medium text-slate-700">
Connection name <span className="text-red-500">*</span>
</label>
<input
id="source-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="vcenter-prod"
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400"
required
/>
</div>
<div>
<SchemaForm
ref={schemaFormRef}
openAPISchema={schema}
formData={formData}
onChange={(data) => setFormData((data ?? {}) as Record<string, unknown>)}
>
<div className="hidden" />
</SchemaForm>
</div>
</div>
<div className="flex items-center gap-2 border-t border-slate-200 px-5 py-3">
<Button
type="button"
variant="primary"
size="sm"
onClick={handleSubmit}
disabled={createMutation.isPending}
>
{createMutation.isPending ? (
<>
<Spinner /> Creating...
</>
) : (
<>
<Save className="size-3.5" /> Create
</>
)}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => navigate("/console/migration/vmimportsources")}
disabled={createMutation.isPending}
>
Cancel
</Button>
</div>
</Section>
</div>
)
}
@@ -0,0 +1,176 @@
import { useEffect, useRef, useState } from "react"
import { useNavigate, useParams } from "react-router"
import { Plug, Save } from "lucide-react"
import { Button, Section, Spinner } from "@cozystack/ui"
import { useK8sGet, useK8sUpdate } from "@cozystack/k8s-client"
import { useTenantContext } from "../lib/tenant-context.tsx"
import { useCRDSchema } from "../lib/use-crd-schema.ts"
import { prepareUpdateSpec } from "../lib/prepare-update.ts"
import { SchemaForm, type SchemaFormHandle } from "../components/SchemaForm.tsx"
import {
MIGRATION_GROUP,
MIGRATION_VERSION,
type MigrationResource,
} from "../lib/migration.ts"
/**
* Editing a connection, which import tasks deliberately do not get.
*
* A source is long-lived: passwords rotate, a CA expires, and an ESXi host
* moves to an address the cluster can reach. Without this page the only way to
* change any of that is kubectl, which is not an answer for the audience this
* API is for. A task is the opposite — a one-shot operation whose every spec
* field is immutable on admission, so an edit form for one would offer changes
* the API server rejects.
*/
export function MigrationSourceEditPage() {
const { name } = useParams<{ name: string }>()
const navigate = useNavigate()
const { tenantNamespace } = useTenantContext()
const [formData, setFormData] = useState<Record<string, unknown>>({})
const initializedRef = useRef(false)
// The spec as it was when the form initialised, so the immutable-field
// overlay writes back what the user actually saw rather than whatever a
// refetch produced in the meantime.
const initialSpecRef = useRef<unknown>(null)
const schemaFormRef = useRef<SchemaFormHandle>(null)
const { schema, isLoading: schemaLoading } = useCRDSchema("vmimportsources.forklift.cozystack.io")
const { data: resource, isLoading: resourceLoading, error } = useK8sGet<MigrationResource>(
{
apiGroup: MIGRATION_GROUP,
apiVersion: MIGRATION_VERSION,
plural: "vmimportsources",
name: name ?? "",
namespace: tenantNamespace ?? "",
},
{ enabled: !!name && !!tenantNamespace },
)
const updateMutation = useK8sUpdate({
apiGroup: MIGRATION_GROUP,
apiVersion: MIGRATION_VERSION,
plural: "vmimportsources",
namespace: tenantNamespace ?? "",
})
// Initialised once: a refetch mid-edit must not discard what is being typed.
useEffect(() => {
if (resource?.spec && !initializedRef.current) {
initializedRef.current = true
setFormData(resource.spec)
initialSpecRef.current = resource.spec
}
}, [resource])
const handleSubmit = async () => {
if (!resource || !schema) return
// The save button sits outside RJSF and bypasses its validation, so ask
// for it explicitly; an invalid spec renders inline and aborts the write.
if (schemaFormRef.current && !schemaFormRef.current.validate()) return
const updated = {
...resource,
spec: prepareUpdateSpec(formData, initialSpecRef.current, schema),
}
try {
await updateMutation.mutateAsync(updated)
navigate("/console/migration/vmimportsources")
} catch (err) {
alert(`Failed to update connection: ${(err as Error).message}`)
}
}
if (schemaLoading || resourceLoading) {
return (
<div className="flex items-center gap-2 p-8 text-slate-500">
<Spinner /> Loading...
</div>
)
}
if (error) {
return (
<div className="p-8 text-red-600">
Failed to load connection: {(error as Error).message}
</div>
)
}
if (!resource) {
return <div className="p-8 text-red-600">Connection not found.</div>
}
if (!schema) {
return (
<div className="p-8 text-red-600">
Failed to load schema. Please refresh the page.
</div>
)
}
return (
<div className="p-6">
<div className="mb-5 flex items-center gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-md bg-slate-100">
<Plug className="size-6 text-slate-600" />
</div>
<div>
<h1 className="text-lg font-semibold text-slate-900">Edit connection</h1>
<p className="text-xs text-slate-500">{name}</p>
</div>
</div>
<Section>
<div className="space-y-4 p-5">
<p className="text-xs text-slate-500">
Changing the credentials re-tests the connection. Imports already
completed are not affected; an import still running keeps the
credentials it started with.
</p>
<SchemaForm
ref={schemaFormRef}
openAPISchema={schema}
formData={formData}
onChange={(data) => setFormData((data ?? {}) as Record<string, unknown>)}
immutableMode="enforce"
>
<div className="hidden" />
</SchemaForm>
</div>
<div className="flex items-center gap-2 border-t border-slate-200 px-5 py-3">
<Button
type="button"
variant="primary"
size="sm"
onClick={handleSubmit}
disabled={updateMutation.isPending}
>
{updateMutation.isPending ? (
<>
<Spinner /> Saving...
</>
) : (
<>
<Save className="size-3.5" /> Save
</>
)}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => navigate("/console/migration/vmimportsources")}
disabled={updateMutation.isPending}
>
Cancel
</Button>
</div>
</Section>
</div>
)
}
@@ -0,0 +1,200 @@
import { useMemo, useRef, useState } from "react"
import { useNavigate } from "react-router"
import { Import, Save } from "lucide-react"
import { Button, Section, Spinner } from "@cozystack/ui"
import { useK8sCreate, useK8sList } from "@cozystack/k8s-client"
import { useTenantContext } from "../lib/tenant-context.tsx"
import { useCRDSchema } from "../lib/use-crd-schema.ts"
import { SchemaForm, type SchemaFormHandle } from "../components/SchemaForm.tsx"
import {
MIGRATION_GROUP,
MIGRATION_VERSION,
type MigrationResource,
readyCondition,
} from "../lib/migration.ts"
export function MigrationTaskCreatePage() {
const navigate = useNavigate()
const { tenantNamespace } = useTenantContext()
const [formData, setFormData] = useState<Record<string, unknown>>({})
const [name, setName] = useState("")
const schemaFormRef = useRef<SchemaFormHandle>(null)
const { schema, isLoading: schemaLoading } = useCRDSchema("vmimporttasks.forklift.cozystack.io")
// sourceRef.name is a dropdown served by the vmimportsource option provider,
// but the list is also read here to warn about a connection that is not ready
// yet: starting an import against one only produces a task that sits Pending.
const { data: sources } = useK8sList<MigrationResource>({
apiGroup: MIGRATION_GROUP,
apiVersion: MIGRATION_VERSION,
plural: "vmimportsources",
namespace: tenantNamespace ?? "",
}, { enabled: !!tenantNamespace })
const createMutation = useK8sCreate({
apiGroup: MIGRATION_GROUP,
apiVersion: MIGRATION_VERSION,
plural: "vmimporttasks",
namespace: tenantNamespace ?? "",
})
const selectedSource = (formData as { sourceRef?: { name?: string } }).sourceRef?.name
const sourceWarning = useMemo(() => {
const items = sources?.items ?? []
if (items.length === 0) {
return "No source connections exist yet. Add one before starting an import."
}
if (!selectedSource) return null
const source = items.find((s) => s.metadata.name === selectedSource)
if (!source) return null
const ready = readyCondition(source)
if (ready?.status === "True") return null
return `Connection "${selectedSource}" is not ready${
ready?.message ? `: ${ready.message}` : ""
}. The import will wait until it is.`
}, [sources, selectedSource])
const handleSubmit = async () => {
if (!tenantNamespace) {
alert("Tenant namespace is not available. Please refresh.")
return
}
if (!name.trim()) {
alert("Name is required")
return
}
if (schemaFormRef.current && !schemaFormRef.current.validate()) return
const spec = formData as { sourceRef?: { name?: string }; vms?: unknown[] }
if (!spec.sourceRef?.name) {
alert("A source connection is required")
return
}
if (!spec.vms || spec.vms.length === 0) {
alert("Add at least one machine to import")
return
}
const resource = {
apiVersion: `${MIGRATION_GROUP}/${MIGRATION_VERSION}`,
kind: "VMImportTask",
metadata: {
name: name.trim(),
namespace: tenantNamespace,
},
spec: formData,
}
try {
await createMutation.mutateAsync(resource)
navigate("/console/migration/vmimporttasks")
} catch (err) {
alert(`Failed to start the import: ${(err as Error).message}`)
}
}
if (schemaLoading) {
return (
<div className="flex items-center gap-2 p-8 text-slate-500">
<Spinner /> Loading schema...
</div>
)
}
if (!schema) {
return (
<div className="p-8 text-red-600">
Failed to load the VMImportTask schema. Is the migration-controller package enabled?
</div>
)
}
return (
<div className="p-6">
<div className="mb-5 flex items-center gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-md bg-slate-100">
<Import className="size-6 text-slate-600" />
</div>
<div>
<h1 className="text-lg font-semibold text-slate-900">New import</h1>
<p className="text-xs text-slate-500">
Name the machines to import. Each one becomes a virtual machine with its own
disks, which stay after this import is deleted.
</p>
</div>
</div>
<Section>
<div className="space-y-4 p-5">
<div>
<label htmlFor="task-name" className="mb-1 block text-sm font-medium text-slate-700">
Import name <span className="text-red-500">*</span>
</label>
<input
id="task-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="import-web-tier"
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400"
required
/>
</div>
{sourceWarning && (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 px-3 py-2 text-xs text-yellow-800">
{sourceWarning}
</div>
)}
<div className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600">
Each machine is identified by its managed-object reference from vSphere
(<code className="font-mono">vm-1234</code>) — visible in the vSphere client
URL, or from <code className="font-mono">govc ls -i</code>.
</div>
<div>
<SchemaForm
ref={schemaFormRef}
openAPISchema={schema}
formData={formData}
onChange={(data) => setFormData((data ?? {}) as Record<string, unknown>)}
>
<div className="hidden" />
</SchemaForm>
</div>
</div>
<div className="flex items-center gap-2 border-t border-slate-200 px-5 py-3">
<Button
type="button"
variant="primary"
size="sm"
onClick={handleSubmit}
disabled={createMutation.isPending}
>
{createMutation.isPending ? (
<>
<Spinner /> Starting...
</>
) : (
<>
<Save className="size-3.5" /> Start import
</>
)}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => navigate("/console/migration/vmimporttasks")}
disabled={createMutation.isPending}
>
Cancel
</Button>
</div>
</Section>
</div>
)
}
@@ -0,0 +1,195 @@
import { Link, useParams } from "react-router"
import { Import } from "lucide-react"
import { Section, Spinner } from "@cozystack/ui"
import { useK8sGet } from "@cozystack/k8s-client"
import { useTenantContext } from "../lib/tenant-context.tsx"
import { formatAge } from "../lib/status.ts"
import {
MIGRATION_GROUP,
MIGRATION_VERSION,
type MigrationResource,
phaseTone,
taskProgress,
} from "../lib/migration.ts"
function PhaseBadge({ phase }: { phase: string | undefined }) {
const tone = phaseTone(phase)
return (
<span
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
tone === "ok"
? "bg-green-100 text-green-800"
: tone === "error"
? "bg-red-100 text-red-800"
: "bg-yellow-100 text-yellow-800"
}`}
>
{phase ?? "Pending"}
</span>
)
}
export function MigrationTaskDetailPage() {
const { name } = useParams<{ name: string }>()
const { tenantNamespace } = useTenantContext()
const { data: task, isLoading, error } = useK8sGet<MigrationResource>(
{
apiGroup: MIGRATION_GROUP,
apiVersion: MIGRATION_VERSION,
plural: "vmimporttasks",
name: name ?? "",
namespace: tenantNamespace ?? "",
},
{ enabled: !!name && !!tenantNamespace },
)
if (isLoading) {
return (
<div className="flex items-center gap-2 p-6 text-sm text-slate-500">
<Spinner /> Loading…
</div>
)
}
if (error || !task) {
return (
<div className="p-6 text-sm text-red-600">
Failed to load import {name}: {(error as Error | null)?.message ?? "not found"}
</div>
)
}
const vms = task.status?.vms ?? []
const overall = taskProgress(task)
return (
<div className="p-6">
<div className="mb-5 flex items-center gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-md bg-slate-100">
<Import className="size-6 text-slate-600" />
</div>
<div>
<h1 className="text-lg font-semibold text-slate-900">{task.metadata.name}</h1>
<p className="text-xs text-slate-500">
from{" "}
<Link
to="/console/migration/vmimportsources"
className="text-blue-700 hover:underline"
>
{task.spec?.sourceRef?.name ?? "-"}
</Link>
{task.metadata.creationTimestamp
? ` · started ${formatAge(task.metadata.creationTimestamp)} ago`
: ""}
</p>
</div>
<div className="ml-auto flex items-center gap-3">
{overall !== null && overall < 100 && (
<span className="text-sm text-slate-500">{overall}%</span>
)}
<PhaseBadge phase={task.status?.phase} />
</div>
</div>
{task.status?.message && (
<div
className={`mb-4 rounded-lg border px-3 py-2 text-xs ${
task.status.phase === "Failed"
? "border-red-200 bg-red-50 text-red-800"
: "border-slate-200 bg-slate-50 text-slate-600"
}`}
>
{task.status.message}
</div>
)}
<Section>
{vms.length === 0 ? (
<div className="py-12 text-center text-sm text-slate-500">
Nothing has started yet. The import waits until its connection is ready.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200 text-left text-xs font-medium uppercase tracking-wider text-slate-500">
<th className="px-5 py-3">Source machine</th>
<th className="px-5 py-3">Phase</th>
<th className="px-5 py-3">Progress</th>
<th className="px-5 py-3">Virtual machine</th>
<th className="px-5 py-3">Disks</th>
</tr>
</thead>
<tbody>
{vms.map((vm) => (
<tr key={vm.id} className="border-b border-slate-100">
<td className="px-5 py-3 text-sm text-slate-900">
<div className="font-medium">{vm.name || vm.id}</div>
{vm.name && <div className="font-mono text-xs text-slate-500">{vm.id}</div>}
</td>
<td className="px-5 py-3">
<PhaseBadge phase={vm.phase} />
{vm.message && (
<div className="mt-1 max-w-md text-xs text-slate-500">{vm.message}</div>
)}
</td>
<td className="px-5 py-3">
<div className="flex items-center gap-2">
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-slate-200">
<div
className={`h-full rounded-full ${
vm.phase === "Failed" ? "bg-red-400" : "bg-blue-500"
}`}
style={{ width: `${Math.min(100, Math.max(0, vm.progress ?? 0))}%` }}
/>
</div>
<span className="text-xs text-slate-500">{vm.progress ?? 0}%</span>
</div>
</td>
<td className="px-5 py-3 text-sm text-slate-600">
{vm.vmInstance ? (
<Link
to={`/console/vminstances/${vm.vmInstance}`}
className="text-blue-700 hover:underline"
>
{vm.vmInstance}
</Link>
) : (
"-"
)}
</td>
<td className="px-5 py-3 text-sm text-slate-600">
{vm.disks && vm.disks.length > 0 ? (
<div className="flex flex-col gap-0.5">
{vm.disks.map((disk) => (
<Link
key={disk}
to={`/console/vmdisks/${disk}`}
className="text-blue-700 hover:underline"
>
{disk}
</Link>
))}
</div>
) : (
"-"
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Section>
{/* Stated on the page rather than only in docs: a tenant who is unsure
whether cleaning up destroys their machines will never clean up. */}
<p className="mt-4 text-xs text-slate-500">
Imported machines and disks are ordinary Cozystack objects. Deleting this import
removes only the migration machinery.
</p>
</div>
)
}
@@ -92,6 +92,20 @@ describe("useConsoleSidebarSections — admin areas moved out", () => {
expect(findItem(result.current, "Info")).toBeUndefined()
expect(findItem(result.current, "Tenants")).toBeUndefined()
})
// Migration is a tenant area, not an admin one: a tenant registers their own
// vCenter and runs their own imports, so it belongs beside Backups in Console
// rather than in the admin portal.
it("offers the Migration group with both source and import entries", async () => {
const client = makeClient({ nodes: true, backupclasses: true })
const { result } = renderHook(() => useConsoleSidebarSections(), {
wrapper: makeWrapper(client),
})
await waitFor(() => expect(result.current.length).toBeGreaterThan(0))
expect(findItem(result.current, "Sources")?.to).toBe("/console/migration/vmimportsources")
expect(findItem(result.current, "Imports")?.to).toBe("/console/migration/vmimporttasks")
expect(result.current.some((s) => s.title === "Migration")).toBe(true)
})
})
describe("useAdminSidebarSections", () => {
@@ -6,10 +6,12 @@ import {
Gauge,
Globe,
HardDrive,
Import,
LayoutGrid,
Layers,
Network,
Package,
Plug,
Server,
ToyBrick,
Users,
@@ -113,7 +115,15 @@ export function useConsoleSidebarSections(): SidebarSection[] {
],
}
return [...categorySections, backupsSection]
const migrationSection: SidebarSection = {
title: "Migration",
items: [
{ label: "Sources", to: "/console/migration/vmimportsources", icon: Plug },
{ label: "Imports", to: "/console/migration/vmimporttasks", icon: Import },
],
}
return [...categorySections, backupsSection, migrationSection]
}, [grouped])
}
@@ -0,0 +1,32 @@
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNGVENDQWJxZ0F3SUJBZ0lRZVZYSk85eWlXZkhqanI1T2RUN0VhVEFLQmdncWhrak9QUVFEQWpCcU1Sb3cKR0FZRFZRUUtFeEYwWld4bGNHOXlkQzVoWlc1cGVDNXBiekVhTUJnR0ExVUVBeE1SZEdWc1pYQnZjblF1WVdWdQphWGd1YVc4eE1EQXVCZ05WQkFVVEp6RTJNVEk0TWpBeE5ESXpNREF5TlRFM05qRTVNekk0T1RreE1qWTBOREUzCk16UTRNVEEyTlRBZUZ3MHlOREV4TWpNeE56TXlNek5hRncwek5ERXhNakV4TnpNeU16TmFNR294R2pBWUJnTlYKQkFvVEVYUmxiR1Z3YjNKMExtRmxibWw0TG1sdk1Sb3dHQVlEVlFRREV4RjBaV3hsY0c5eWRDNWhaVzVwZUM1cApiekV3TUM0R0ExVUVCUk1uTVRZeE1qZ3lNREUwTWpNd01ESTFNVGMyTVRrek1qZzVPVEV5TmpRME1UY3pORGd4Ck1EWTFNRmt3RXdZSEtvWkl6ajBDQVFZSUtvWkl6ajBEQVFjRFFnQUVVNWVLeitTcFhyeGNEWnU1U0NXZTVUWjAKeVBISlNLS3VEbzRaSnNTU0U1TVZpUGJEMXRLMExhRWhjWHh1QU5CeWlzWXRyUmRKTnlRRndIQmNqbkFiKzZOQwpNRUF3RGdZRFZSMFBBUUgvQkFRREFnR0dNQThHQTFVZEV3RUIvd1FGTUFNQkFmOHdIUVlEVlIwT0JCWUVGSmg5ClYwNE4wbytNL0tnTjRQdTZuZXo5U01oRE1Bb0dDQ3FHU000OUJBTUNBMGtBTUVZQ0lRQ1pTZ21TVDdUNGVRYVUKQUhQUjI5b2Z2a1RoMGRXV1gzZkMzOEtFaURsaTJBSWhBUFlDNFhVRFU4QlU1VWdTMldzc1FjWlp5amJuT1JJZQpEOUZzdHVQbFlXUFQKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=
server: https://teleport.aenix.io:443
tls-server-name: kube-teleport-proxy-alpn.teleport.aenix.io
name: teleport.aenix.io
contexts:
- context:
cluster: teleport.aenix.io
extensions:
- extension: hidora-test
name: teleport.kube.name
user: teleport.aenix.io-hidora-test
name: teleport.aenix.io-hidora-test
current-context: teleport.aenix.io-hidora-test
kind: Config
preferences: {}
users:
- name: teleport.aenix.io-hidora-test
user:
exec:
apiVersion: client.authentication.k8s.io/v1beta1
args:
- kube
- credentials
- --kube-cluster=hidora-test
- --teleport-cluster=teleport.aenix.io
- --proxy=teleport.aenix.io:443
command: /usr/local/bin/tsh
env: null
provideClusterInfo: false
@@ -0,0 +1,3 @@
apiVersion: v2
name: cozy-forklift-operator
version: 0.0.0 # Placeholder, the actual version will be automatically set during the build process
@@ -0,0 +1,73 @@
export NAME=forklift-operator
export NAMESPACE=cozy-forklift
include ../../../hack/package.mk
test:
helm unittest .
# Forklift release. Upstream tags container images as `release-2.11`,
# not `v2.11.x`, so we use that channel here. Bump VERSION to ride
# patch releases; bumping FORKLIFT_CHANNEL (e.g. release-2.12) is a
# breaking change that requires re-checking CRDs against vm-import.
#
# The image refs substituted below are pinned to immutable @sha256 digests
# so pulls are reproducible and mirrored / air-gapped installs can resolve
# them (mirror the digests, not the floating tag). Renovate keeps the
# digests fresh via a custom manager over this Makefile and the rendered
# templates/forklift-operator.yaml (see .github/renovate.json). must-gather
# and console-plugin are versioned independently of the operator channel
# upstream (no release-2.11 tag exists for them), so they carry their own
# tags (v2.7.1 / release-v2.6.7); re-resolve when bumping FORKLIFT_CHANNEL.
FORKLIFT_VERSION=v2.11.5
FORKLIFT_CHANNEL=release-2.11
update:
rm -rf templates
mkdir templates
git clone --depth 1 --branch $(FORKLIFT_VERSION) https://github.com/kubev2v/forklift.git /tmp/forklift-$(FORKLIFT_VERSION)
kubectl kustomize /tmp/forklift-$(FORKLIFT_VERSION)/operator/config/default/ > templates/forklift-operator.yaml
rm -rf /tmp/forklift-$(FORKLIFT_VERSION)
# Forklift kustomize emits ${VAR} placeholders for images and tunables;
# the upstream OLM bundle substitutes them at install time. We don't use
# OLM, so we substitute them statically against the kubev2v registry.
sed -i 's|namespace: forklift-operator-system|namespace: $(NAMESPACE)|g' templates/forklift-operator.yaml
sed -i 's|namespace: konveyor-forklift|namespace: $(NAMESPACE)|g' templates/forklift-operator.yaml
# Cozystack doesn't use OLM, so WATCH_NAMESPACE (sourced from the
# olm.targetNamespaces annotation) resolves empty and the Ansible role
# defaults app_namespace to `konveyor-forklift`, deploying the controller
# outside our RBAC scope. Pin it to our namespace.
python3 -c "import re; p='templates/forklift-operator.yaml'; c=open(p).read(); c=re.sub(r'- name: WATCH_NAMESPACE\n\s*valueFrom:\n\s*fieldRef:\n\s*fieldPath: metadata.annotations\[.olm.targetNamespaces.\]', '- name: WATCH_NAMESPACE\n value: $(NAMESPACE)', c); open(p,'w').write(c)"
sed -i 's|$${OPERATOR_IMAGE}|quay.io/kubev2v/forklift-operator:release-2.11@sha256:1ffde2c6b88419a2aee17b871eaf36c078af1f1b21369bf131b84f7b93cb012f|g' templates/forklift-operator.yaml
sed -i 's|$${CONTROLLER_IMAGE}|quay.io/kubev2v/forklift-controller:release-2.11@sha256:2600e68a235e4c3351a38ce2dd97562206795a733abd8b0cfd8444a541b0db51|g' templates/forklift-operator.yaml
sed -i 's|$${API_IMAGE}|quay.io/kubev2v/forklift-api:release-2.11@sha256:f8ad08949f5d0f051a81b2e198df7273f55d16cfd41878f41133e3a0ed1ad5ec|g' templates/forklift-operator.yaml
sed -i 's|$${MUST_GATHER_IMAGE}|quay.io/kubev2v/forklift-must-gather:v2.7.1@sha256:62eecec1408be00e907fbde6b13be9223fdbca116933ecba85cd87d68d6acfbb|g' templates/forklift-operator.yaml
sed -i 's|$${UI_PLUGIN_IMAGE}|quay.io/kubev2v/forklift-console-plugin:release-v2.6.7@sha256:99c34ac714604853f4abf589e1ccddb13cd9eaf1bd1eae2c928c622deb22304d|g' templates/forklift-operator.yaml
sed -i 's|$${CLI_DOWNLOAD_IMAGE}|quay.io/kubev2v/forklift-cli-download:release-2.11@sha256:8d59927b902bbdea6ecb76c0bae358c9d9d2ed40aaa56155d82d8c15ac56634a|g' templates/forklift-operator.yaml
sed -i 's|$${VALIDATION_IMAGE}|quay.io/kubev2v/forklift-validation:release-2.11@sha256:fc06f88fe54130ca24d39f4c9261e2359c122cb7b5146fa93403e8b0f0c0716f|g' templates/forklift-operator.yaml
sed -i 's|$${VIRT_V2V_IMAGE}|quay.io/kubev2v/forklift-virt-v2v:release-2.11@sha256:fe373a538db180c5bed2c99df667a450c432671ad02057d4c692776f7d7cc203|g' templates/forklift-operator.yaml
sed -i 's|$${VIRT_V2V_IMAGE_RHEL9}|quay.io/kubev2v/forklift-virt-v2v-xfs:release-2.11@sha256:5402dbc95daacb7d5caab2c855c1cb6889575e2d9d4d282ce9dae201d6cf0331|g' templates/forklift-operator.yaml
sed -i 's|$${POPULATOR_CONTROLLER_IMAGE}|quay.io/kubev2v/populator-controller:release-2.11@sha256:70d8dc184b41168496d280622e41e8d7fc15c82640be6f025c22d101c1f336bb|g' templates/forklift-operator.yaml
sed -i 's|$${OVIRT_POPULATOR_IMAGE}|quay.io/kubev2v/ovirt-populator:release-2.11@sha256:223857c91dd2a1050d6d087c6b58845bada676a54d450a7ef7bc7c7bfec28784|g' templates/forklift-operator.yaml
sed -i 's|$${OPENSTACK_POPULATOR_IMAGE}|quay.io/kubev2v/openstack-populator:release-2.11@sha256:f50cfb4f4bc86666ba5cd304f1baa95bbefc843ee677ad338e927cbef88604b0|g' templates/forklift-operator.yaml
sed -i 's|$${OVA_PROVIDER_SERVER_IMAGE}|quay.io/kubev2v/forklift-ova-provider-server:release-2.11@sha256:5901f34027354554f91f2be6669eb458bcb0a568d572c93188a3313904e0020a|g' templates/forklift-operator.yaml
sed -i 's|$${OVA_PROXY_IMAGE}|quay.io/kubev2v/forklift-ova-proxy:release-2.11@sha256:bd4e662e8c3055c7b4aa2ce1db80ec06b27016c9c39e0358d036031669fc88ca|g' templates/forklift-operator.yaml
sed -i 's|$${VSPHERE_XCOPY_VOLUME_POPULATOR_IMAGE}|quay.io/kubev2v/vsphere-xcopy-volume-populator:release-2.11@sha256:e63773d07e8612e9d153d9e2b93df757d0887980f2455ea278f02c32821fd954|g' templates/forklift-operator.yaml
sed -i 's|$${HYPERV_PROVIDER_SERVER_IMAGE}|quay.io/kubev2v/forklift-hyperv-provider-server:release-2.11@sha256:7bba40b2fca79a96c3388cb1c4f4a71694f5059324ba52e5821342b3a3058e23|g' templates/forklift-operator.yaml
# Tunable env vars (defaults from upstream Ansible role).
sed -i 's|$${VIRT_V2V_DONT_REQUEST_KVM}|"false"|g' templates/forklift-operator.yaml
sed -i 's|$${SNAPSHOT_REMOVAL_TIMEOUT}|"120"|g' templates/forklift-operator.yaml
sed -i 's|$${SNAPSHOT_STATUS_CHECK_RATE}|"10"|g' templates/forklift-operator.yaml
sed -i 's|$${CLEANUP_RETRIES}|"10"|g' templates/forklift-operator.yaml
sed -i 's|$${CDI_EXPORT_TOKEN_TTL}|""|g' templates/forklift-operator.yaml
sed -i 's|$${FILESYSTEM_OVERHEAD}|"0.055"|g' templates/forklift-operator.yaml
sed -i 's|$${BLOCK_OVERHEAD}|"0"|g' templates/forklift-operator.yaml
sed -i 's|$${OVIRT_OS_MAP}|""|g' templates/forklift-operator.yaml
sed -i 's|$${VSPHERE_OS_MAP}|""|g' templates/forklift-operator.yaml
sed -i 's|$${VIRT_CUSTOMIZE_MAP}|""|g' templates/forklift-operator.yaml
# Drop residual Namespace resources — those are provisioned by Cozystack.
python3 -c "content=open('templates/forklift-operator.yaml').read(); docs=content.split('---'); result=[d for d in docs if d.strip() and not any(l.strip()=='kind: Namespace' for l in d.strip().split(chr(10)))]; open('templates/forklift-operator.yaml','w').write('---'.join(result))"
# Forklift CRDs ship Go-template literals (e.g. `{{.PlanName}}`) in
# field descriptions/examples; Helm would try to evaluate them. Escape
# every `{{`/`}}` as `{{\`{{\`}}`/`{{\`}}\`}}` so they survive rendering.
python3 -c "import sys; p='templates/forklift-operator.yaml'; c=open(p).read(); c=c.replace('{{','XXLEFTXX').replace('}}','{{\`}}\`}}').replace('XXLEFTXX','{{\`{{\`}}'); open(p,'w').write(c)"
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,33 @@
suite: forklift-operator image pinning
release:
name: forklift-operator
namespace: cozy-forklift
templates:
- templates/forklift-operator.yaml
tests:
- it: pins must-gather and console-plugin to immutable digests, not :latest
documentSelector:
path: kind
value: Deployment
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: MUST_GATHER_IMAGE
value: quay.io/kubev2v/forklift-must-gather:v2.7.1@sha256:62eecec1408be00e907fbde6b13be9223fdbca116933ecba85cd87d68d6acfbb
- contains:
path: spec.template.spec.containers[0].env
content:
name: UI_PLUGIN_IMAGE
value: quay.io/kubev2v/forklift-console-plugin:release-v2.6.7@sha256:99c34ac714604853f4abf589e1ccddb13cd9eaf1bd1eae2c928c622deb22304d
- it: pins the operator and controller images to immutable digests
documentSelector:
path: kind
value: Deployment
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: CONTROLLER_IMAGE
value: quay.io/kubev2v/forklift-controller:release-2.11@sha256:2600e68a235e4c3351a38ce2dd97562206795a733abd8b0cfd8444a541b0db51
+3
View File
@@ -0,0 +1,3 @@
apiVersion: v2
name: cozy-forklift
version: 0.0.0 # Placeholder, the actual version will be automatically set during the build process
+7
View File
@@ -0,0 +1,7 @@
export NAME=forklift
export NAMESPACE=cozy-$(NAME)
include ../../../hack/package.mk
test:
helm unittest .
@@ -0,0 +1,136 @@
{{- /*
Uninstalling this chart deletes the ForkliftController CR, and the operator's
finalizer on it holds the delete open until the operator has torn its operand
down. If the operator is already gone — which is the normal order when the
whole bundle is being removed, since nothing sequences the operator package
against this one — nothing will ever clear that finalizer and the release
never finishes uninstalling. Observed live.
So: delete the CR first, give the operator a bounded chance to do it properly,
and only then clear the finalizer by hand. Doing it in that order means a
live operator still performs its own cleanup; the finalizer strip is the
fallback for when it cannot.
The hook always exits 0. A cleanup that cannot complete must not be the reason
an operator cannot uninstall the chart.
*/ -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: forklift-cr-cleanup
namespace: {{ .Release.Namespace }}
annotations:
helm.sh/hook: pre-delete
helm.sh/hook-weight: "-10"
helm.sh/hook-delete-policy: hook-succeeded,hook-failed,before-hook-creation
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: forklift-cr-cleanup
namespace: cozy-forklift
annotations:
helm.sh/hook: pre-delete
helm.sh/hook-weight: "-10"
helm.sh/hook-delete-policy: hook-succeeded,hook-failed,before-hook-creation
rules:
- apiGroups: ["forklift.konveyor.io"]
resources: ["forkliftcontrollers"]
resourceNames: ["forklift-controller"]
# get to poll for the delete completing, delete to start it, patch to clear
# the finalizer when the operator is no longer there to clear it itself.
verbs: ["get", "delete", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: forklift-cr-cleanup
namespace: cozy-forklift
annotations:
helm.sh/hook: pre-delete
helm.sh/hook-weight: "-10"
helm.sh/hook-delete-policy: hook-succeeded,hook-failed,before-hook-creation
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: forklift-cr-cleanup
subjects:
- kind: ServiceAccount
name: forklift-cr-cleanup
namespace: {{ .Release.Namespace }}
---
apiVersion: batch/v1
kind: Job
metadata:
name: forklift-cr-cleanup
namespace: {{ .Release.Namespace }}
annotations:
helm.sh/hook: pre-delete
helm.sh/hook-weight: "0"
helm.sh/hook-delete-policy: hook-succeeded,before-hook-creation
spec:
# Bounded so a wedged API call cannot hold helm uninstall open indefinitely.
activeDeadlineSeconds: 120
backoffLimit: 1
# Failed pods outlive the hook bundle so their logs can still be read.
ttlSecondsAfterFinished: 600
template:
spec:
serviceAccountName: forklift-cr-cleanup
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 65532
seccompProfile:
type: RuntimeDefault
# kubectl writes its discovery cache under $HOME; with a read-only root
# filesystem it aborts before running any subcommand unless HOME points
# at something writable.
volumes:
- name: kube-cache
emptyDir: {}
containers:
- name: cleanup
image: docker.io/clastix/kubectl:v1.32
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env:
- name: HOME
value: /tmp
volumeMounts:
- name: kube-cache
mountPath: /tmp
command:
- sh
- -c
- |
set -x
ns=cozy-forklift
cr=forklift-controller
kubectl -n "$ns" get forkliftcontroller "$cr" >/dev/null 2>&1 || {
echo "ForkliftController $cr already gone"
exit 0
}
kubectl -n "$ns" delete forkliftcontroller "$cr" --wait=false || true
# Give a live operator its chance to finish. 60s against the
# 120s activeDeadlineSeconds leaves room for the strip below.
i=0
while [ "$i" -lt 30 ]; do
kubectl -n "$ns" get forkliftcontroller "$cr" >/dev/null 2>&1 || {
echo "operator completed its own teardown"
exit 0
}
i=$((i + 1))
sleep 2
done
echo "finalizer still held after 60s; clearing it"
kubectl -n "$ns" patch forkliftcontroller "$cr" \
--type=merge -p '{"metadata":{"finalizers":null}}' || true
exit 0
@@ -0,0 +1,11 @@
apiVersion: forklift.konveyor.io/v1beta1
kind: ForkliftController
metadata:
name: forklift-controller
namespace: cozy-forklift
spec:
feature_ui_plugin: "false"
feature_validation: "true"
feature_volume_populator: "true"
feature_auth_required: "true"
feature_cli_download: "false"
@@ -0,0 +1,66 @@
suite: forklift CR pre-delete cleanup
# The operator holds a finalizer on the ForkliftController. When the whole
# bundle is removed, nothing sequences the operator package against this one,
# so the operator is usually gone before the CR is deleted — and then nothing
# ever clears that finalizer and the release never finishes uninstalling.
release:
name: forklift
namespace: cozy-forklift
templates:
- templates/forklift-cr-cleanup-hook.yaml
tests:
- it: runs before the release is deleted, and cleans itself up afterwards
documentSelector:
path: kind
value: Job
asserts:
- equal:
path: metadata.annotations["helm.sh/hook"]
value: pre-delete
- equal:
path: metadata.annotations["helm.sh/hook-delete-policy"]
value: hook-succeeded,before-hook-creation
# Bounded, or a wedged API call holds helm uninstall open indefinitely.
- equal:
path: spec.activeDeadlineSeconds
value: 120
- it: can delete the CR and clear its finalizer, and nothing else
documentSelector:
path: kind
value: Role
asserts:
- equal:
path: rules[0].apiGroups[0]
value: forklift.konveyor.io
- equal:
path: rules[0].resources[0]
value: forkliftcontrollers
# Named explicitly: the hook has no business touching any other CR.
- equal:
path: rules[0].resourceNames[0]
value: forklift-controller
- contains:
path: rules[0].verbs
content: delete
- contains:
path: rules[0].verbs
content: patch
- it: runs under the restricted pod security standard
documentSelector:
path: kind
value: Job
asserts:
- equal:
path: spec.template.spec.securityContext.runAsNonRoot
value: true
- equal:
path: spec.template.spec.containers[0].securityContext.allowPrivilegeEscalation
value: false
- equal:
path: spec.template.spec.containers[0].securityContext.readOnlyRootFilesystem
value: true
- contains:
path: spec.template.spec.containers[0].securityContext.capabilities.drop
content: ALL
@@ -0,0 +1,20 @@
suite: forklift controller CR
release:
name: forklift
namespace: cozy-forklift
templates:
- templates/forklift-cr.yaml
tests:
- it: renders the ForkliftController with the expected feature flags
asserts:
- isKind:
of: ForkliftController
- equal:
path: spec.feature_validation
value: "true"
- equal:
path: spec.feature_auth_required
value: "true"
- equal:
path: spec.feature_volume_populator
value: "true"
Whitespace-only changes.
@@ -0,0 +1,3 @@
apiVersion: v2
name: cozy-migration-controller
version: 0.0.0 # Placeholder, the actual version will be automatically set during the build process
@@ -0,0 +1,20 @@
NAME=migration-controller
NAMESPACE=cozy-migration-controller
include ../../../hack/common-envs.mk
include ../../../hack/package.mk
test:
helm unittest .
image: image-migration-controller
image-migration-controller:
docker buildx build -f images/migration-controller/Dockerfile ../../.. \
$(call image-tags,migration-controller,v$(COZYSTACK_VERSION)) \
$(call cache-args,migration-controller) \
--metadata-file images/migration-controller.json \
$(BUILDX_ARGS)
IMAGE="$(REGISTRY)/migration-controller:$(IMAGE_TAG)@$$(yq e '."containerimage.digest"' images/migration-controller.json -o json -r)" \
yq -i '.migrationController.image = strenv(IMAGE)' values.yaml
rm -f images/migration-controller.json
@@ -0,0 +1,193 @@
# migration-controller
Reconciles `forklift.cozystack.io`: `VMImportSource` connections and the `VMImportTask` operations that run over them. Konveyor Forklift is the transfer engine underneath; this controller owns the tenant-facing API, drives Forklift's objects, and turns each finished transfer into a Cozystack `VMDisk` and `VMInstance`.
Opt-in. Add all three packages to `bundles.enabledPackages`:
```yaml
bundles:
enabledPackages:
- cozystack.forklift-operator
- cozystack.forklift
- cozystack.migration-controller
```
## Before the first import
Four checks, each of which otherwise fails late and obscurely. All were hit on a real cluster.
1. **The storage class must bind `Immediate`.** A `WaitForFirstConsumer` class deadlocks an import: nothing consumes the claim while it is being populated. The task refuses such a class up front rather than hanging, but the *cluster default* is frequently `WaitForFirstConsumer`, so `spec.storageClass` usually has to be named explicitly. Check with `kubectl get storageclass`.
2. **The ESXi host must be reachable, and must not collide with the Service CIDR.** See [When the cluster cannot reach the ESXi host](#when-the-cluster-cannot-reach-the-esxi-host). This is the one that costs a whole transfer before it announces itself.
3. **The vCenter account needs its domain.** `mtv-migration` is rejected as an incorrect username; `mtv-migration@vsphere.local` is not. The engine reports it as a credentials failure either way, which reads like the wrong password.
4. **The engine's own certificates must be current.** Forklift rotates its serving certificates and updates the secret, but does not restart its pods, so a long-lived deployment can serve a certificate its own published CA no longer matches. The controller verifies that CA and will refuse the connection. `kubectl -n cozy-forklift rollout restart deploy/forklift-controller` resolves it; the same staleness eventually expires the certificate outright.
## Platform configuration
One key, and only for VMware:
```yaml
vmImport:
vddkImage: registry.example.com/vddk:8.0.3
```
Cozystack neither ships nor mirrors that image: it is built from VMware's proprietary Virtual Disk Development Kit, which we have no licence to redistribute. An operator who holds one builds it, pushes it where the cluster can pull from, and names it here. Only the reference travels to the controller — never a credential.
Leaving it empty is a normal, supported state. A vSphere `VMImportSource` then reports `Ready=False` with reason `VDDKNotConfigured` at the moment it is created, so a tenant learns the path is unavailable at registration rather than watching a transfer die halfway through. Nothing is materialized for such a source — not even its credentials Secret.
## What a tenant creates
A connection, reusable across imports:
```yaml
apiVersion: forklift.cozystack.io/v1alpha1
kind: VMImportSource
metadata:
name: vcenter-prod
namespace: tenant-foo
spec:
type: vsphere
url: https://vcenter.example.com/sdk
credentials:
username: mtv-migration@vsphere.local
password: "..."
caCert: |
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
```
Then an import, which is one-shot:
```yaml
apiVersion: forklift.cozystack.io/v1alpha1
kind: VMImportTask
metadata:
name: import-web-tier
namespace: tenant-foo
spec:
sourceRef:
name: vcenter-prod
vms:
- id: vm-1234 # managed-object reference, from the vSphere client URL or `govc ls -i`
name: web-01
storageClass: replicated
```
Deleting the task removes the migration machinery and leaves `web-01` and its disks in place: they are ordinary Cozystack objects from the moment they exist, and carry no owner reference back to the task. Deleting the source deregisters the connection — its Forklift Provider and projected Secret go with it, and nothing already imported is touched.
Created VMs start `Halted`, not running. A freshly imported guest usually needs its network reconfigured, and booting it the instant the transfer finishes can put a second copy of a still-running production machine on the network.
## When the cluster cannot reach the ESXi host
Disk data does not travel through vCenter. The VDDK opens its connection straight to the ESXi host holding the VM, at whatever address vCenter advertises for that host — and that address is often one the cluster cannot use.
Check it before the first import, because the failure comes late. The address vCenter advertises must be routable from the worker nodes and, critically, **must not fall inside the cluster's Service CIDR**: an address in that range is claimed by service routing, the packets never leave the node, and the transfer dies after validation has already passed. Compare the two:
```sh
# what vCenter advertises for the host, as the engine sees it
kubectl -n cozy-forklift exec deploy/forklift-controller -c inventory -- \
curl -sk "https://localhost:8443/providers/vsphere/<provider-uid>/hosts"
# the cluster's Service CIDR, via the address of the kubernetes service
kubectl get svc -n default kubernetes -o jsonpath='{.spec.clusterIP}'
```
A host that is unreachable, or that collides, gets an entry on the source:
```yaml
spec:
hosts:
- id: host-10 # the host's managed-object id, as the VM's inventory record names it
address: 10.31.0.29 # an address the cluster can actually reach
credentials:
username: root
password: "..."
insecureSkipVerify: true
```
The credentials are the ESXi host's own account, not the vCenter one: the host authenticates this connection itself rather than honouring the vCenter session, and the engine connection-tests the pair before any transfer starts. That is also why the field takes a full credential rather than a bare address.
Symptom when the override is missing: the task reaches `Transferring`, sits there, and then fails with an NBD error — `nbd_connect_uri: the server has no export named ''` — which names neither the address nor the reason.
## TLS: caCert or insecureSkipVerify, not a thumbprint
Exactly one of these must be set, and the API rejects the object at admission otherwise.
`caCert` is the PEM certificate authority that signed the endpoint's certificate. `insecureSkipVerify: true` turns verification off — convenient in a lab and a poor idea anywhere else, since the credentials above travel over that same connection.
A SHA-1 **thumbprint does not work here**, and this is worth stating plainly because the engine's own error is misleading. Forklift's vSphere provider validation requires `user`, `password`, and either a `cacert` key or `insecureSkipVerify`; given a thumbprint instead it marks the provider `SecretNotValid` with the message "The secret is not valid", which reads like a credentials problem and is not. A thumbprint is what the engine wants for a *direct ESXi host* connection, a path this version does not expose.
## vCenter privileges
Neither `Administrator` nor a plain read-only role is right. Read-only cannot do the job, and Administrator hands the cluster a credential whose blast radius is the entire vCenter — and that credential lands in a Secret in the tenant's own namespace.
Clone the read-only role, add the privileges Forklift documents, and assign it to a service account created for the migration campaign and disabled afterwards. Scope it to the datacenter being migrated, propagated to children — not the whole vCenter.
Eleven privileges cover a cold migration, and several of them are writes, which is why read-only is not enough:
- `Datastore.Browse`, `Datastore.FileManagement`
- `Sessions.ValidateSession`
- `VirtualMachine.Interact.PowerOff`, `.PowerOn`, `.GuestControl`
- `VirtualMachine.Provisioning.DiskRandomAccess`, `.FileRandomAccess`, `.DiskRandomRead`, `.GetVmFiles`, `.PutVmFiles`
Warm migration adds `VirtualMachine.State.CreateSnapshot` and `.RemoveSnapshot`; encrypted guests need the `Cryptographer.*` set. Older vendor documentation asks for all fifteen `Provisioning` privileges — the remainder only matter for template migration, which this path does not do.
Two things about that role are worth knowing before you build it. vCenter **silently ignores an unknown privilege**, so a typo produces a role that looks correct and is quietly under-privileged; enumerate the valid identifiers with `govc role.ls Admin` rather than trusting a document. And the identifiers themselves have drifted between versions — what one document calls `Cryptographic.Direct access` is `Cryptographer.Access` on vSphere 7.
Note that the VDDK opens NFC connections to the **ESXi hosts** on TCP 443 and 902, not only to vCenter. That matters more than it sounds: vCenter frequently advertises a host on an address the cluster cannot reach, and the transfer then fails with `VixDiskLib_Open ... The server refused connection` while everything about the provider still looks healthy. When that address is unusable, list the host under `spec.hosts` with an address the cluster can reach and its own ESXi credentials — the host authenticates that connection itself rather than honouring the vCenter session.
A related trap on the cluster side: if the cluster's Service CIDR overlaps the ESXi network, the VDDK breaks before anything else does.
*The privilege detail above comes from the operational work on the original VMware import implementation, validated against a real vCenter.*
## Storage
Every disk of a task lands on `spec.storageClass`, or the cluster default when it is omitted. Either way the class must bind `Immediate`.
A `WaitForFirstConsumer` class does not fail an import, it **hangs** it: nothing consumes the claim while CDI populates it, so the transfer waits forever. The controller checks the binding mode before creating anything and fails the task with a message naming the class. On a stock Cozystack install the default class is `local`, which is `WaitForFirstConsumer` — so a task that names no class fails immediately with that explanation. `replicated` qualifies.
The transferred volume is handed to its `VMDisk` without being copied: the `PersistentVolume` is retained and re-bound into the claim the disk expects. That volume stays on `Retain` permanently and this is part of the contract, not an implementation detail — 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 data.
**Budget roughly twice each disk's size against the tenant quota while an import runs.** CDI allocates a prime volume of the target size *and* a scratch volume of about 106% of it, so a 230 GiB disk occupies around 474 GiB until the transfer finishes. On a tenant near its quota this shows up as the import stalling on `exceeded quota` rather than as anything resembling a storage error, and it clears on its own once the previous disk releases its transit volumes.
Also expect a second phase that nothing reports. After the download, CDI runs `qemu-img convert` from scratch onto the target volume — around half an hour per 230 GiB — while the DataVolume's own progress sits frozen at 99.92%, because that counter only ever measured the download. The real percentage is only visible in the importer pod's log.
## Timings, for planning
Measured on a real vSphere source during the original implementation work, on a 16 GiB UEFI Linux guest:
| Path | Duration |
|---|---|
| VDDK raw copy (what this version does) | ≈2 min 55 s |
| virt-v2v conversion plus its cross-namespace clone | ≈5 min 46 s |
The factor of two is the second copy, not the conversion. For scale at the other end: a 160 GiB Windows Server OVF expanding to 295 GiB took about an hour and five minutes end to end, with the disk transfer itself running near 80 MiB/s.
## Privilege model
Nothing on the transfer path is privileged. With guest conversion off — the only mode this version offers — no Forklift pod moves the bytes at all: Forklift emits a CDI DataVolume with a VDDK source and CDI's own importer does the work, and that pod satisfies the `restricted` Pod Security Standard. Verified against Forklift v2.11.5 and CDI v1.64.0, and confirmed on a live cluster by importing into a namespace enforcing `restricted` with no admission denial.
Guest conversion is a separate matter: it needs a node-level seccomp profile, because libguestfs starts `passt`, which unconditionally creates its own namespaces. It is deliberately not part of this version.
Tenants get read and write on both kinds through the standard aggregation labels, and no access at all to `forklift.konveyor.io` — the Forklift objects a task builds are internal machinery.
## Failure modes worth recognising
**A task stuck in `Creating` with the transfer finished** usually means the engine reported success without producing anything. A Plan that already has a failed attempt in its history can report `SUCCEEDED` on a retry while creating neither the volume nor the VM; the task surfaces this rather than hanging, and the remedy is a new task (which builds a new Plan) rather than retrying the old one.
**A transfer whose progress sits at 0 for many minutes** is not reported as an error on any of the migration objects. Look at the target namespace's claim events — the usual cause is the VDDK failing to reach an ESXi host, which surfaces there and nowhere else. When it eventually does fail, the message is `Unable to connect to vddk data source: nbd_connect_uri: the server has no export named ''`, which sounds like a missing disk and is almost always an unreachable host address: see [When the cluster cannot reach the ESXi host](#when-the-cluster-cannot-reach-the-esxi-host).
**A VM that reports `SecretNotValid` or an authentication failure** on a vCenter source is usually the username, not the password: vCenter wants the SSO domain (`user@vsphere.local`), and rejects a bare account name with the same message it uses for a wrong password.
**`server-side dry run does not protect you here.`** `kubectl apply --dry-run=server` is not honoured by Cozystack's aggregated API: for `apps.cozystack.io` resources it really creates the object. That applies to the `VMInstance` and `VMDisk` an import produces, so do not use it as a safety net when experimenting.
**Instance types are not interchangeable after the fact.** A `VirtualMachineInstance` is immutable, so changing `instanceType` on an imported VM requires deleting the VMI to have it re-created. Some catalog sizes also request hugepages, which a node without them cannot satisfy — the VM then stays `Pending` with `Insufficient hugepages-2Mi` rather than failing outright.
## Uninstalling
Remove the `ForkliftController` operand before the operator, or CRD deletion deadlocks on the operand's finalizer with nothing left running to clear it:
```sh
kubectl patch forkliftcontroller -n cozy-forklift forklift-controller \
--type merge -p '{"metadata":{"finalizers":null}}'
```
@@ -0,0 +1,284 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.16.4
name: vmimportsources.forklift.cozystack.io
spec:
group: forklift.cozystack.io
names:
kind: VMImportSource
listKind: VMImportSourceList
plural: vmimportsources
shortNames:
- vmis
- vmimportsrc
singular: vmimportsource
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.type
name: Type
type: string
- jsonPath: .spec.url
name: URL
type: string
- jsonPath: .status.conditions[?(@.type=='Ready')].status
name: Ready
type: string
- jsonPath: .status.conditions[?(@.type=='Ready')].reason
name: Reason
priority: 1
type: string
- jsonPath: .metadata.creationTimestamp
name: Age
type: date
name: v1alpha1
schema:
openAPIV3Schema:
description: |-
VMImportSource registers a reusable connection to a source virtualization
platform. It is long-lived: VMImportTasks reference it, and deleting it
deregisters the connection without touching anything already imported.
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: VMImportSourceSpec describes a connection to a source virtualization
platform.
properties:
credentials:
description: Credentials is the account used to reach the provider.
properties:
caCert:
description: |-
CACert is the PEM-encoded certificate authority that signed the
endpoint's TLS certificate. Required unless insecureSkipVerify is set:
the migration engine refuses a vSphere connection that can neither
verify the endpoint nor was told not to.
This is a CA certificate, not a fingerprint. A SHA-1 thumbprint is what
the engine wants for a direct ESXi host connection, which v1 does not
expose, and supplying one here does not satisfy the check.
type: string
insecureSkipVerify:
description: |-
InsecureSkipVerify disables verification of the endpoint's TLS
certificate. Convenient in a lab and a bad idea anywhere else: the
credentials above travel over that connection. Prefer caCert.
type: boolean
password:
description: Password is the account's password.
minLength: 1
type: string
username:
description: |-
Username is the account used to authenticate against the provider
(e.g. `migration@vsphere.local`).
minLength: 1
type: string
required:
- password
- username
type: object
x-kubernetes-validations:
- message: 'either caCert must be set or insecureSkipVerify must be
true: the migration engine will not open a vSphere connection
it can neither verify nor was told to trust'
rule: has(self.caCert) || (has(self.insecureSkipVerify) && self.insecureSkipVerify)
hosts:
description: |-
Hosts redirects the disk transfer for individual ESXi hosts.
Disk data does not travel through vCenter. VDDK opens its connection
straight to the ESXi host holding the VM, at whatever address vCenter
advertises for it — and that address is frequently one the cluster
cannot reach: a management network the workers are not on, or, worse,
one that collides with the cluster's own Service CIDR, where the packet
is swallowed by service routing and never leaves. The transfer then
fails late, after validation has passed, with an NBD error naming
neither the address nor the reason.
An entry here names the host and the address to use instead. Each
carries its own credentials because the ESXi host authenticates the
connection itself rather than honouring the vCenter session, and the
engine connection-tests them before the transfer starts.
items:
description: HostOverride redirects the disk transfer for one ESXi
host.
properties:
address:
description: |-
Address is the address the cluster should use to reach this host for the
disk transfer, replacing the one vCenter advertises.
minLength: 1
type: string
credentials:
description: |-
Credentials is the account on the ESXi host itself. The host
authenticates the transfer connection directly, so the provider's
vCenter account does not carry over.
properties:
caCert:
description: |-
CACert is the PEM-encoded certificate authority that signed the
endpoint's TLS certificate. Required unless insecureSkipVerify is set:
the migration engine refuses a vSphere connection that can neither
verify the endpoint nor was told not to.
This is a CA certificate, not a fingerprint. A SHA-1 thumbprint is what
the engine wants for a direct ESXi host connection, which v1 does not
expose, and supplying one here does not satisfy the check.
type: string
insecureSkipVerify:
description: |-
InsecureSkipVerify disables verification of the endpoint's TLS
certificate. Convenient in a lab and a bad idea anywhere else: the
credentials above travel over that connection. Prefer caCert.
type: boolean
password:
description: Password is the account's password.
minLength: 1
type: string
username:
description: |-
Username is the account used to authenticate against the provider
(e.g. `migration@vsphere.local`).
minLength: 1
type: string
required:
- password
- username
type: object
x-kubernetes-validations:
- message: 'either caCert must be set or insecureSkipVerify
must be true: the migration engine will not open a connection
to the host it can neither verify nor was told to trust'
rule: has(self.caCert) || (has(self.insecureSkipVerify) &&
self.insecureSkipVerify)
id:
description: |-
ID is the host's managed-object reference in the source inventory
(e.g. `host-10`). It is what the VM's inventory record names as its
host, not the hostname.
minLength: 1
type: string
required:
- address
- credentials
- id
type: object
type: array
x-kubernetes-list-map-keys:
- id
x-kubernetes-list-type: map
type:
description: Type is the source platform this connection speaks to.
enum:
- vsphere
type: string
x-kubernetes-validations:
- message: type is immutable
rule: self == oldSelf
url:
description: URL is the provider's API endpoint (e.g. `https://vcenter.example.com/sdk`).
minLength: 1
type: string
required:
- credentials
- type
- url
type: object
status:
description: VMImportSourceStatus reports whether the connection is usable.
properties:
conditions:
description: |-
Conditions represents the latest available observations of the connection's state.
The Ready condition is the connection's readiness: it is true only once the
endpoint has answered and every platform prerequisite for this provider
type is satisfied.
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
observedGeneration:
description: ObservedGeneration is the .metadata.generation the conditions
were computed from.
format: int64
type: integer
type: object
type: object
selectableFields:
- jsonPath: .spec.type
served: true
storage: true
subresources:
status: {}
@@ -0,0 +1,311 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.16.4
options.cozystack.io/source.sourceRef.name: vmimportsource
options.cozystack.io/source.storageClass: storageclass
options.cozystack.io/source.vms.id: vmimportvm.{sourceRef.name}
options.cozystack.io/source.vms.instanceProfile: instanceprofile
options.cozystack.io/source.vms.instanceType: instancetype
name: vmimporttasks.forklift.cozystack.io
spec:
group: forklift.cozystack.io
names:
kind: VMImportTask
listKind: VMImportTaskList
plural: vmimporttasks
shortNames:
- vmit
- vmimporttask
singular: vmimporttask
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.sourceRef.name
name: Source
type: string
- jsonPath: .status.phase
name: Phase
type: string
- jsonPath: .metadata.creationTimestamp
name: Age
type: date
- jsonPath: .status.message
name: Message
priority: 1
type: string
name: v1alpha1
schema:
openAPIV3Schema:
description: |-
VMImportTask runs a one-shot import of VMs from a registered source. It
produces VMDisks and VMInstances that outlive it: deleting the task removes
the migration machinery and leaves everything already imported in place.
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: VMImportTaskSpec describes a one-shot import of one or more
VMs.
properties:
sourceRef:
description: |-
SourceRef names the VMImportSource to import from. It must live in this
task's own namespace.
properties:
name:
default: ""
description: |-
Name of the referent.
This field is effectively required, but due to backwards compatibility is
allowed to be empty. Instances of this type with an empty value here are
almost certainly wrong.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
type: object
x-kubernetes-map-type: atomic
x-kubernetes-validations:
- message: sourceRef is immutable
rule: self == oldSelf
storageClass:
description: |-
StorageClass is the StorageClass every disk of this task is imported
onto. It must bind Immediate: a WaitForFirstConsumer class deadlocks the
import, because nothing consumes the claim while it is populated. Empty
uses the cluster default, which is validated the same way.
type: string
x-kubernetes-validations:
- message: storageClass is immutable
rule: self == oldSelf
vms:
description: |-
VMs lists the source VMs to import. Each produces one VMInstance over
one VMDisk per source disk.
items:
description: VMImportRequest names one source VM to import and what
to call the result.
properties:
id:
description: |-
ID is the VM's managed-object reference in the source inventory
(e.g. `vm-1234` on vSphere). It appears in the vSphere client's URL, and
`govc ls -i` prints it.
minLength: 1
type: string
instanceProfile:
description: |-
InstanceProfile is the Cozystack instance profile (guest preference set)
for the created VMInstance.
type: string
instanceType:
description: |-
InstanceType is the Cozystack instance type for the created VMInstance.
When omitted the controller sets explicit resources matching the source
VM's CPU and memory, so an import never silently resizes a guest.
type: string
name:
description: |-
Name is the name of the VMInstance to create. Its disks are named after
it. Must be a valid Kubernetes object name; defaults to the source VM's
name normalized to one when omitted.
Capped at 46 rather than the usual 63 because the name is not used alone:
each disk becomes `vm-disk-<name>-disk-<i>`, and that whole string is
stamped as the DataVolume's `app.kubernetes.io/instance` label value,
which Kubernetes limits to 63 characters. The fixed parts cost 14
(`vm-disk-` plus `-disk-`), leaving 49 to share between the name and the
disk index; 46 keeps a VM with up to a thousand disks inside the limit.
Overflowing it does not fail cleanly — the DataVolume is simply rejected
after the transfer has already run.
maxLength: 46
pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
type: string
required:
- id
type: object
minItems: 1
type: array
x-kubernetes-validations:
- message: vms is immutable
rule: self == oldSelf
required:
- sourceRef
- vms
type: object
status:
description: VMImportTaskStatus represents the observed state of a VMImportTask.
properties:
completedAt:
description: CompletedAt is the time the task reached a terminal phase.
format: date-time
type: string
conditions:
description: Conditions represents the latest available observations
of the task's state.
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
message:
description: Message is a human-readable message about the current
phase.
type: string
observedGeneration:
description: ObservedGeneration is the .metadata.generation the status
was computed from.
format: int64
type: integer
phase:
description: Phase is a high-level summary of the task's state.
enum:
- ""
- Pending
- Validating
- Transferring
- Creating
- Succeeded
- Failed
type: string
startedAt:
description: StartedAt is the time the task began validating.
format: date-time
type: string
vms:
description: VMs reports per-VM progress, one entry per spec.vms entry.
items:
description: VMImportStatus reports the progress of one VM within
a task.
properties:
disks:
description: Disks lists the names of the created VMDisks, in
the source VM's disk order.
items:
type: string
type: array
id:
description: ID is the source VM's managed-object reference,
matching the request.
type: string
message:
description: Message explains this VM's current phase when it
needs explaining.
type: string
name:
description: Name is the name of the VMInstance being created
for this VM.
type: string
phase:
description: Phase is this VM's own lifecycle phase; a task's
phase aggregates these.
enum:
- ""
- Pending
- Validating
- Transferring
- Creating
- Succeeded
- Failed
type: string
progress:
description: Progress is the percentage of disk data transferred,
0-100.
format: int32
maximum: 100
minimum: 0
type: integer
reason:
description: |-
Reason is a machine-readable cause for this VM's phase, from the same set
the task's conditions use. Message says the same thing to a person; a
caller deciding whether a failure is worth retrying should read this.
type: string
vmInstance:
description: VMInstance is the name of the created VMInstance,
once it exists.
type: string
required:
- id
type: object
type: array
type: object
type: object
x-kubernetes-validations:
- message: 'metadata.name must be at most 63 characters: the task''s name
is carried as a label value on the Forklift objects it owns, and Kubernetes
limits label values to 63'
rule: self.metadata.name.size() <= 63
selectableFields:
- jsonPath: .spec.sourceRef.name
- jsonPath: .status.phase
served: true
storage: true
subresources:
status: {}
@@ -0,0 +1,29 @@
# Pinned to the build host's own architecture, not the target's: the Go
# toolchain cross-compiles perfectly well, and running it under QEMU emulation
# instead makes its garbage collector die with "found bad pointer in Go heap"
# on an arm64 host building for amd64. The output binary is identical either
# way, and a native build is far faster.
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder
ARG TARGETOS
ARG TARGETARCH
WORKDIR /workspace
COPY go.mod go.sum ./
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go mod download
COPY api api/
COPY pkg pkg/
COPY cmd cmd/
COPY internal internal/
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags="-extldflags=-static" -o /migration-controller cmd/migration-controller/main.go
FROM scratch
COPY --from=builder /migration-controller /migration-controller
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
USER 65532:65532
ENTRYPOINT ["/migration-controller"]
@@ -0,0 +1,4 @@
{{- range $path, $_ := .Files.Glob "definitions/*.yaml" }}
---
{{ $.Files.Get $path }}
{{- end }}
@@ -0,0 +1,85 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: migration-controller
labels:
app: migration-controller
spec:
replicas: {{ .Values.migrationController.replicas }}
selector:
matchLabels:
app: migration-controller
template:
metadata:
labels:
app: migration-controller
spec:
tolerations:
- key: "node-role.kubernetes.io/control-plane"
operator: "Exists"
effect: "NoSchedule"
- key: "node-role.kubernetes.io/master"
operator: "Exists"
effect: "NoSchedule"
serviceAccountName: migration-controller
{{- /*
A controller whose whole purpose is to keep an import inside the
restricted Pod Security Standard has no business violating it itself.
The image is a static binary in a scratch layer, so there is nothing to
give up here: without these fields the apiserver admits the pod with a
restricted-violation warning, which is both a bad example and noise in
every operator's audit log.
*/}}
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: migration-controller
image: "{{ .Values.migrationController.image }}"
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
args:
- --leader-elect
{{- if .Values.migrationController.metrics.enabled }}
- --metrics-bind-address={{ .Values.migrationController.metrics.bindAddress }}
{{- end }}
{{- if .Values.migrationController.debug }}
- --zap-log-level=debug
{{- else }}
- --zap-log-level=info
{{- end }}
env:
{{- /*
The VDDK image reference travels as configuration, not as a tenant
value, and only a reference travels — never a credential. Empty is a
valid, documented state: the vSphere path then reports itself
unavailable on each VMImportSource instead of failing mid-transfer.
*/}}
- name: MIGRATION_VDDK_IMAGE
value: {{ .Values.vddkImage | default "" | quote }}
ports:
- name: metrics
containerPort: {{ splitList ":" .Values.migrationController.metrics.bindAddress | mustLast }}
- name: health
containerPort: 8081
readinessProbe:
httpGet:
path: /readyz
port: health
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: health
initialDelaySeconds: 15
periodSeconds: 20
{{- with .Values.migrationController.resources }}
resources: {{- . | toYaml | nindent 10 }}
{{- end }}
@@ -0,0 +1,12 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: forklift.cozystack.io:controller
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: forklift.cozystack.io:controller
subjects:
- kind: ServiceAccount
name: migration-controller
namespace: {{ .Release.Namespace }}
@@ -0,0 +1,80 @@
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: forklift.cozystack.io:controller
rules:
# The API this controller owns. `update` covers finalizers and nothing else:
# a task is held open while the engine's transfer volumes are removed, and
# releasing it means writing metadata.finalizers, which is a write to the object
# rather than to its status. Spec fields are never written — every one of them
# is immutable on admission anyway.
- apiGroups: ["forklift.cozystack.io"]
resources: ["vmimportsources", "vmimporttasks"]
verbs: ["get", "list", "watch", "update"]
- apiGroups: ["forklift.cozystack.io"]
resources: ["vmimportsources/status", "vmimporttasks/status"]
verbs: ["get", "update", "patch"]
# Forklift is the migration engine. The controller renders the whole object
# graph a transfer needs and reads back the verdicts Forklift publishes on it,
# which is why it needs no provider client of its own.
- apiGroups: ["forklift.konveyor.io"]
resources: ["providers", "plans", "migrations", "networkmaps", "storagemaps", "hosts"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# The credentials Secret projected for Forklift to consume, plus the CA of
# Forklift's inventory service, read by name from Forklift's own namespace.
# Deliberately no list or watch: every Secret this controller touches is read by
# name, so a cluster-wide Secret informer is neither needed nor granted.
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "create", "update", "patch", "delete"]
# The machine list a Source publishes for the console's VM picker. It is written
# to a ConfigMap owned by the Source rather than onto its status, so refreshing
# thousands of entries does not wake everything watching the Source.
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
# The copy-free handoff: a transferred volume is retained and re-bound into the
# claim a VMDisk expects. Volumes are cluster-scoped, which is why this role is
# cluster-wide rather than namespaced.
- apiGroups: [""]
resources: ["persistentvolumes"]
verbs: ["get", "list", "watch", "update", "patch"]
- apiGroups: [""]
resources: ["persistentvolumeclaims"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Storage classes are read to reject a binding mode that would deadlock an
# import before anything is created.
- apiGroups: ["storage.k8s.io"]
resources: ["storageclasses"]
verbs: ["get", "list", "watch"]
# CDI DataVolumes: created for the VMDisk release to adopt, and the
# transferring one is removed before its claim is re-pointed.
- apiGroups: ["cdi.kubevirt.io"]
resources: ["datavolumes"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# The KubeVirt VirtualMachine Forklift builds is read for the source VM's shape
# and then discarded. It is never started.
#
# `create` is here even though this controller never creates a VirtualMachine
# itself. Forklift's Plan admission webhook runs a SubjectAccessReview against
# whoever creates the Plan and refuses it unless that identity could create
# VirtualMachines in the Plan's target namespace — Forklift is going to create
# one on their behalf, and it declines to act with more authority than the
# requester has. Without this verb every Plan is denied at admission with
# "Action is forbidden", and the import never starts.
- apiGroups: ["kubevirt.io"]
resources: ["virtualmachines"]
verbs: ["get", "list", "watch", "create", "delete"]
# The imported objects. Create and read only: an import produces tenant
# objects, and nothing here may edit or delete a disk or instance a tenant
# already owns.
- apiGroups: ["apps.cozystack.io"]
resources: ["vmdisks", "vminstances"]
verbs: ["get", "list", "watch", "create"]
# Leader election (--leader-elect) and events.
- apiGroups: ["coordination.k8s.io"]
resources: ["leases"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch"]
@@ -0,0 +1,4 @@
kind: ServiceAccount
apiVersion: v1
metadata:
name: migration-controller
@@ -0,0 +1,55 @@
---
# == migration view cluster role ==
# Aggregated into cozy-tenant-view (and consequently use, admin, super-admin)
# Provides read-only access to the migration API.
#
# Tenant access to a new API group is shipped by the package that owns the
# group, not by a change to platform policy: cozystack-basics defines the
# cozy:tenant:* roles as aggregation roles, so a ClusterRole carrying the label
# below is folded in. This is the same mechanism backup-controller uses for
# backups.cozystack.io.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: cozy:migration:view
labels:
rbac.cozystack.io/aggregate-to-tenant-view: "true"
rules:
- apiGroups: ["forklift.cozystack.io"]
resources:
- vmimportsources
- vmimporttasks
verbs:
- get
- list
- watch
---
# == migration admin cluster role ==
# Aggregated into cozy-tenant-admin (and consequently super-admin)
# Provides write access to both kinds.
#
# Unlike the backup API there is no admin-only class object to withhold here:
# the one platform-scoped setting is the VDDK image, which is platform
# configuration on the controller rather than a CRD a tenant could see. A
# tenant registers their own connection to their own vCenter and runs their own
# imports, which is the whole point of the API.
#
# Note what is NOT granted anywhere: forklift.konveyor.io. The Forklift objects
# a task creates are internal machinery, so a tenant can neither read nor
# tamper with them — they only ever see the migration API's own status.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: cozy:migration:admin
labels:
rbac.cozystack.io/aggregate-to-tenant-admin: "true"
rules:
- apiGroups: ["forklift.cozystack.io"]
resources:
- vmimportsources
- vmimporttasks
verbs:
- create
- update
- patch
- delete
@@ -0,0 +1,153 @@
suite: migration-controller packaging
release:
name: migration-controller
namespace: cozy-migration-controller
tests:
# The VDDK reference is the one piece of platform configuration this
# controller takes. It must arrive as an environment variable rather than a
# flag default baked into the image, so an operator can set it without a
# rebuild — and it must be present even when empty, because empty is a
# meaningful, documented state (the vSphere path reports itself unavailable).
- it: passes the operator-configured VDDK image to the controller
templates:
- templates/deployment.yaml
set:
vddkImage: registry.example.com/vddk:8.0.3
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: MIGRATION_VDDK_IMAGE
value: registry.example.com/vddk:8.0.3
- it: still declares the VDDK variable when the platform has not set one
templates:
- templates/deployment.yaml
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: MIGRATION_VDDK_IMAGE
value: ""
- it: runs with leader election so a second replica cannot race a handoff
templates:
- templates/deployment.yaml
asserts:
- contains:
path: spec.template.spec.containers[0].args
content: --leader-elect
# Both CRDs must ship with the controller that reconciles them: a cluster
# that installs one without the other has an API nothing serves, or a
# controller watching a kind that does not exist.
- it: ships both custom resource definitions
templates:
- templates/crds.yaml
asserts:
- containsDocument:
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
name: vmimportsources.forklift.cozystack.io
any: true
- containsDocument:
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
name: vmimporttasks.forklift.cozystack.io
any: true
# Tenant access to a new API group travels with the package that owns the
# group. Losing these labels does not break anything visibly — it silently
# makes the whole feature invisible to every tenant, which is exactly the
# kind of regression worth a test.
- it: aggregates read access into the tenant view role
templates:
- templates/tenant-clusterroles.yaml
documentSelector:
path: metadata.name
value: cozy:migration:view
asserts:
- equal:
path: metadata.labels["rbac.cozystack.io/aggregate-to-tenant-view"]
value: "true"
- contains:
path: rules[0].resources
content: vmimportsources
- contains:
path: rules[0].resources
content: vmimporttasks
- contains:
path: rules[0].verbs
content: watch
- it: aggregates write access into the tenant admin role
templates:
- templates/tenant-clusterroles.yaml
documentSelector:
path: metadata.name
value: cozy:migration:admin
asserts:
- equal:
path: metadata.labels["rbac.cozystack.io/aggregate-to-tenant-admin"]
value: "true"
- contains:
path: rules[0].verbs
content: create
- contains:
path: rules[0].verbs
content: delete
# Tenants must never reach the Forklift objects a task builds: those are
# internal machinery, and a tenant editing a Plan mid-transfer is a support
# case nobody wants. They see the migration API's status and nothing else.
- it: grants tenants no access to the Forklift API
templates:
- templates/tenant-clusterroles.yaml
asserts:
- notContains:
path: rules[0].apiGroups
content: forklift.konveyor.io
documentSelector:
path: metadata.name
value: cozy:migration:view
- notContains:
path: rules[0].apiGroups
content: forklift.konveyor.io
documentSelector:
path: metadata.name
value: cozy:migration:admin
# The imported disk and instance are the point of the whole operation and
# belong to the tenant from the moment they exist. The controller creates
# them and must not be able to modify or remove them afterwards — that is
# what makes deleting a finished task safe.
- it: lets the controller create imported objects but never delete them
templates:
- templates/rbac.yaml
asserts:
- notContains:
path: rules
content:
apiGroups: ["apps.cozystack.io"]
resources: ["vmdisks", "vminstances"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- contains:
path: rules
content:
apiGroups: ["apps.cozystack.io"]
resources: ["vmdisks", "vminstances"]
verbs: ["get", "list", "watch", "create"]
# No list or watch on Secrets: the controller reads the credentials Secrets it
# owns by name, and a cluster-wide Secret informer would both need RBAC this
# package refuses to grant and hold every tenant's Secret in memory.
- it: never grants list or watch on Secrets
templates:
- templates/rbac.yaml
asserts:
- contains:
path: rules
content:
apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "create", "update", "patch", "delete"]
@@ -0,0 +1,24 @@
migrationController:
image: "ghcr.io/cozystack/cozystack/migration-controller:latest"
replicas: 1
debug: false
metrics:
enabled: true
bindAddress: ":8443"
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
## The VMware Virtual Disk Development Kit image, delivered from the platform.
## Never a tenant value: naming an image the cluster will run is not a tenant's
## decision, and this one is built from a proprietary SDK that Cozystack can
## neither ship nor mirror. An operator holding a VDDK licence builds it, pushes
## it somewhere the cluster can pull from, and sets `vmImport.vddkImage` in the
## platform values, which the iaas bundle forwards into this chart.
## Empty is the normal state and means the vSphere import path is unavailable
## rather than broken: a vSphere VMImportSource reports it on its own status.
vddkImage: ""
+2 -2
View File
@@ -8,7 +8,7 @@ spec:
singular: vminstance
plural: vminstances
openAPISchema: |-
{"title":"Chart Values","type":"object","properties":{"external":{"description":"Enable external access from outside the cluster.","type":"boolean","default":false},"externalMethod":{"description":"Method to pass through traffic to the VM.","type":"string","default":"PortList","enum":["PortList","WholeIP"]},"externalPorts":{"description":"Ports to forward from outside the cluster.","type":"array","default":[22],"items":{"type":"integer"}},"externalAllowICMP":{"description":"Whether to accept ICMP traffic to the VM in PortList mode (preserves ping and PMTU discovery). No effect in WholeIP mode. Default true so ping behaves as users expect even when port filtering is in effect.","type":"boolean","default":true},"runStrategy":{"description":"Requested running state of the VirtualMachineInstance","type":"string","default":"Always","enum":["Always","Halted","Manual","RerunOnFailure","Once"]},"instanceType":{"description":"Virtual Machine instance type.","type":"string","default":"u1.medium","x-cozystack-options":{"source":"instancetype"}},"instanceProfile":{"description":"Virtual Machine preferences profile.","type":"string","default":"ubuntu","x-cozystack-options":{"source":"instanceprofile"}},"disks":{"description":"List of disks to attach.","type":"array","default":[],"items":{"type":"object","required":["name"],"properties":{"bus":{"description":"Disk bus type (e.g. \"sata\").","type":"string"},"name":{"description":"Disk name.","type":"string","x-cozystack-options":{"source":"vmdisk"}}}}},"networks":{"description":"Networks to attach the VM to.","type":"array","default":[],"items":{"type":"object","properties":{"name":{"description":"Network attachment name.","type":"string","x-cozystack-options":{"source":"network"}}}}},"subnets":{"description":"Deprecated: use networks instead.","type":"array","default":[],"items":{"type":"object","properties":{"name":{"description":"Network attachment name.","type":"string","x-cozystack-options":{"source":"network"}}}}},"gpus":{"description":"List of GPUs to attach (NVIDIA driver requires at least 4 GiB RAM).","type":"array","default":[],"items":{"type":"object","required":["name"],"properties":{"name":{"description":"The name of the GPU resource to attach.","type":"string","x-cozystack-options":{"source":"gpu"}}}}},"cpuModel":{"description":"Model specifies the CPU model inside the VMI. List of available models https://github.com/libvirt/libvirt/tree/master/src/cpu_map","type":"string","default":""},"resources":{"description":"Resource configuration for the virtual machine.","type":"object","default":{},"properties":{"cpu":{"description":"Number of CPU cores allocated.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Amount of memory allocated.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"sockets":{"description":"Number of CPU sockets (vCPU topology).","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"sshKeys":{"description":"List of SSH public keys for authentication.","type":"array","default":[],"items":{"type":"string"}},"cloudInit":{"description":"Cloud-init user data.","type":"string","default":""},"cloudInitSeed":{"description":"Seed string to generate SMBIOS UUID for the VM.","type":"string","default":""}}}
{"title":"Chart Values","type":"object","properties":{"external":{"description":"Enable external access from outside the cluster.","type":"boolean","default":false},"externalMethod":{"description":"Method to pass through traffic to the VM.","type":"string","default":"PortList","enum":["PortList","WholeIP"]},"externalPorts":{"description":"Ports to forward from outside the cluster.","type":"array","default":[22],"items":{"type":"integer"}},"externalAllowICMP":{"description":"Whether to accept ICMP traffic to the VM in PortList mode (preserves ping and PMTU discovery). No effect in WholeIP mode. Default true so ping behaves as users expect even when port filtering is in effect.","type":"boolean","default":true},"runStrategy":{"description":"Requested running state of the VirtualMachineInstance","type":"string","default":"Always","enum":["Always","Halted","Manual","RerunOnFailure","Once"]},"instanceType":{"description":"Virtual Machine instance type.","type":"string","default":"u1.medium","x-cozystack-options":{"source":"instancetype"}},"instanceProfile":{"description":"Virtual Machine preferences profile.","type":"string","default":"ubuntu","x-cozystack-options":{"source":"instanceprofile"}},"disks":{"description":"List of disks to attach.","type":"array","default":[],"items":{"type":"object","required":["name"],"properties":{"bus":{"description":"Disk bus type (e.g. \"sata\").","type":"string"},"name":{"description":"Disk name.","type":"string","x-cozystack-options":{"source":"vmdisk"}}}}},"networks":{"description":"Networks to attach the VM to.","type":"array","default":[],"items":{"type":"object","properties":{"name":{"description":"Network attachment name.","type":"string","x-cozystack-options":{"source":"network"}}}}},"subnets":{"description":"Deprecated: use networks instead.","type":"array","default":[],"items":{"type":"object","properties":{"name":{"description":"Network attachment name.","type":"string","x-cozystack-options":{"source":"network"}}}}},"gpus":{"description":"List of GPUs to attach (NVIDIA driver requires at least 4 GiB RAM).","type":"array","default":[],"items":{"type":"object","required":["name"],"properties":{"name":{"description":"The name of the GPU resource to attach.","type":"string","x-cozystack-options":{"source":"gpu"}}}}},"cpuModel":{"description":"Model specifies the CPU model inside the VMI. List of available models https://github.com/libvirt/libvirt/tree/master/src/cpu_map","type":"string","default":""},"firmware":{"description":"Firmware and boot configuration (UEFI/BIOS selection, Secure Boot, persistent EFI NVRAM).","type":"object","default":{},"properties":{"bootloader":{"description":"Bootloader to boot the VM with: \"uefi\" (OVMF) or \"bios\" (SeaBIOS). Empty inherits the instanceProfile default.","type":"string"},"efiPersistent":{"description":"Persist EFI NVRAM (e.g. enrolled Secure Boot keys, such as an updated Microsoft UEFI CA) across reboots. Only applies when bootloader is \"uefi\". On default RWO storage the VM is node-pinned (no live-migration).","type":"boolean"},"secureBoot":{"description":"Enable UEFI Secure Boot. Only applies when bootloader is \"uefi\".","type":"boolean"}}},"resources":{"description":"Resource configuration for the virtual machine.","type":"object","default":{},"properties":{"cpu":{"description":"Number of CPU cores allocated.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Amount of memory allocated.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"sockets":{"description":"Number of CPU sockets (vCPU topology).","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"sshKeys":{"description":"List of SSH public keys for authentication.","type":"array","default":[],"items":{"type":"string"}},"cloudInit":{"description":"Cloud-init user data.","type":"string","default":""},"cloudInitSeed":{"description":"Seed string to generate SMBIOS UUID for the VM.","type":"string","default":""}}}
release:
prefix: vm-instance-
labels:
@@ -26,7 +26,7 @@ spec:
tags:
- compute
icon: PHN2ZyB3aWR0aD0iMTQ0IiBoZWlnaHQ9IjE0NCIgdmlld0JveD0iMCAwIDE0NCAxNDQiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxNDQiIGhlaWdodD0iMTQ0IiByeD0iMjQiIGZpbGw9InVybCgjcGFpbnQwX2xpbmVhcl82ODdfMzQ1NCkiLz4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzY4N18zNDU0KSI+CjxwYXRoIGQ9Ik04OS41MDM5IDExMS43MDdINTQuNDk3QzU0LjE3MjcgMTExLjcwNyA1NC4wMTA4IDExMS4yMjEgNTQuMzM0OSAxMTEuMDU5TDU3LjI1MjIgMTA4Ljk1MkM2MC4zMzE0IDEwNi42ODMgNjEuOTUyMiAxMDIuNjMxIDYwLjk3OTcgOTguNzQxMkg4My4wMjFDODIuMDQ4NSAxMDIuNjMxIDgzLjY2OTMgMTA2LjY4MyA4Ni43NDg1IDEwOC45NTJMODkuNjY1OCAxMTEuMDU5Qzg5Ljk5IDExMS4yMjEgODkuODI3OSAxMTEuNzA3IDg5LjUwMzkgMTExLjcwN1oiIGZpbGw9IiNCMEI2QkIiLz4KPHBhdGggZD0iTTExMy4zMjggOTguNzQxSDMwLjY3MjVDMjcuNTkzMSA5OC43NDEgMjUgOTYuMTQ4IDI1IDkzLjA2ODdWMzMuMTAzMkMyNSAzMC4wMjM5IDI3LjU5MzEgMjcuNDMwNyAzMC42NzI1IDI3LjQzMDdIMTEzLjMyOEMxMTYuNDA3IDI3LjQzMDcgMTE5IDMwLjAyMzcgMTE5IDMzLjEwMzJWOTMuMDY4N0MxMTkgOTYuMTQ4IDExNi40MDcgOTguNzQxIDExMy4zMjggOTguNzQxWiIgZmlsbD0iI0U4RURFRSIvPgo8cGF0aCBkPSJNMTE5IDg0LjE1NDlIMjVWMzMuMTAzMkMyNSAzMC4wMjM5IDI3LjU5MzEgMjcuNDMwNyAzMC42NzI1IDI3LjQzMDdIMTEzLjMyOEMxMTYuNDA3IDI3LjQzMDcgMTE5IDMwLjAyMzcgMTE5IDMzLjEwMzJMMTE5IDg0LjE1NDlaIiBmaWxsPSIjMDBCM0ZGIi8+CjxwYXRoIGQ9Ik05MC42Mzc0IDExNi41NjlINTMuMzYxNkM1Mi4wNjUxIDExNi41NjkgNTAuOTMwNyAxMTUuNDM1IDUwLjkzMDcgMTE0LjEzOEM1MC45MzA3IDExMi44NDEgNTIuMDY1MSAxMTEuNzA3IDUzLjM2MTYgMTExLjcwN0g5MC42Mzc0QzkxLjkzMzkgMTExLjcwNyA5My4wNjg0IDExMi44NDEgOTMuMDY4NCAxMTQuMTM4QzkzLjA2ODQgMTE1LjQzNSA5MS45MzM5IDExNi41NjkgOTAuNjM3NCAxMTYuNTY5WiIgZmlsbD0iI0U4RURFRSIvPgo8L2c+CjxwYXRoIGQ9Ik03Mi41Mjc1IDUzLjgzNjdDNzIuNDQzMSA1My44MzUxIDcyLjM2MDUgNTMuODEyMiA3Mi4yODczIDUzLjc3MDFMNTYuNDY5OSA0NC43OTM0QzU2LjM5ODMgNDQuNzUxOSA1Ni4zMzg4IDQ0LjY5MjMgNTYuMjk3MyA0NC42MjA3QzU2LjI1NTkgNDQuNTQ5IDU2LjIzMzggNDQuNDY3OCA1Ni4yMzM0IDQ0LjM4NUM1Ni4yMzM0IDQ0LjIxNjkgNTYuMzI1OCA0NC4wNjE3IDU2LjQ2OTkgNDMuOTc4NUw3Mi4xOTEyIDM1LjA2MDlDNzIuMjYzNyAzNS4wMjEgNzIuMzQ1IDM1IDcyLjQyNzcgMzVDNzIuNTEwNSAzNSA3Mi41OTE4IDM1LjAyMSA3Mi42NjQzIDM1LjA2MDlMODguNDg3MiA0NC4wMzk1Qzg4LjU1OTEgNDQuMDgwMSA4OC42MTg4IDQ0LjEzOTIgODguNjYgNDQuMjEwN0M4OC43MDEzIDQ0LjI4MjIgODguNzIyNyA0NC4zNjM1IDg4LjcyMTkgNDQuNDQ2Qzg4LjcyMjUgNDQuNTI4NSA4OC43MDEgNDQuNjA5NyA4OC42NTk4IDQ0LjY4MTJDODguNjE4NSA0NC43NTI2IDg4LjU1ODkgNDQuODExOCA4OC40ODcyIDQ0Ljg1MjVMNzIuNzcxNCA1My43NjgzQzcyLjY5NzIgNTMuODExNCA3Mi42MTMzIDUzLjgzNDkgNzIuNTI3NSA1My44MzY3IiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBvcGFjaXR5PSIwLjciIGQ9Ik03MC4yNTUzIDc1LjY1MTdDNzAuMTcxIDc1LjY1MzUgNzAuMDg3OCA3NS42MzE3IDcwLjAxNTEgNzUuNTg4OEw1NC4yNDU4IDY2LjY0MTdDNTQuMTcxNSA2Ni42MDI0IDU0LjEwOTUgNjYuNTQzNiA1NC4wNjYxIDY2LjQ3MTZDNTQuMDIyOCA2Ni4zOTk3IDU0IDY2LjMxNzMgNTQgNjYuMjMzM1Y0OC4yNzhDNTQgNDguMTA4IDU0LjA5MjQgNDcuOTU0NiA1NC4yNDM5IDQ3Ljg2OTZDNTQuMzE3MiA0Ny44MjcxIDU0LjQwMDQgNDcuODA0NyA1NC40ODUxIDQ3LjgwNDdDNTQuNTY5NyA0Ny44MDQ3IDU0LjY1MjkgNDcuODI3MSA1NC43MjYyIDQ3Ljg2OTZMNzAuNDkzNyA1Ni44MTMxQzcwLjU2NDIgNTYuODU2NSA3MC42MjI1IDU2LjkxNyA3MC42NjMyIDU2Ljk4OTFDNzAuNzAzOSA1Ny4wNjEyIDcwLjcyNTcgNTcuMTQyNCA3MC43MjY1IDU3LjIyNTFWNzUuMTgwNUM3MC43MjU5IDc1LjI2MjggNzAuNzA0MiA3NS4zNDM2IDcwLjY2MzUgNzUuNDE1MUM3MC42MjI3IDc1LjQ4NjYgNzAuNTY0MiA3NS41NDY0IDcwLjQ5MzcgNzUuNTg4OEM3MC40MjA2IDc1LjYyOTEgNzAuMzM4NyA3NS42NTA3IDcwLjI1NTMgNzUuNjUxNyIgZmlsbD0id2hpdGUiLz4KPHBhdGggb3BhY2l0eT0iMC40IiBkPSJNNzQuNzE5OCA3NS42NTExQzc0LjYzMzMgNzUuNjUxMiA3NC41NDgyIDc1LjYyOTYgNzQuNDcyMiA3NS41ODgzQzc0LjQwMTYgNzUuNTQ2MSA3NC4zNDMyIDc1LjQ4NjIgNzQuMzAyNyA3NS40MTQ3Qzc0LjI2MjMgNzUuMzQzMSA3NC4yNDExIDc1LjI2MjIgNzQuMjQxMiA3NS4xOFY1Ny4zMzczQzc0LjI0MTIgNTcuMTcxIDc0LjMzMzYgNTcuMDE1OCA3NC40NzIyIDU2LjkyOUw5MC4yMzk3IDQ3Ljk4NTVDOTAuMzExOSA0Ny45NDM4IDkwLjM5MzggNDcuOTIxOSA5MC40NzcxIDQ3LjkyMTlDOTAuNTYwNSA0Ny45MjE5IDkwLjY0MjQgNDcuOTQzOCA5MC43MTQ2IDQ3Ljk4NTVDOTAuNzg3NiA0OC4wMjU1IDkwLjg0ODUgNDguMDg0MiA5MC44OTExIDQ4LjE1NTdDOTAuOTMzNyA0OC4yMjcyIDkwLjk1NjMgNDguMzA4OCA5MC45NTY2IDQ4LjM5MlY2Ni4yMzI4QzkwLjk1NyA2Ni4zMTY0IDkwLjkzNDcgNjYuMzk4NSA5MC44OTIxIDY2LjQ3MDRDOTAuODQ5NSA2Ni41NDI0IDkwLjc4ODEgNjYuNjAxNCA5MC43MTQ2IDY2LjY0MTFMNzQuOTUyNiA3NS41ODgzQzc0Ljg4MjUgNzUuNjMwNyA3NC44MDE4IDc1LjY1MjUgNzQuNzE5OCA3NS42NTExIiBmaWxsPSJ3aGl0ZSIvPgo8ZGVmcz4KPGxpbmVhckdyYWRpZW50IGlkPSJwYWludDBfbGluZWFyXzY4N18zNDU0IiB4MT0iMTYxIiB5MT0iMTgwIiB4Mj0iMy41OTI4NGUtMDciIHkyPSI0Ljk5OTk4IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CjxzdG9wLz4KPHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjNTk1NjU2Ii8+CjwvbGluZWFyR3JhZGllbnQ+CjxjbGlwUGF0aCBpZD0iY2xpcDBfNjg3XzM0NTQiPgo8cmVjdCB3aWR0aD0iOTQiIGhlaWdodD0iOTQiIGZpbGw9IndoaXRlIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgyNSAyNSkiLz4KPC9jbGlwUGF0aD4KPC9kZWZzPgo8L3N2Zz4K
keysOrder: [["apiVersion"], ["appVersion"], ["kind"], ["metadata"], ["metadata", "name"], ["spec", "external"], ["spec", "externalMethod"], ["spec", "externalPorts"], ["spec", "externalAllowICMP"], ["spec", "runStrategy"], ["spec", "instanceType"], ["spec", "instanceProfile"], ["spec", "disks"], ["spec", "networks"], ["spec", "subnets"], ["spec", "gpus"], ["spec", "cpuModel"], ["spec", "resources"], ["spec", "sshKeys"], ["spec", "cloudInit"], ["spec", "cloudInitSeed"]]
keysOrder: [["apiVersion"], ["appVersion"], ["kind"], ["metadata"], ["metadata", "name"], ["spec", "external"], ["spec", "externalMethod"], ["spec", "externalPorts"], ["spec", "externalAllowICMP"], ["spec", "runStrategy"], ["spec", "instanceType"], ["spec", "instanceProfile"], ["spec", "disks"], ["spec", "networks"], ["spec", "subnets"], ["spec", "gpus"], ["spec", "cpuModel"], ["spec", "firmware"], ["spec", "resources"], ["spec", "sshKeys"], ["spec", "cloudInit"], ["spec", "cloudInitSeed"]]
secrets:
exclude: []
include: []
+4 -1
View File
@@ -225,7 +225,10 @@ func (c completedConfig) New() (*CozyServer, error) {
tenantmodulestorage.NewREST(cli, watchCli),
)
coreV1alpha1Storage["options"] = cozyregistry.RESTInPeace(
optionstorage.NewREST(optionstorage.DefaultProviders(dyn)),
optionstorage.NewRESTWithParams(
optionstorage.DefaultProviders(dyn),
optionstorage.DefaultParamProviders(dyn),
),
)
coreV1alpha1Storage["taps"] = cozyregistry.RESTInPeace(
tapstorage.NewREST(dyn),
+82
View File
@@ -7,16 +7,20 @@ package option
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"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/runtime/schema"
"k8s.io/client-go/dynamic"
"k8s.io/klog/v2"
migrationv1alpha1 "github.com/cozystack/cozystack/api/migration/v1alpha1"
corev1alpha1 "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1"
)
@@ -34,6 +38,11 @@ const (
// it.
type providerFunc func(ctx context.Context, namespace string) ([]corev1alpha1.OptionItem, error)
// paramProviderFunc computes the items for a source whose contents depend on a
// choice already made elsewhere in the form — the machines of one import
// source, say, which are meaningless until that source is picked.
type paramProviderFunc func(ctx context.Context, namespace, arg string) ([]corev1alpha1.OptionItem, error)
var (
gvrNodes = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "nodes"}
gvrPVCs = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "persistentvolumeclaims"}
@@ -47,6 +56,8 @@ var (
gvrVMDisks = schema.GroupVersionResource{Group: "apps.cozystack.io", Version: "v1alpha1", Resource: "vmdisks"}
gvrPlans = schema.GroupVersionResource{Group: "backups.cozystack.io", Version: "v1alpha1", Resource: "plans"}
gvrBackups = schema.GroupVersionResource{Group: "backups.cozystack.io", Version: "v1alpha1", Resource: "backups"}
gvrImportSource = schema.GroupVersionResource{Group: "forklift.cozystack.io", Version: "v1alpha1", Resource: "vmimportsources"}
gvrConfigMaps = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "configmaps"}
gvrAppDefs = schema.GroupVersionResource{Group: "cozystack.io", Version: "v1alpha1", Resource: "applicationdefinitions"}
)
@@ -67,6 +78,77 @@ func DefaultProviders(dyn dynamic.Interface) map[string]providerFunc {
"plan": nameListNamespacedProvider(dyn, gvrPlans),
"backup": nameListNamespacedProvider(dyn, gvrBackups),
"appkind": appKindProvider(dyn),
// Source connections a VMImportTask can reference. Namespaced, so a
// tenant only ever sees their own.
"vmimportsource": nameListNamespacedProvider(dyn, gvrImportSource),
}
}
// DefaultParamProviders returns the sources that need an argument. They are
// addressed as `<name>.<argument>` and never appear in a List, because without
// the argument they describe nothing.
func DefaultParamProviders(dyn dynamic.Interface) map[string]paramProviderFunc {
return map[string]paramProviderFunc{
"vmimportvm": importVMProvider(dyn),
}
}
// importVMProvider offers the machines of one import source.
//
// The list is not read from vCenter here. This process has no route to a
// provider's inventory and should not grow one — that would put the source's
// credentials in a second component. Instead the migration controller, which
// already holds an inventory client, publishes what it found into a ConfigMap
// owned by the source, and this reads it back as an ordinary object.
//
// An absent or empty ConfigMap yields no items rather than an error: a source
// that was just created has not published yet, and an empty dropdown says that
// more usefully than a failure does.
func importVMProvider(dyn dynamic.Interface) paramProviderFunc {
return func(ctx context.Context, namespace, source string) ([]corev1alpha1.OptionItem, error) {
if namespace == "" || source == "" {
return nil, nil
}
cm, err := dyn.Resource(gvrConfigMaps).Namespace(namespace).
Get(ctx, migrationv1alpha1.InventoryConfigMapName(source), metav1.GetOptions{})
if err != nil {
if apierrors.IsNotFound(err) {
return nil, nil
}
return nil, err
}
raw, found, err := unstructured.NestedString(cm.Object, "data", migrationv1alpha1.InventoryVMsKey)
if err != nil || !found || raw == "" {
return nil, nil
}
var vms []migrationv1alpha1.PublishedVM
if err := json.Unmarshal([]byte(raw), &vms); err != nil {
return nil, fmt.Errorf("decoding the published VM list for source %q: %w", source, err)
}
items := make([]corev1alpha1.OptionItem, 0, len(vms))
for _, vm := range vms {
if vm.ID == "" {
continue
}
// The name is what a person recognises and the reference is what the
// task records, so the label carries both. Two machines can share a
// name in different folders, and the reference is also the only
// thing a saved task shows afterwards -- a list of bare names would
// be ambiguous to pick from and impossible to check against.
label := vm.Name
if label != "" {
label = fmt.Sprintf("%s (%s)", vm.Name, vm.ID)
}
items = append(items, corev1alpha1.OptionItem{
Value: vm.ID,
Label: label,
Description: vm.Path,
})
}
sort.Slice(items, func(i, j int) bool { return items[i].Label < items[j].Label })
return items, nil
}
}
@@ -35,6 +35,8 @@ func listKinds() map[schema.GroupVersionResource]string {
gvrHelmReleases: "HelmReleaseList",
gvrStorageClass: "StorageClassList",
gvrVMDisks: "VMDiskList",
gvrImportSource: "VMImportSourceList",
gvrConfigMaps: "ConfigMapList",
}
}
+35 -4
View File
@@ -11,6 +11,7 @@ import (
"fmt"
"net/http"
"sort"
"strings"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metainternal "k8s.io/apimachinery/pkg/apis/meta/internalversion"
@@ -35,16 +36,32 @@ var (
_ rest.SingularNameProvider = &REST{}
)
// paramSeparator divides a parameterised source name from its argument, as in
// `vmimportvm.my-source`. A dot is legal in a resource name, so the whole thing
// still addresses as an ordinary object.
const paramSeparator = "."
// REST implements the read-only Option resource.
type REST struct {
providers map[string]providerFunc
gvr schema.GroupVersionResource
// paramProviders answer sources whose contents depend on a value the caller
// already chose — the VMs of one import source, say. They are addressed as
// `<source><paramSeparator><argument>` and are deliberately absent from
// List: without an argument they name nothing.
paramProviders map[string]paramProviderFunc
gvr schema.GroupVersionResource
}
// NewREST builds the Option REST storage from a provider registry.
func NewREST(providers map[string]providerFunc) *REST {
return NewRESTWithParams(providers, nil)
}
// NewRESTWithParams builds the storage from both registries.
func NewRESTWithParams(providers map[string]providerFunc, params map[string]paramProviderFunc) *REST {
return &REST{
providers: providers,
providers: providers,
paramProviders: params,
gvr: schema.GroupVersionResource{
Group: corev1alpha1.GroupName,
Version: "v1alpha1",
@@ -102,11 +119,25 @@ func (r *REST) List(ctx context.Context, _ *metainternal.ListOptions) (runtime.O
}
func (r *REST) Get(ctx context.Context, name string, _ *metav1.GetOptions) (runtime.Object, error) {
ns, _ := request.NamespaceFrom(ctx)
provider, ok := r.providers[name]
if !ok {
return nil, apierrors.NewNotFound(r.gvr.GroupResource(), name)
// A parameterised source: everything up to the first separator names
// the provider, the rest is its argument.
base, arg, found := strings.Cut(name, paramSeparator)
param, known := r.paramProviders[base]
if !found || arg == "" || !known {
return nil, apierrors.NewNotFound(r.gvr.GroupResource(), name)
}
items, err := param(ctx, ns, arg)
if err != nil {
return nil, apierrors.NewInternalError(fmt.Errorf("compute options for %q: %w", name, err))
}
opt := r.makeOption(name, ns, items)
return &opt, nil
}
ns, _ := request.NamespaceFrom(ctx)
items, err := provider(ctx, ns)
if err != nil {
return nil, apierrors.NewInternalError(fmt.Errorf("compute options for %q: %w", name, err))
+169
View File
@@ -0,0 +1,169 @@
// SPDX-License-Identifier: Apache-2.0
package option
import (
"context"
"testing"
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/runtime/schema"
"k8s.io/apiserver/pkg/endpoints/request"
dynamicfake "k8s.io/client-go/dynamic/fake"
migrationv1alpha1 "github.com/cozystack/cozystack/api/migration/v1alpha1"
corev1alpha1 "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1"
)
func inventoryConfigMap(namespace, source, payload string) *unstructured.Unstructured {
o := &unstructured.Unstructured{}
o.SetGroupVersionKind(schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"})
o.SetNamespace(namespace)
o.SetName(migrationv1alpha1.InventoryConfigMapName(source))
_ = unstructured.SetNestedStringMap(o.Object,
map[string]string{migrationv1alpha1.InventoryVMsKey: payload}, "data")
return o
}
func fakeDynWithConfigMaps(objs ...runtime.Object) *dynamicfake.FakeDynamicClient {
return dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), listKinds(), objs...)
}
// The picker has to show a name and write a managed-object reference; getting
// that pairing backwards produces a form that looks right and imports nothing.
func TestImportVMProviderShowsNamesAndWritesRefs(t *testing.T) {
dyn := fakeDynWithConfigMaps(inventoryConfigMap("tenant-a", "vcenter",
`[{"id":"vm-52","name":"web-01","path":"/dc/vm/web-01"},{"id":"vm-7","name":"db-01"}]`))
items, err := DefaultParamProviders(dyn)["vmimportvm"](context.Background(), "tenant-a", "vcenter")
if err != nil {
t.Fatalf("provider returned %v", err)
}
if len(items) != 2 {
t.Fatalf("got %d items, want 2", len(items))
}
// Sorted by the label a person reads, not by the reference. The label shows
// both, because the reference is all a saved task displays afterwards.
if items[0].Label != "db-01 (vm-7)" || items[0].Value != "vm-7" {
t.Errorf("first item = %+v, want db-01 (vm-7)/vm-7", items[0])
}
if items[1].Label != "web-01 (vm-52)" || items[1].Value != "vm-52" {
t.Errorf("second item = %+v, want web-01 (vm-52)/vm-52", items[1])
}
if items[1].Description != "/dc/vm/web-01" {
t.Errorf("path not carried: %q", items[1].Description)
}
}
// A machine the inventory gave no name for still has to be pickable: an empty
// label makes the widget fall back to showing the reference itself, where
// composing one would render a stray "(vm-9)" with nothing in front of it.
func TestImportVMProviderLeavesANamelessMachineToTheWidget(t *testing.T) {
dyn := fakeDynWithConfigMaps(inventoryConfigMap("tenant-a", "vcenter",
`[{"id":"vm-9","name":""}]`))
items, err := DefaultParamProviders(dyn)["vmimportvm"](context.Background(), "tenant-a", "vcenter")
if err != nil {
t.Fatalf("provider returned %v", err)
}
if len(items) != 1 || items[0].Label != "" || items[0].Value != "vm-9" {
t.Errorf("items = %+v", items)
}
}
// A source that has not published yet is a normal state, not a failure: the
// dropdown is empty and the form still renders.
func TestImportVMProviderToleratesAnUnpublishedSource(t *testing.T) {
dyn := fakeDynWithConfigMaps()
items, err := DefaultParamProviders(dyn)["vmimportvm"](context.Background(), "tenant-a", "vcenter")
if err != nil {
t.Fatalf("provider returned %v", err)
}
if len(items) != 0 {
t.Errorf("got %d items, want none", len(items))
}
}
// Each source answers with its own machines. Serving one tenant's list for
// another source is the failure this addressing scheme exists to prevent.
func TestImportVMProviderIsScopedToItsArgument(t *testing.T) {
dyn := fakeDynWithConfigMaps(
inventoryConfigMap("tenant-a", "vcenter-one", `[{"id":"vm-1","name":"one"}]`),
inventoryConfigMap("tenant-a", "vcenter-two", `[{"id":"vm-2","name":"two"}]`),
)
provider := DefaultParamProviders(dyn)["vmimportvm"]
for _, tc := range []struct{ source, want string }{
{"vcenter-one", "vm-1"},
{"vcenter-two", "vm-2"},
} {
items, err := provider(context.Background(), "tenant-a", tc.source)
if err != nil {
t.Fatalf("%s: %v", tc.source, err)
}
if len(items) != 1 || items[0].Value != tc.want {
t.Errorf("%s: got %v, want [%s]", tc.source, values(items), tc.want)
}
}
}
// The whole point of the parameterised name: `<source>.<argument>` reaches the
// provider with the argument intact.
func TestGetRoutesAParameterisedName(t *testing.T) {
dyn := fakeDynWithConfigMaps(inventoryConfigMap("tenant-a", "vcenter",
`[{"id":"vm-52","name":"web-01"}]`))
r := NewRESTWithParams(DefaultProviders(dyn), DefaultParamProviders(dyn))
ctx := request.WithNamespace(context.Background(), "tenant-a")
obj, err := r.Get(ctx, "vmimportvm.vcenter", &metav1.GetOptions{})
if err != nil {
t.Fatalf("Get returned %v", err)
}
opt, ok := obj.(*corev1alpha1.Option)
if !ok {
t.Fatalf("Get returned %T", obj)
}
if len(opt.Spec.Items) != 1 || opt.Spec.Items[0].Value != "vm-52" {
t.Errorf("items = %v", values(opt.Spec.Items))
}
}
// Without an argument the name describes nothing, and answering it with an
// empty list would look like a source with no machines.
func TestGetRejectsAParameterisedNameWithoutAnArgument(t *testing.T) {
dyn := fakeDynWithConfigMaps()
r := NewRESTWithParams(DefaultProviders(dyn), DefaultParamProviders(dyn))
ctx := request.WithNamespace(context.Background(), "tenant-a")
for _, name := range []string{"vmimportvm", "vmimportvm.", "nosuch.arg"} {
if _, err := r.Get(ctx, name, &metav1.GetOptions{}); err == nil {
t.Errorf("Get(%q) succeeded, want NotFound", name)
}
}
}
// A parameterised source must stay out of List: the console iterates it to fill
// every static dropdown, and an entry that needs an argument has no contents
// there.
func TestListOmitsParameterisedSources(t *testing.T) {
dyn := fakeDynWithConfigMaps()
r := NewRESTWithParams(DefaultProviders(dyn), DefaultParamProviders(dyn))
ctx := request.WithNamespace(context.Background(), "tenant-a")
obj, err := r.List(ctx, nil)
if err != nil {
t.Fatalf("List returned %v", err)
}
list, ok := obj.(*corev1alpha1.OptionList)
if !ok {
t.Fatalf("List returned %T", obj)
}
for _, o := range list.Items {
if o.Name == "vmimportvm" {
t.Fatalf("parameterised source appeared in List")
}
}
}