Files
Andrei Kvapil 54621bc046 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>
2026-09-07 17:03:44 +02:00

204 lines
7.3 KiB
Go

/*
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)
}
}