Repository: FuturFusion/migration-manager
Pull request #134: Build migration backup volume
Author: login: masnax
Created: 2025-02-27T07:01:22Z | Merged: 2025-03-06T22:49:23Z

Closes #126
Closes #87 

On batch start, creates `<target>_<pool>_<version>_worker.tar.gz` for each newly encountered target & storage pool combo, and imports it as a storage pool volume backup on the Incus target, if it doesn't exist already.
* If the volume already exists, it will be re-used, without creating a new tarball to import.
* If the tarball for the current migration-manager version already exists for the given pool in the given target, it will be re-used, without creating a new image with the VMware VIX tarball.
* If the raw image for the current migration-manager already exists, it will not be re-fetched from github.

Linked issue #126: Complete worker setup
Now that we have the `mkosi` based `worker` image in the repository, the next stage needed is for `migration-manager` to consume that.

It needs to:
 - Look for / Download the `migration-manager-worker.raw` file (currently available as a Github Actions result, but we'll make it easier to fetch as part of a tag/release on Github)
 - Look for a user downloaded VMware VIX tarball
 - Write the VIX tarball at the 616448*512 offset in the image, for example with `dd`: `sudo dd if=VMware-vix-disklib-8.0.3-23950268.x86_64.tar.gz of=migration-manager-worker.raw seek=616448 bs=512 conv=notrunc`
 - Turn the resulting raw image into a custom volume backup file (see `worker/pack-image` for shell version of that)
 - Import the resulting compressed backup file into Incus

After that, the rest works the same as with the current ISO image.
The volume gets added with a high priority, the VM boots into that tiny image, a systemd unit unpacks the VIX tarball into /opt and we have a working `incus-agent` so we can transfer the worker binary and start the migration.
Linked issue comments: user: login: stgraber
created_at: 2025-02-17T20:16:42Z
body: I'm going to put together a release Github Action which will automatically attach a bunch of stuff to a newly created tag/release on Github. That will allow us to have some pre-release tags that give us all the artifacts we want for installation.
author_association: COLLABORATOR
user: login: stgraber
created_at: 2025-02-17T21:06:41Z
body: https://github.com/FuturFusion/migration-manager/releases/tag/v0.0.0-pre.1
author_association: COLLABORATOR

Diff:
diff --git a/cmd/migration-managerd/internal/api/daemon.go b/cmd/migration-managerd/internal/api/daemon.go
index 47fe7757..0afd7538 100644
--- a/cmd/migration-managerd/internal/api/daemon.go
+++ b/cmd/migration-managerd/internal/api/daemon.go
@@ -27,6 +27,7 @@ import (
 	"github.com/FuturFusion/migration-manager/internal/server/sys"
 	tlsutil "github.com/FuturFusion/migration-manager/internal/server/util"
 	"github.com/FuturFusion/migration-manager/internal/transaction"
+	"github.com/FuturFusion/migration-manager/internal/util"
 	"github.com/FuturFusion/migration-manager/internal/version"
 )
 
@@ -66,6 +67,8 @@ type Daemon struct {
 	errgroup *errgroup.Group
 	config   *config.DaemonConfig
 
+	batchLock util.IDLock[string]
+
 	ShutdownCtx    context.Context    // Canceled when shutdown starts.
 	ShutdownCancel context.CancelFunc // Cancels the shutdownCtx to indicate shutdown starting.
 	ShutdownDoneCh chan error         // Receives the result of the d.Stop() function and tells the daemon to end.
@@ -78,6 +81,7 @@ func NewDaemon(cfg *config.DaemonConfig) *Daemon {
 		db:             &db.Node{},
 		os:             sys.DefaultOS(),
 		config:         cfg,
+		batchLock:      util.NewIDLock[string](),
 		ShutdownCtx:    shutdownCtx,
 		ShutdownCancel: shutdownCancel,
 		ShutdownDoneCh: make(chan error),
diff --git a/cmd/migration-managerd/internal/api/workers.go b/cmd/migration-managerd/internal/api/workers.go
index 445e0f83..d5a13bb5 100644
--- a/cmd/migration-managerd/internal/api/workers.go
+++ b/cmd/migration-managerd/internal/api/workers.go
@@ -4,14 +4,11 @@ import (
 	"context"
 	"fmt"
 	"log/slog"
-	"net/http"
 	"path/filepath"
 	"slices"
-	"strings"
 	"time"
 
 	"github.com/google/uuid"
-	incusAPI "github.com/lxc/incus/v6/shared/api"
 	"github.com/lxc/incus/v6/shared/revert"
 	incusUtil "github.com/lxc/incus/v6/shared/util"
 
@@ -561,19 +558,7 @@ func (d *Daemon) ensureISOImagesExistInStoragePool(ctx context.Context, t migrat
 		slog.String("storage_pool", storagePool),
 	)
 
-	// Determine the ISO names.
-	workerISOName, err := d.os.GetMigrationManagerISOName()
-	if err != nil {
-		return err
-	}
-
-	workerISOPath := filepath.Join(d.os.CacheDir, workerISOName)
-	workerISOExists := incusUtil.PathExists(workerISOPath)
-	if !workerISOExists {
-		return fmt.Errorf("Worker ISO not found at %q", workerISOPath)
-	}
-
-	importISOs := []string{workerISOName}
+	importISOs := []string{}
 	for _, inst := range instances {
 		if inst.GetOSType() == api.OSTYPE_WINDOWS {
 			driverISOName, err := d.os.GetVirtioDriversISOName()
@@ -603,6 +588,14 @@ func (d *Daemon) ensureISOImagesExistInStoragePool(ctx context.Context, t migrat
 		return err
 	}
 
+	reverter := revert.New()
+	defer reverter.Fail()
+
+	// Key the batch by its constituent parts, as batches with different IDs may share the same target, pool, and project.
+	batchKey := t.Name + "_" + storagePool + "_" + project
+	d.batchLock.Lock(batchKey)
+	reverter.Add(func() { d.batchLock.Unlock(batchKey) })
+
 	// Connect to the target.
 	err = it.Connect(ctx)
 	if err != nil {
@@ -615,12 +608,35 @@ func (d *Daemon) ensureISOImagesExistInStoragePool(ctx context.Context, t migrat
 		return err
 	}
 
+	volumes, err := it.GetStoragePoolVolumeNames(storagePool)
+	if err != nil {
+		return err
+	}
+
+	if !slices.Contains(volumes, "custom/"+util.WorkerVolume()) {
+		err = d.os.LoadWorkerImage(ctx)
+		if err != nil {
+			return err
+		}
+
+		ops, err := it.CreateStoragePoolVolumeFromBackup(storagePool, filepath.Join(d.os.CacheDir, util.RawWorkerImage()))
+		if err != nil {
+			return err
+		}
+
+		for _, op := range ops {
+			err = op.Wait()
+			if err != nil {
+				return err
+			}
+		}
+	}
+
 	// Verify needed ISO images are in the storage pool.
 	for _, iso := range importISOs {
 		log := log.With(slog.String("iso", iso))
 
-		_, _, err = it.GetStoragePoolVolume(storagePool, "custom", iso)
-		if err != nil && incusAPI.StatusErrorCheck(err, http.StatusNotFound) {
+		if !slices.Contains(volumes, "custom/"+iso) {
 			log.Info("ISO image doesn't exist in storage pool, importing...")
 
 			op, err := it.CreateStoragePoolVolumeFromISO(storagePool, filepath.Join(d.os.CacheDir, iso))
@@ -639,6 +655,9 @@ func (d *Daemon) ensureISOImagesExistInStoragePool(ctx context.Context, t migrat
 		}
 	}
 
+	d.batchLock.Unlock(batchKey)
+	reverter.Success()
+
 	return nil
 }
 
@@ -774,12 +793,6 @@ func (d *Daemon) createTargetVMs(ctx context.Context, b migration.Batch, instanc
 			return fmt.Errorf("Failed to set project %q for target %q: %w", b.TargetProject, it.GetName(), err)
 		}
 
-		// Create the instance.
-		workerISOName, err := d.os.GetMigrationManagerISOName()
-		if err != nil {
-			return fmt.Errorf("Failed to get worker ISO path: %w", err)
-		}
-
 		var driverISOName string
 		if inst.GetOSType() == api.OSTYPE_WINDOWS {
 			driverISOName, err = d.os.GetVirtioDriversISOName()
@@ -800,7 +813,7 @@ func (d *Daemon) createTargetVMs(ctx context.Context, b migration.Batch, instanc
 			})
 		}
 
-		err = it.CreateNewVM(instanceDef, b.StoragePool, workerISOName, driverISOName)
+		err = it.CreateNewVM(instanceDef, b.StoragePool, util.WorkerVolume(), driverISOName)
 		if err != nil {
 			return fmt.Errorf("Failed to create new instance %q on migration target %q: %w", instanceDef.Name, it.GetName(), err)
 		}
@@ -961,86 +974,9 @@ func (d *Daemon) configureMigratedInstances(ctx context.Context, instances migra
 			return fmt.Errorf("Failed to set target %q project %q: %w", it.GetName(), batch.TargetProject, err)
 		}
 
-		// Stop the instance.
-		err = it.StopVM(i.GetName(), true)
-		if err != nil {
-			return fmt.Errorf("Failed to stop instance %q on target %q: %w", i.GetName(), it.GetName(), err)
-		}
-
-		// Get the instance definition.
-		apiDef, _, err := it.GetInstance(i.GetName())
-		if err != nil {
-			return fmt.Errorf("Failed to get configuration for instance %q on target %q: %w", i.GetName(), it.GetName(), err)
-		}
-
-		for idx, nic := range i.NICs {
-			nicDeviceName := fmt.Sprintf("eth%d", idx)
-			baseNetwork, ok := allNetworks[nic.Network]
-			if !ok {
-				err = fmt.Errorf("No network %q associated with instance %q on target %q", nic.Network, i.GetName(), it.GetName())
-				return err
-			}
-
-			// Pickup device name override if set.
-			deviceName, ok := baseNetwork.Config["name"]
-			if ok {
-				nicDeviceName = deviceName
-			}
-
-			// Copy the base network definitions.
-			apiDef.Devices[nicDeviceName] = make(map[string]string, len(baseNetwork.Config))
-			for k, v := range baseNetwork.Config {
-				apiDef.Devices[nicDeviceName][k] = v
-			}
-
-			// Set a few forced overrides.
-			apiDef.Devices[nicDeviceName]["type"] = "nic"
-			apiDef.Devices[nicDeviceName]["name"] = nicDeviceName
-			apiDef.Devices[nicDeviceName]["hwaddr"] = nic.Hwaddr
-		}
-
-		// Remove the migration ISO image.
-		delete(apiDef.Devices, "migration-iso")
-
-		// Don't set any profiles by default.
-		apiDef.Profiles = []string{}
-
-		// Handle Windows-specific completion steps.
-		if strings.Contains(apiDef.Config["image.os"], "swodniw") {
-			// Remove the drivers ISO image.
-			delete(apiDef.Devices, "drivers")
-
-			// Fixup the OS name.
-			apiDef.Config["image.os"] = strings.Replace(apiDef.Config["image.os"], "swodniw", "windows", 1)
-		}
-
-		// Handle RHEL (and derivative) specific completion steps.
-		if util.IsRHELOrDerivative(apiDef.Config["image.os"]) {
-			// RHEL7+ don't support 9p, so make agent config available via cdrom.
-			apiDef.Devices["agent"] = map[string]string{
-				"type":   "disk",
-				"source": "agent:config",
-			}
-		}
-
-		// Set the instance's UUID copied from the source.
-		apiDef.Config["volatile.uuid"] = i.UUID.String()
-		apiDef.Config["volatile.uuid.generation"] = i.UUID.String()
-
-		// Update the instance in Incus.
-		op, err := it.UpdateInstance(i.GetName(), apiDef.Writable(), "")
-		if err != nil {
-			return fmt.Errorf("Failed to update instance %q on target %q: %w", i.GetName(), it.GetName(), err)
-		}
-
-		err = op.Wait()
-		if err != nil {
-			return fmt.Errorf("Failed to wait for update to instance %q on target %q: %w", i.GetName(), it.GetName(), err)
-		}
-
-		err = it.StartVM(i.GetName())
+		err = it.SetPostMigrationVMConfig(i, allNetworks)
 		if err != nil {
-			return fmt.Errorf("Failed to start instance %q on target %q: %w", i.GetName(), it.GetName(), err)
+			return fmt.Errorf("Failed to update post-migration config for instance %q in %q: %w", i.GetName(), it.GetName(), err)
 		}
 
 		// Update the instance status.
diff --git a/go.mod b/go.mod
index bdc18581..160b3023 100644
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,7 @@ require (
 	github.com/expr-lang/expr v1.16.9
 	github.com/flosch/pongo2/v4 v4.0.2
 	github.com/fvbommel/sortorder v1.1.0
+	github.com/google/go-github/v69 v69.2.0
 	github.com/google/uuid v1.6.0
 	github.com/gosimple/slug v1.14.0
 	github.com/hexdigest/gowrap v1.4.1
@@ -54,6 +55,7 @@ require (
 	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 // indirect
 	github.com/google/cel-go v0.22.1 // indirect
+	github.com/google/go-querystring v1.1.0 // indirect
 	github.com/gorilla/securecookie v1.1.2 // indirect
 	github.com/gorilla/websocket v1.5.3 // indirect
 	github.com/gosimple/unidecode v1.0.1 // indirect
diff --git a/go.sum b/go.sum
index e91f76e1..3ffcb5c8 100644
--- a/go.sum
+++ b/go.sum
@@ -104,8 +104,13 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6
 github.com/google/cel-go v0.22.1 h1:AfVXx3chM2qwoSbM7Da8g8hX8OVSkBFwX+rz2+PcK40=
 github.com/google/cel-go v0.22.1/go.mod h1:BuznPXXfQDpXKWQ9sPW3TzlAJN5zzFe+i9tIs0yC4s8=
 github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
+github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-github/v69 v69.2.0 h1:wR+Wi/fN2zdUx9YxSmYE0ktiX9IAR/BeePzeaUUbEHE=
+github.com/google/go-github/v69 v69.2.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM=
+github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
+github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
diff --git a/internal/migratekit/nbdcopy/nbdcopy.go b/internal/migratekit/nbdcopy/nbdcopy.go
index a313cc7b..7fbe8e98 100644
--- a/internal/migratekit/nbdcopy/nbdcopy.go
+++ b/internal/migratekit/nbdcopy/nbdcopy.go
@@ -2,6 +2,7 @@ package nbdcopy
 
 import (
 	"bufio"
+	"bytes"
 	"fmt"
 	"os"
 	"os/exec"
@@ -15,6 +16,7 @@ import (
 
 func Run(source, destination string, size int64, targetIsClean bool, diskName string, statusCallback func(string, bool)) error {
 	logger := log.WithFields(log.Fields{
+		"command":     "nbdcopy",
 		"source":      source,
 		"destination": destination,
 	})
@@ -39,11 +41,21 @@ func Run(source, destination string, size int64, targetIsClean bool, diskName st
 		"nbdcopy",
 		args...,
 	)
-	cmd.Stdout = os.Stdout
-	cmd.Stderr = os.Stderr
+
+	stdout := bytes.Buffer{}
+	stderr := bytes.Buffer{}
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
 	cmd.ExtraFiles = []*os.File{progressWrite}
 
-	logger.Debug("Running command: ", cmd)
+	logger.Info("Running command: ", cmd)
+	defer func() {
+		log.Debug("stdout", stdout.String())
+		if len(stderr.String()) > 0 {
+			log.Error("stderr", stderr.String())
+		}
+	}()
+
 	if err := cmd.Start(); err != nil {
 		return err
 	}
diff --git a/internal/migratekit/nbdkit/builder.go b/internal/migratekit/nbdkit/builder.go
index 890a858f..83bda7a0 100644
--- a/internal/migratekit/nbdkit/builder.go
+++ b/internal/migratekit/nbdkit/builder.go
@@ -79,7 +79,6 @@ func (b *NbdkitBuilder) Build() (*NbdkitServer, error) {
 	socket := fmt.Sprintf("%s/nbdkit.sock", tmp)
 	pidFile := fmt.Sprintf("%s/nbdkit.pid", tmp)
 
-	os.Setenv("LD_LIBRARY_PATH", "/usr/lib64/vmware-vix-disklib/lib64")
 	cmd := exec.Command(
 		"nbdkit",
 		"--exit-with-parent",
@@ -95,6 +94,7 @@ func (b *NbdkitBuilder) Build() (*NbdkitServer, error) {
 		fmt.Sprintf("compression=%s", b.compression),
 		fmt.Sprintf("vm=moref=%s", b.vm),
 		fmt.Sprintf("snapshot=%s", b.snapshot),
+		"libdir=/opt/vmware-vix-disklib-distrib",
 		"transports=file:nbdssl:nbd",
 		b.filename,
 	)
diff --git a/internal/migratekit/nbdkit/server.go b/internal/migratekit/nbdkit/server.go
index fc3d37dc..b3cfb49a 100644
--- a/internal/migratekit/nbdkit/server.go
+++ b/internal/migratekit/nbdkit/server.go
@@ -1,6 +1,7 @@
 package nbdkit
 
 import (
+	"bytes"
 	"fmt"
 	"os"
 	"os/exec"
@@ -16,10 +17,20 @@ type NbdkitServer struct {
 }
 
 func (s *NbdkitServer) Start() error {
-	s.cmd.Stdout = os.Stdout
-	s.cmd.Stderr = os.Stderr
+	log := log.WithFields(log.Fields{"command": "nbdkit"})
+	stdout := bytes.Buffer{}
+	stderr := bytes.Buffer{}
+	s.cmd.Stdout = &stdout
+	s.cmd.Stderr = &stderr
+
+	log.Info("Running command: ", s.cmd)
+	defer func() {
+		log.Debug("stdout", stdout.String())
+		if len(stderr.String()) > 0 {
+			log.Error("stderr", stderr.String())
+		}
+	}()
 
-	log.Debug("Running command: ", s.cmd)
 	if err := s.cmd.Start(); err != nil {
 		return fmt.Errorf("failed to start nbdkit server: %w", err)
 	}
diff --git a/internal/server/sys/os.go b/internal/server/sys/os.go
index 8ba014fe..10bed42c 100644
--- a/internal/server/sys/os.go
+++ b/internal/server/sys/os.go
@@ -1,7 +1,9 @@
 package sys
 
 import (
+	"context"
 	"fmt"
+	"io"
 	"os"
 	"path/filepath"
 
@@ -44,30 +46,85 @@ func (s *OS) LocalDatabaseDir() string {
 	return filepath.Join(s.VarDir, "database")
 }
 
-// Returns the name of the migration manger worker ISO image.
-func (s *OS) GetMigrationManagerISOName() (string, error) {
-	files, err := filepath.Glob(fmt.Sprintf("%s/migration-manager-minimal-boot*.iso", s.CacheDir))
+// Returns the name of the virtio drivers ISO image.
+func (s *OS) GetVirtioDriversISOName() (string, error) {
+	files, err := filepath.Glob(fmt.Sprintf("%s/virtio-win-*.iso", s.CacheDir))
 	if err != nil {
 		return "", err
 	}
 
 	if len(files) != 1 {
-		return "", fmt.Errorf("Unable to determine migration manager ISO name")
+		return "", fmt.Errorf("Unable to determine virtio drivers ISO name")
 	}
 
 	return filepath.Base(files[0]), nil
 }
 
-// Returns the name of the virtio drivers ISO image.
-func (s *OS) GetVirtioDriversISOName() (string, error) {
-	files, err := filepath.Glob(fmt.Sprintf("%s/virtio-win-*.iso", s.CacheDir))
+// GetVMWareVixName returns the name of the VMWare vix disklib tarball.
+func (s *OS) GetVMwareVixName() (string, error) {
+	files, err := filepath.Glob(filepath.Join(s.CacheDir, "VMware-vix-disklib*.tar.gz"))
 	if err != nil {
-		return "", err
+		return "", fmt.Errorf("Failed to find VMware vix tarball in %q: %w", s.CacheDir, err)
 	}
 
 	if len(files) != 1 {
-		return "", fmt.Errorf("Unable to determine virtio drivers ISO name")
+		return "", fmt.Errorf("Failed to find exactly one VMWare vix tarball in %q (Found %d)", s.CacheDir, len(files))
 	}
 
 	return filepath.Base(files[0]), nil
 }
+
+// LoadWorkerImage writes the VMWare vix tarball to the worker image.
+// If the worker image does not exist, it is fetched from the current project version's corresponding GitHub release.
+func (s *OS) LoadWorkerImage(ctx context.Context) error {
+	vixName, err := s.GetVMwareVixName()
+	if err != nil {
+		return err
+	}
+
+	rawWorkerPath := filepath.Join(s.CacheDir, util.RawWorkerImage())
+	_, err = os.Stat(rawWorkerPath)
+	if err != nil && !os.IsNotExist(err) {
+		return err
+	}
+
+	// If the image doesn't exist yet, then download it from GitHub.
+	if err != nil {
+		g, err := util.GetProjectRepo(ctx, false)
+		if err != nil {
+			return err
+		}
+
+		err = g.DownloadAsset(ctx, rawWorkerPath, "migration-manager-worker.img.gz")
+		if err != nil {
+			return err
+		}
+	}
+
+	rawImgFile, err := os.OpenFile(rawWorkerPath, os.O_WRONLY, 0o600)
+	if err != nil {
+		return err
+	}
+
+	defer rawImgFile.Close()
+
+	vixFile, err := os.Open(filepath.Join(s.CacheDir, vixName))
+	if err != nil {
+		return err
+	}
+
+	defer vixFile.Close()
+
+	_, err = rawImgFile.Seek(616448*512, io.SeekStart)
+	if err != nil {
+		return err
+	}
+
+	// Write the VIX tarball at the offset.
+	_, err = io.Copy(rawImgFile, vixFile)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/internal/source/vmware_linux.go b/internal/source/vmware_linux.go
index 451739c6..0f684eb4 100644
--- a/internal/source/vmware_linux.go
+++ b/internal/source/vmware_linux.go
@@ -8,6 +8,7 @@ import (
 	"net/url"
 	"time"
 
+	log "github.com/sirupsen/logrus"
 	"github.com/vmware/govmomi"
 
 	"github.com/FuturFusion/migration-manager/internal/migratekit/nbdkit"
@@ -37,6 +38,9 @@ func (s *InternalVMwareSource) ImportDisks(ctx context.Context, vmName string, s
 			break
 		}
 
+		logger := log.WithFields(log.Fields{"error": err, "attempt": i + 1})
+		logger.Error("Disk import attempt failed")
+
 		time.Sleep(time.Second * 1)
 	}
 
diff --git a/internal/target/common.go b/internal/target/common.go
index 7e6f32b3..f45fcc49 100644
--- a/internal/target/common.go
+++ b/internal/target/common.go
@@ -66,6 +66,10 @@ func (t *InternalTarget) SetProject(project string) error {
 	return fmt.Errorf("Not implemented by InternalTarget")
 }
 
+func (t *InternalTarget) SetPostMigrationVMConfig(i migration.Instance, allNetworks map[string]migration.Network) error {
+	return fmt.Errorf("Not implemented by InternalTarget")
+}
+
 func (t *InternalTarget) CreateVMDefinition(instanceDef migration.Instance, sourceName string, storagePool string) incusAPI.InstancesPost {
 	return incusAPI.InstancesPost{}
 }
@@ -102,8 +106,12 @@ func (t *InternalTarget) UpdateInstance(name string, instanceDef incusAPI.Instan
 	return nil, fmt.Errorf("Not implemented by InternalTarget")
 }
 
-func (t *InternalTarget) GetStoragePoolVolume(pool string, volType string, name string) (*incusAPI.StorageVolume, string, error) {
-	return nil, "", fmt.Errorf("Not implemented by InternalTarget")
+func (t *InternalTarget) GetStoragePoolVolumeNames(pool string) ([]string, error) {
+	return nil, fmt.Errorf("Not implemented by InternalTarget")
+}
+
+func (t *InternalTarget) CreateStoragePoolVolumeFromBackup(pool string, isoFilePath string) ([]incus.Operation, error) {
+	return nil, fmt.Errorf("Not implemented by InternalTarget")
 }
 
 func (t *InternalTarget) CreateStoragePoolVolumeFromISO(pool string, isoFilePath string) (incus.Operation, error) {
diff --git a/internal/target/incus.go b/internal/target/incus.go
index 62600275..44ae8e6c 100644
--- a/internal/target/incus.go
+++ b/internal/target/incus.go
@@ -7,8 +7,10 @@ import (
 	"encoding/pem"
 	"errors"
 	"fmt"
+	"io"
 	"os"
 	"path/filepath"
+	"strconv"
 	"strings"
 	"time"
 
@@ -16,9 +18,11 @@ import (
 	incusAPI "github.com/lxc/incus/v6/shared/api"
 	"github.com/lxc/incus/v6/shared/revert"
 	incusTLS "github.com/lxc/incus/v6/shared/tls"
+	"gopkg.in/yaml.v3"
 
 	"github.com/FuturFusion/migration-manager/internal/migration"
 	"github.com/FuturFusion/migration-manager/internal/util"
+	"github.com/FuturFusion/migration-manager/internal/version"
 	"github.com/FuturFusion/migration-manager/shared/api"
 )
 
@@ -180,6 +184,120 @@ func (t *InternalIncusTarget) SetProject(project string) error {
 	return nil
 }
 
+// SetPostMigrationVMConfig stops the target instance and applies post-migration configuration before restarting it.
+func (t *InternalIncusTarget) SetPostMigrationVMConfig(i migration.Instance, allNetworks map[string]migration.Network) error {
+	// Stop the instance.
+	err := t.StopVM(i.GetName(), true)
+	if err != nil {
+		return fmt.Errorf("Failed to stop instance %q on target %q: %w", i.GetName(), t.GetName(), err)
+	}
+
+	apiDef, _, err := t.GetInstance(i.GetName())
+	if err != nil {
+		return fmt.Errorf("Failed to get configuration for instance %q on target %q: %w", i.GetName(), t.GetName(), err)
+	}
+
+	for idx, nic := range i.NICs {
+		nicDeviceName := fmt.Sprintf("eth%d", idx)
+		baseNetwork, ok := allNetworks[nic.Network]
+		if !ok {
+			err = fmt.Errorf("No network %q associated with instance %q on target %q", nic.Network, i.GetName(), t.GetName())
+			return err
+		}
+
+		// Pickup device name override if set.
+		deviceName, ok := baseNetwork.Config["name"]
+		if ok {
+			nicDeviceName = deviceName
+		}
+
+		// Copy the base network definitions.
+		apiDef.Devices[nicDeviceName] = make(map[string]string, len(baseNetwork.Config))
+		for k, v := range baseNetwork.Config {
+			apiDef.Devices[nicDeviceName][k] = v
+		}
+
+		// Set a few forced overrides.
+		apiDef.Devices[nicDeviceName]["type"] = "nic"
+		apiDef.Devices[nicDeviceName]["name"] = nicDeviceName
+		apiDef.Devices[nicDeviceName]["hwaddr"] = nic.Hwaddr
+	}
+
+	// Remove the migration ISO image.
+	delete(apiDef.Devices, util.WorkerVolume())
+
+	// Don't set any profiles by default.
+	apiDef.Profiles = []string{}
+
+	// Handle Windows-specific completion steps.
+	if strings.Contains(apiDef.Config["image.os"], "swodniw") {
+		// Remove the drivers ISO image.
+		delete(apiDef.Devices, "drivers")
+
+		// Fixup the OS name.
+		apiDef.Config["image.os"] = strings.Replace(apiDef.Config["image.os"], "swodniw", "windows", 1)
+	}
+
+	// Handle RHEL (and derivative) specific completion steps.
+	if util.IsRHELOrDerivative(apiDef.Config["image.os"]) {
+		// RHEL7+ don't support 9p, so make agent config available via cdrom.
+		apiDef.Devices["agent"] = map[string]string{
+			"type":   "disk",
+			"source": "agent:config",
+		}
+	}
+
+	// Set the instance's UUID copied from the source.
+	apiDef.Config["volatile.uuid"] = i.UUID.String()
+	apiDef.Config["volatile.uuid.generation"] = i.UUID.String()
+
+	// Apply CPU and memory limits.
+	apiDef.Config["limits.cpu"] = fmt.Sprintf("%d", i.CPU.NumberCPUs)
+	if i.Overrides != nil && i.Overrides.NumberCPUs != 0 {
+		apiDef.Config["limits.cpu"] = fmt.Sprintf("%d", i.Overrides.NumberCPUs)
+	}
+
+	apiDef.Config["limits.memory"] = fmt.Sprintf("%dB", i.Memory.MemoryInBytes)
+	if i.Overrides != nil && i.Overrides.MemoryInBytes != 0 {
+		apiDef.Config["limits.memory"] = fmt.Sprintf("%dB", i.Overrides.MemoryInBytes)
+	}
+
+	// Set UEFI and secure boot configs.
+	if i.UseLegacyBios {
+		apiDef.Config["security.csm"] = "true"
+		apiDef.Config["security.secureboot"] = "false"
+	} else {
+		apiDef.Config["security.csm"] = "false"
+		apiDef.Config["security.secureboot"] = strconv.FormatBool(i.SecureBootEnabled)
+	}
+
+	// Add TPM if needed.
+	if i.TPMPresent {
+		apiDef.Devices["vtpm"] = map[string]string{
+			"type": "tpm",
+			"path": "/dev/tpm0",
+		}
+	}
+
+	// Update the instance in Incus.
+	op, err := t.UpdateInstance(i.GetName(), apiDef.Writable(), "")
+	if err != nil {
+		return fmt.Errorf("Failed to update instance %q on target %q: %w", i.GetName(), t.GetName(), err)
+	}
+
+	err = op.Wait()
+	if err != nil {
+		return fmt.Errorf("Failed to wait for update to instance %q on target %q: %w", i.GetName(), t.GetName(), err)
+	}
+
+	err = t.StartVM(i.GetName())
+	if err != nil {
+		return fmt.Errorf("Failed to start instance %q on target %q: %w", i.GetName(), t.GetName(), err)
+	}
+
+	return nil
+}
+
 func (t *InternalIncusTarget) CreateVMDefinition(instanceDef migration.Instance, sourceName string, storagePool string) incusAPI.InstancesPost {
 	// Note -- We don't set any VM-specific NICs yet, and rely on the default profile to provide network connectivity during the migration process.
 	// Final network setup will be performed just prior to restarting into the freshly migrated VM.
@@ -207,16 +325,9 @@ func (t *InternalIncusTarget) CreateVMDefinition(instanceDef migration.Instance,
 	ret.Config["image.os"] = instanceDef.OS
 	ret.Config["image.release"] = instanceDef.OSVersion
 
-	// Apply CPU and memory limits.
-	ret.Config["limits.cpu"] = fmt.Sprintf("%d", instanceDef.CPU.NumberCPUs)
-	if instanceDef.Overrides != nil && instanceDef.Overrides.NumberCPUs != 0 {
-		ret.Config["limits.cpu"] = fmt.Sprintf("%d", instanceDef.Overrides.NumberCPUs)
-	}
-
-	ret.Config["limits.memory"] = fmt.Sprintf("%dB", instanceDef.Memory.MemoryInBytes)
-	if instanceDef.Overrides != nil && instanceDef.Overrides.MemoryInBytes != 0 {
-		ret.Config["limits.memory"] = fmt.Sprintf("%dB", instanceDef.Overrides.MemoryInBytes)
-	}
+	// Apply minimal CPU and memory limits.
+	ret.Config["limits.cpu"] = "2"
+	ret.Config["limits.memory"] = "4GiB"
 
 	// Define the default disk settings.
 	defaultDiskDef := map[string]string{
@@ -252,27 +363,8 @@ func (t *InternalIncusTarget) CreateVMDefinition(instanceDef migration.Instance,
 		numDisks++
 	}
 
-	// Add TPM if needed.
-	if instanceDef.TPMPresent {
-		ret.Devices["vtpm"] = map[string]string{
-			"type": "tpm",
-			"path": "/dev/tpm0",
-		}
-	}
-
-	// Set UEFI and secure boot configs
-	if instanceDef.UseLegacyBios {
-		ret.Config["security.csm"] = "true"
-	} else {
-		ret.Config["security.csm"] = "false"
-	}
-
-	if instanceDef.SecureBootEnabled {
-		ret.Config["security.secureboot"] = "true"
-	} else {
-		ret.Config["security.secureboot"] = "false"
-	}
-
+	ret.Config["security.csm"] = "false"
+	ret.Config["security.secureboot"] = "false"
 	ret.Description = ret.Config["image.description"]
 
 	// Set the migration source as a user tag to allow easy filtering.
@@ -298,11 +390,13 @@ func (t *InternalIncusTarget) CreateNewVM(apiDef incusAPI.InstancesPost, storage
 	defer reverter.Fail()
 
 	// Attach bootable ISO to run migration of this VM.
-	apiDef.Devices["migration-iso"] = map[string]string{
+	apiDef.Devices[util.WorkerVolume()] = map[string]string{
 		"type":          "disk",
 		"pool":          storagePool,
 		"source":        bootISOImage,
 		"boot.priority": "10",
+		"readonly":      "true",
+		"io.bus":        "virtio-blk",
 	}
 
 	// If this is a Windows VM, attach the virtio drivers ISO.
@@ -445,8 +539,83 @@ func (t *InternalIncusTarget) UpdateInstance(name string, instanceDef incusAPI.I
 	return t.incusClient.UpdateInstance(name, instanceDef, ETag)
 }
 
-func (t *InternalIncusTarget) GetStoragePoolVolume(pool string, volType string, name string) (*incusAPI.StorageVolume, string, error) {
-	return t.incusClient.GetStoragePoolVolume(pool, volType, name)
+func (t *InternalIncusTarget) GetStoragePoolVolumeNames(pool string) ([]string, error) {
+	return t.incusClient.GetStoragePoolVolumeNames(pool)
+}
+
+func (t *InternalIncusTarget) CreateStoragePoolVolumeFromBackup(poolName string, backupFilePath string) ([]incus.Operation, error) {
+	pool, _, err := t.incusClient.GetStoragePool(poolName)
+	if err != nil {
+		return nil, err
+	}
+
+	s, _, err := t.incusClient.GetServer()
+	if err != nil {
+		return nil, err
+	}
+
+	poolIsShared := false
+	for _, driver := range s.Environment.StorageSupportedDrivers {
+		if driver.Name == pool.Driver {
+			poolIsShared = driver.Remote
+			break
+		}
+	}
+
+	// Re-use the backup tarball if we used this source before.
+	backupName := filepath.Join(util.CachePath(), fmt.Sprintf("%s_%s_%s_worker.tar.gz", t.GetName(), pool.Name, version.GoVersion()))
+	_, err = os.Stat(backupName)
+	if err != nil && !os.IsNotExist(err) {
+		return nil, err
+	}
+
+	if err != nil {
+		err := createIncusBackup(backupName, backupFilePath, pool)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	// If the pool is a shared pool, we only need to create the volume once.
+	if poolIsShared {
+		f, err := os.Open(backupName)
+		if err != nil {
+			return nil, err
+		}
+
+		createArgs := incus.StorageVolumeBackupArgs{BackupFile: f, Name: util.WorkerVolume()}
+		op, err := t.incusClient.CreateStoragePoolVolumeFromBackup(poolName, createArgs)
+		if err != nil {
+			return nil, err
+		}
+
+		return []incus.Operation{op}, nil
+	}
+
+	// If the pool is local-only, we have to create the volume for each member.
+	members, err := t.incusClient.GetClusterMemberNames()
+	if err != nil {
+		return nil, err
+	}
+
+	ops := make([]incus.Operation, 0, len(members))
+	for _, member := range members {
+		f, err := os.Open(backupName)
+		if err != nil {
+			return nil, err
+		}
+
+		createArgs := incus.StorageVolumeBackupArgs{BackupFile: f, Name: util.WorkerVolume()}
+		target := t.incusClient.UseTarget(member)
+		op, err := target.CreateStoragePoolVolumeFromBackup(poolName, createArgs)
+		if err != nil {
+			return nil, err
+		}
+
+		ops = append(ops, op)
+	}
+
+	return ops, nil
 }
 
 func (t *InternalIncusTarget) CreateStoragePoolVolumeFromISO(pool string, isoFilePath string) (incus.Operation, error) {
@@ -464,3 +633,95 @@ func (t *InternalIncusTarget) CreateStoragePoolVolumeFromISO(pool string, isoFil
 
 	return t.incusClient.CreateStoragePoolVolumeFromISO(pool, createArgs)
 }
+
+type backupIndexFile struct {
+	Name    string         `yaml:"name"`
+	Backend string         `yaml:"backend"`
+	Pool    string         `yaml:"pool"`
+	Type    string         `yaml:"type"`
+	Config  map[string]any `yaml:"config"`
+}
+
+// createIncusBackup creates a backup tarball at backupPath with the given imagePath, for the given pool.
+func createIncusBackup(backupPath string, imagePath string, pool *incusAPI.StoragePool) error {
+	imgFile, err := os.Open(imagePath)
+	if err != nil {
+		return err
+	}
+
+	// Make sure the file exists.
+	imgInfo, err := imgFile.Stat()
+	if err != nil {
+		return err
+	}
+
+	dir := filepath.Dir(backupPath)
+
+	// Create a temporary directory to build the backup image.
+	tmpDir, err := os.MkdirTemp(dir, "build-worker-img_")
+	if err != nil {
+		return err
+	}
+
+	defer func() { _ = os.RemoveAll(tmpDir) }()
+
+	// Create the backup directory.
+	err = os.Mkdir(filepath.Join(tmpDir, "backup"), 0o700)
+	if err != nil {
+		return err
+	}
+
+	// Create the backup volume.
+	volumePath := filepath.Join(tmpDir, "backup", "volume.img")
+	volumeFile, err := os.Create(volumePath)
+	if err != nil {
+		return err
+	}
+
+	defer volumeFile.Close()
+	_, err = io.Copy(volumeFile, imgFile)
+	if err != nil {
+		return err
+	}
+
+	// Create the backup index file.
+	index := backupIndexFile{
+		Name:    util.WorkerVolume(),
+		Backend: pool.Driver,
+		Pool:    pool.Name,
+		Type:    "custom",
+		Config: map[string]any{
+			"volume": incusAPI.StorageVolume{
+				StorageVolumePut: incusAPI.StorageVolumePut{
+					Config: map[string]string{
+						"size":            fmt.Sprintf("%dB", imgInfo.Size()),
+						"security.shared": "true",
+					},
+					Description: "Temporary image for the migration-manager worker",
+				},
+				Name:        util.WorkerVolume(),
+				Type:        "custom",
+				ContentType: "block",
+			},
+		},
+	}
+
+	indexYaml, err := yaml.Marshal(index)
+	if err != nil {
+		return err
+	}
+
+	indexPath := filepath.Join(tmpDir, "backup", "index.yaml")
+	indexFile, err := os.Create(indexPath)
+	if err != nil {
+		return err
+	}
+
+	defer indexFile.Close()
+	_, err = indexFile.Write(indexYaml)
+	if err != nil {
+		return err
+	}
+
+	return util.CreateTarballFromDir(backupPath, filepath.Join(tmpDir, "backup"))
+}
diff --git a/internal/target/interface.go b/internal/target/interface.go
index 996d0d8e..03bdaddc 100644
--- a/internal/target/interface.go
+++ b/internal/target/interface.go
@@ -75,6 +75,9 @@ type Target interface {
 	// Returns an error if called while disconnected from a target.
 	SetProject(project string) error
 
+	// SetPostMigrationVMConfig stops the target instance and applies post-migration configuration before restarting it.
+	SetPostMigrationVMConfig(i migration.Instance, allNetworks map[string]migration.Network) error
+
 	// Creates a VM definition for use with the Incus REST API.
 	CreateVMDefinition(instanceDef migration.Instance, sourceName string, storagePool string) incusAPI.InstancesPost
 
@@ -102,8 +105,11 @@ type Target interface {
 	// Wrapper around Incus' UpdateInstance method.
 	UpdateInstance(name string, instanceDef incusAPI.InstancePut, ETag string) (incus.Operation, error)
 
-	// Wrapper around Incus' GetStoragePoolVolume method.
-	GetStoragePoolVolume(pool string, volType string, name string) (*incusAPI.StorageVolume, string, error)
+	// Wrapper around Incus' GetStoragePoolVolumeNames method.
+	GetStoragePoolVolumeNames(pool string) ([]string, error)
+
+	// Wrapper around Incus' CreateStoragePoolVolumeFromBackup.
+	CreateStoragePoolVolumeFromBackup(poolName string, backupFilePath string) ([]incus.Operation, error)
 
 	// Wrapper around Incus' CreateStoragePoolVolumeFromISO.
 	CreateStoragePoolVolumeFromISO(pool string, isoFilePath string) (incus.Operation, error)
diff --git a/internal/util/archive.go b/internal/util/archive.go
new file mode 100644
index 00000000..9edf78bb
--- /dev/null
+++ b/internal/util/archive.go
@@ -0,0 +1,67 @@
+package util
+
+import (
+	"archive/tar"
+	"compress/gzip"
+	"io"
+	"io/fs"
+	"os"
+	"path/filepath"
+)
+
+// CreateTarballFromDir creates a tarball from a given directory, inclusive of the containing directory.
+func CreateTarballFromDir(target string, contentDir string) error {
+	f, err := os.Create(target)
+	if err != nil {
+		return err
+	}
+
+	defer func() { _ = f.Close() }()
+
+	gzipWriter := gzip.NewWriter(f)
+	defer func() { _ = gzipWriter.Close() }()
+
+	tarWriter := tar.NewWriter(gzipWriter)
+	defer func() { _ = tarWriter.Close() }()
+
+	return filepath.Walk(contentDir, archiveDir(tarWriter, contentDir))
+}
+
+func archiveDir(tarWriter *tar.Writer, contentDir string) filepath.WalkFunc {
+	return func(path string, info fs.FileInfo, err error) error {
+		if err != nil || path == contentDir {
+			return err
+		}
+
+		// Recurse through directories.
+		if info.IsDir() {
+			return filepath.Walk(path, archiveDir(tarWriter, path))
+		}
+
+		relPath, err := filepath.Rel(filepath.Dir(contentDir), path)
+		if err != nil {
+			return err
+		}
+
+		err = tarWriter.WriteHeader(&tar.Header{
+			Name:    relPath,
+			Size:    info.Size(),
+			Mode:    int64(info.Mode()),
+			ModTime: info.ModTime(),
+		})
+		if err != nil {
+			return err
+		}
+
+		f, err := os.Open(path)
+		if err != nil {
+			return err
+		}
+
+		defer func() { _ = f.Close() }()
+
+		_, err = io.Copy(tarWriter, f)
+
+		return err
+	}
+}
diff --git a/internal/util/concurrent.go b/internal/util/concurrent.go
index 56eb5ef0..a19e508f 100644
--- a/internal/util/concurrent.go
+++ b/internal/util/concurrent.go
@@ -74,3 +74,42 @@ func RunConcurrentMap[K comparable, V any](entities map[K]V, f func(K, V) error)
 
 	return nil
 }
+
+type IDLock[T comparable] struct {
+	accessLock sync.Mutex
+	lockMap    map[T]*sync.Mutex
+}
+
+// NewIDLock creates a thread-safe map of sync.Mutexes keyed by ID.
+func NewIDLock[T comparable]() IDLock[T] {
+	return IDLock[T]{lockMap: make(map[T]*sync.Mutex)}
+}
+
+// Lock fetches the existing lock, or creates a new lock for the given ID, and locks it.
+func (l *IDLock[T]) Lock(key T) {
+	l.accessLock.Lock()
+
+	lock, ok := l.lockMap[key]
+	if !ok {
+		lock = &sync.Mutex{}
+		l.lockMap[key] = lock
+	}
+
+	l.accessLock.Unlock()
+
+	lock.Lock()
+}
+
+// Unlock fetches the existing lock and deletes it from the map, before unlocking it.
+func (l *IDLock[T]) Unlock(key T) {
+	l.accessLock.Lock()
+	defer l.accessLock.Unlock()
+
+	lock, ok := l.lockMap[key]
+	if !ok {
+		return
+	}
+
+	lock.Unlock()
+	delete(l.lockMap, key)
+}
diff --git a/internal/util/github.go b/internal/util/github.go
new file mode 100644
index 00000000..ee4c29a0
--- /dev/null
+++ b/internal/util/github.go
@@ -0,0 +1,107 @@
+package util
+
+import (
+	"compress/gzip"
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+
+	"github.com/google/go-github/v69/github"
+
+	"github.com/FuturFusion/migration-manager/internal/version"
+)
+
+type githubRepo struct {
+	owner string
+	repo  string
+	tag   string
+
+	client        *github.Client
+	releaseAssets map[string]int64
+}
+
+// GetProjectRepo returns a GitHub repository client for the project at the current tag, and populates release assets.
+// If latest is true, release assets will be populated from the latest release instead of the tag.
+func GetProjectRepo(ctx context.Context, latest bool) (*githubRepo, error) {
+	g := &githubRepo{
+		owner: "futurfusion",
+		repo:  "migration-manager",
+		tag:   version.GoVersion(),
+
+		client: github.NewClient(nil),
+	}
+
+	var err error
+	var release *github.RepositoryRelease
+	if latest {
+		release, _, err = g.client.Repositories.GetLatestRelease(ctx, g.owner, g.repo)
+		if err != nil {
+			return nil, fmt.Errorf("Failed to get latest GitHub release: %w", err)
+		}
+	} else {
+		release, _, err = g.client.Repositories.GetReleaseByTag(ctx, g.owner, g.repo, g.tag)
+		if err != nil {
+			return nil, fmt.Errorf("Failed to get GitHub release for tag %q: %w", g.tag, err)
+		}
+	}
+
+	g.releaseAssets = make(map[string]int64, len(release.Assets))
+	for _, r := range release.Assets {
+		g.releaseAssets[r.GetName()] = r.GetID()
+	}
+
+	return g, nil
+}
+
+// DownloadAsset downloads the given release asset to the given target location.
+func (g *githubRepo) DownloadAsset(ctx context.Context, target string, asset string) error {
+	assetID, ok := g.releaseAssets[asset]
+	if !ok {
+		return fmt.Errorf("No GitHub release asset found with name %q", asset)
+	}
+
+	rc, _, err := g.client.Repositories.DownloadReleaseAsset(ctx, g.owner, g.repo, assetID, http.DefaultClient)
+	if err != nil {
+		return fmt.Errorf("Failed to fetch GitHub release asset with name %q: %w", asset, err)
+	}
+
+	defer func() { _ = rc.Close() }()
+	f, err := os.Create(target)
+	if err != nil {
+		if !os.IsExist(err) {
+			return fmt.Errorf("Failed to create file at %q: %w", target, err)
+		}
+
+		f, err = os.OpenFile(target, os.O_WRONLY, 0o644)
+		if err != nil {
+			return fmt.Errorf("Failed to open file at %q: %w", target, err)
+		}
+	}
+
+	defer func() { _ = f.Close() }()
+
+	// Setup a gzip reader to decompress during streaming.
+	body, err := gzip.NewReader(rc)
+	if err != nil {
+		return err
+	}
+
+	defer func() { _ = body.Close() }()
+
+	// Read from the decompressor in chunks to avoid excessive memory consumption.
+	for {
+		_, err = io.CopyN(f, body, 4*1024*1024)
+		if err != nil {
+			if errors.Is(err, io.EOF) {
+				break
+			}
+
+			return fmt.Errorf("Failed to copy GitHub release asset to %q: %w", target, err)
+		}
+	}
+
+	return nil
+}
diff --git a/internal/util/paths.go b/internal/util/paths.go
index bb7086a1..aa2148e6 100644
--- a/internal/util/paths.go
+++ b/internal/util/paths.go
@@ -3,8 +3,20 @@ package util
 import (
 	"os"
 	"path/filepath"
+
+	"github.com/FuturFusion/migration-manager/internal/version"
 )
 
+// WorkerVolume represents the name of the storage volume containing the migration worker.
+func WorkerVolume() string {
+	return "migration-worker-" + version.GoVersion()
+}
+
+// RawWorkerImage represents the raw worker image supplied to an Incus target.
+func RawWorkerImage() string {
+	return WorkerVolume() + ".img"
+}
+
 // CachePath returns the directory that migration manager should use for caching assets. If MIGRATION_MANAGER_DIR is
 // set, this path is $MIGRATION_MANAGER_DIR/cache, otherwise it is /var/cache/migration-manager.
 func CachePath(path ...string) string {
diff --git a/internal/version/version.go b/internal/version/version.go
index c34c9803..c25c7834 100644
--- a/internal/version/version.go
+++ b/internal/version/version.go
@@ -1,3 +1,8 @@
 package version
 
-const Version string = "0.0.1"
+const Version string = "0.0.0-pre.2"
+
+// GoVersion returns the project version formatted as a Go semantic version string.
+func GoVersion() string {
+	return "v" + Version
+}
diff --git a/internal/worker/linux.go b/internal/worker/linux.go
index eb136adc..9d03510d 100644
--- a/internal/worker/linux.go
+++ b/internal/worker/linux.go
@@ -45,7 +45,7 @@ type LVSOutput struct {
 	} `json:"report"`
 }
 
-const chrootMountPath string = "/mnt/target/"
+const chrootMountPath string = "/run/mount/target/"
 
 func LinuxDoPostMigrationConfig(distro string, majorVersion int) error {
 	slog.Info("Preparing to perform post-migration configuration of VM")
diff --git a/internal/worker/windows.go b/internal/worker/windows.go
index c36effe3..43bc6cd2 100644
--- a/internal/worker/windows.go
+++ b/internal/worker/windows.go
@@ -30,11 +30,11 @@ const (
 )
 
 const (
-	bitLockerMountPath       string = "/mnt/dislocker/"
+	bitLockerMountPath       string = "/run/mount/dislocker/"
 	driversMountDevice       string = "/dev/disk/by-id/scsi-0QEMU_QEMU_CD-ROM_incus_drivers"
-	driversMountPath         string = "/mnt/drivers/"
-	windowsMainMountPath     string = "/mnt/win_main/"
-	windowsRecoveryMountPath string = "/mnt/win_recovery/"
+	driversMountPath         string = "/run/mount/drivers/"
+	windowsMainMountPath     string = "/run/mount/win_main/"
+	windowsRecoveryMountPath string = "/run/mount/win_recovery/"
 )
 
 func init() {


Reviews:
user: login: stgraber
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-06T22:00:08Z
user: login: stgraber
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-06T22:00:53Z
user: login: stgraber
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-06T22:01:10Z
user: login: stgraber
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-06T22:02:02Z
user: login: stgraber
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-06T22:02:52Z
user: login: stgraber
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-06T22:03:52Z
user: login: stgraber
body: Just a few small nits and tweaks needed
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-03-06T22:04:22Z
user: login: masnax
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-06T22:10:17Z
user: login: masnax
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-06T22:10:43Z
user: login: stgraber
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-06T22:12:37Z
user: login: masnax
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-06T22:29:39Z
user: login: masnax
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-06T22:30:21Z
user: login: masnax
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-06T22:31:04Z
user: login: masnax
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-06T22:31:10Z
user: login: stgraber
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-06T22:49:17Z

Review comments:
diff_hunk: @@ -250,6 +250,38 @@ func (t *InternalIncusTarget) SetPostMigrationVMConfig(i migration.Instance, all
 	apiDef.Config["volatile.uuid"] = i.UUID.String()
 	apiDef.Config["volatile.uuid.generation"] = i.UUID.String()
 
+	// Apply CPU and memory limits.
+	apiDef.Config["limits.cpu"] = fmt.Sprintf("%d", i.CPU.NumberCPUs)
+	if i.Overrides != nil && i.Overrides.NumberCPUs != 0 {
+		apiDef.Config["limits.cpu"] = fmt.Sprintf("%d", i.Overrides.NumberCPUs)
+	}
+
+	apiDef.Config["limits.memory"] = fmt.Sprintf("%dB", i.Memory.MemoryInBytes)
+	if i.Overrides != nil && i.Overrides.MemoryInBytes != 0 {
+		apiDef.Config["limits.memory"] = fmt.Sprintf("%dB", i.Overrides.MemoryInBytes)
+	}
+
+	// Set UEFI and secure boot configs
path: internal/target/incus.go
user: login: stgraber
body: Nit, missing period at end of comment
created_at: 2025-03-06T22:00:08Z
author_association: COLLABORATOR
diff_hunk: @@ -250,6 +250,38 @@ func (t *InternalIncusTarget) SetPostMigrationVMConfig(i migration.Instance, all
 	apiDef.Config["volatile.uuid"] = i.UUID.String()
 	apiDef.Config["volatile.uuid.generation"] = i.UUID.String()
 
+	// Apply CPU and memory limits.
+	apiDef.Config["limits.cpu"] = fmt.Sprintf("%d", i.CPU.NumberCPUs)
+	if i.Overrides != nil && i.Overrides.NumberCPUs != 0 {
+		apiDef.Config["limits.cpu"] = fmt.Sprintf("%d", i.Overrides.NumberCPUs)
+	}
+
+	apiDef.Config["limits.memory"] = fmt.Sprintf("%dB", i.Memory.MemoryInBytes)
+	if i.Overrides != nil && i.Overrides.MemoryInBytes != 0 {
+		apiDef.Config["limits.memory"] = fmt.Sprintf("%dB", i.Overrides.MemoryInBytes)
+	}
+
+	// Set UEFI and secure boot configs
+	if i.UseLegacyBios {
+		apiDef.Config["security.csm"] = "true"
+	} else {
+		apiDef.Config["security.csm"] = "false"
+	}
+
+	if i.SecureBootEnabled {
path: internal/target/incus.go
user: login: stgraber
body: Secureboot isn't possible if under legacy bios. May be best to put secureboot handling under the else branch above.
created_at: 2025-03-06T22:00:52Z
author_association: COLLABORATOR
diff_hunk: @@ -250,6 +250,38 @@ func (t *InternalIncusTarget) SetPostMigrationVMConfig(i migration.Instance, all
 	apiDef.Config["volatile.uuid"] = i.UUID.String()
 	apiDef.Config["volatile.uuid.generation"] = i.UUID.String()
 
+	// Apply CPU and memory limits.
+	apiDef.Config["limits.cpu"] = fmt.Sprintf("%d", i.CPU.NumberCPUs)
+	if i.Overrides != nil && i.Overrides.NumberCPUs != 0 {
+		apiDef.Config["limits.cpu"] = fmt.Sprintf("%d", i.Overrides.NumberCPUs)
+	}
+
+	apiDef.Config["limits.memory"] = fmt.Sprintf("%dB", i.Memory.MemoryInBytes)
+	if i.Overrides != nil && i.Overrides.MemoryInBytes != 0 {
+		apiDef.Config["limits.memory"] = fmt.Sprintf("%dB", i.Overrides.MemoryInBytes)
+	}
+
+	// Set UEFI and secure boot configs
+	if i.UseLegacyBios {
+		apiDef.Config["security.csm"] = "true"
+	} else {
+		apiDef.Config["security.csm"] = "false"
+	}
+
+	if i.SecureBootEnabled {
path: internal/target/incus.go
user: login: stgraber
body: And ensure that secureboot=false when setting csm=true
created_at: 2025-03-06T22:01:10Z
author_association: COLLABORATOR
diff_hunk: @@ -341,27 +366,7 @@ func (t *InternalIncusTarget) CreateVMDefinition(instanceDef migration.Instance,
 		numDisks++
 	}
 
-	// Add TPM if needed.
-	if instanceDef.TPMPresent {
-		ret.Devices["vtpm"] = map[string]string{
-			"type": "tpm",
-			"path": "/dev/tpm0",
-		}
-	}
-
-	// Set UEFI and secure boot configs
-	if instanceDef.UseLegacyBios {
-		ret.Config["security.csm"] = "true"
-	} else {
-		ret.Config["security.csm"] = "false"
-	}
-
-	if instanceDef.SecureBootEnabled {
-		ret.Config["security.secureboot"] = "true"
-	} else {
-		ret.Config["security.secureboot"] = "false"
-	}
-
+	ret.Config["security.secureboot"] = "false"
path: internal/target/incus.go
user: login: stgraber
body: Might as well set `securit.csm=false` for consistency with post-migration and just in case a default profile forces csm on.
created_at: 2025-03-06T22:02:02Z
author_association: COLLABORATOR
diff_hunk: @@ -397,6 +397,9 @@ func (t *InternalIncusTarget) CreateNewVM(apiDef incusAPI.InstancesPost, storage
 		"pool":          storagePool,
 		"source":        bootISOImage,
 		"boot.priority": "10",
+		"readonly":      "true",
+		"io.bus":        "virtio-blk",
+		"io.cache":      "writeback",
path: internal/target/incus.go
user: login: stgraber
body: Do we still need the io.cache bit now that we've fixed the source of the corruption problem?
created_at: 2025-03-06T22:02:52Z
author_association: COLLABORATOR
diff_hunk: @@ -8,6 +8,7 @@ import (
 	"net/url"
 	"time"
 
+	log "github.com/sirupsen/logrus"
path: internal/source/vmware_linux.go
user: login: stgraber
body: why logrus? I thought we were using slog usually.
created_at: 2025-03-06T22:03:52Z
author_association: COLLABORATOR
diff_hunk: @@ -8,6 +8,7 @@ import (
 	"net/url"
 	"time"
 
+	log "github.com/sirupsen/logrus"
path: internal/source/vmware_linux.go
user: login: masnax
body: We still use logrus on the worker side for the time being. I thought it best for this PR to just keep the current format rather than instantiate both slog and logrus, or make this PR longer by removing logrus. 

 I've filed [an issue](https://github.com/FuturFusion/migration-manager/issues/138) to move all of these to slog. 
created_at: 2025-03-06T22:10:17Z
author_association: COLLABORATOR
diff_hunk: @@ -397,6 +397,9 @@ func (t *InternalIncusTarget) CreateNewVM(apiDef incusAPI.InstancesPost, storage
 		"pool":          storagePool,
 		"source":        bootISOImage,
 		"boot.priority": "10",
+		"readonly":      "true",
+		"io.bus":        "virtio-blk",
+		"io.cache":      "writeback",
path: internal/target/incus.go
user: login: masnax
body: I'll run a quick test to be sure. I'm guessing probably not though.
created_at: 2025-03-06T22:10:43Z
author_association: COLLABORATOR
diff_hunk: @@ -8,6 +8,7 @@ import (
 	"net/url"
 	"time"
 
+	log "github.com/sirupsen/logrus"
path: internal/source/vmware_linux.go
user: login: stgraber
body: Ah, interesting, not sure why we didn't move that over at the same time as the rest :)

I know we had a couple of spots where an external package uses logrus, but that's why we have the SlogBackedLogrus stuff.
created_at: 2025-03-06T22:12:37Z
author_association: COLLABORATOR
diff_hunk: @@ -397,6 +397,9 @@ func (t *InternalIncusTarget) CreateNewVM(apiDef incusAPI.InstancesPost, storage
 		"pool":          storagePool,
 		"source":        bootISOImage,
 		"boot.priority": "10",
+		"readonly":      "true",
+		"io.bus":        "virtio-blk",
+		"io.cache":      "writeback",
path: internal/target/incus.go
user: login: masnax
body: Yup, seems to work without it. Removed it now.
created_at: 2025-03-06T22:29:39Z
author_association: COLLABORATOR
diff_hunk: @@ -250,6 +250,38 @@ func (t *InternalIncusTarget) SetPostMigrationVMConfig(i migration.Instance, all
 	apiDef.Config["volatile.uuid"] = i.UUID.String()
 	apiDef.Config["volatile.uuid.generation"] = i.UUID.String()
 
+	// Apply CPU and memory limits.
+	apiDef.Config["limits.cpu"] = fmt.Sprintf("%d", i.CPU.NumberCPUs)
+	if i.Overrides != nil && i.Overrides.NumberCPUs != 0 {
+		apiDef.Config["limits.cpu"] = fmt.Sprintf("%d", i.Overrides.NumberCPUs)
+	}
+
+	apiDef.Config["limits.memory"] = fmt.Sprintf("%dB", i.Memory.MemoryInBytes)
+	if i.Overrides != nil && i.Overrides.MemoryInBytes != 0 {
+		apiDef.Config["limits.memory"] = fmt.Sprintf("%dB", i.Overrides.MemoryInBytes)
+	}
+
+	// Set UEFI and secure boot configs
path: internal/target/incus.go
user: login: masnax
body: Fixed. 
created_at: 2025-03-06T22:30:21Z
author_association: COLLABORATOR
diff_hunk: @@ -250,6 +250,38 @@ func (t *InternalIncusTarget) SetPostMigrationVMConfig(i migration.Instance, all
 	apiDef.Config["volatile.uuid"] = i.UUID.String()
 	apiDef.Config["volatile.uuid.generation"] = i.UUID.String()
 
+	// Apply CPU and memory limits.
+	apiDef.Config["limits.cpu"] = fmt.Sprintf("%d", i.CPU.NumberCPUs)
+	if i.Overrides != nil && i.Overrides.NumberCPUs != 0 {
+		apiDef.Config["limits.cpu"] = fmt.Sprintf("%d", i.Overrides.NumberCPUs)
+	}
+
+	apiDef.Config["limits.memory"] = fmt.Sprintf("%dB", i.Memory.MemoryInBytes)
+	if i.Overrides != nil && i.Overrides.MemoryInBytes != 0 {
+		apiDef.Config["limits.memory"] = fmt.Sprintf("%dB", i.Overrides.MemoryInBytes)
+	}
+
+	// Set UEFI and secure boot configs
+	if i.UseLegacyBios {
+		apiDef.Config["security.csm"] = "true"
+	} else {
+		apiDef.Config["security.csm"] = "false"
+	}
+
+	if i.SecureBootEnabled {
path: internal/target/incus.go
user: login: masnax
body: Done.
created_at: 2025-03-06T22:31:04Z
author_association: COLLABORATOR
diff_hunk: @@ -341,27 +366,7 @@ func (t *InternalIncusTarget) CreateVMDefinition(instanceDef migration.Instance,
 		numDisks++
 	}
 
-	// Add TPM if needed.
-	if instanceDef.TPMPresent {
-		ret.Devices["vtpm"] = map[string]string{
-			"type": "tpm",
-			"path": "/dev/tpm0",
-		}
-	}
-
-	// Set UEFI and secure boot configs
-	if instanceDef.UseLegacyBios {
-		ret.Config["security.csm"] = "true"
-	} else {
-		ret.Config["security.csm"] = "false"
-	}
-
-	if instanceDef.SecureBootEnabled {
-		ret.Config["security.secureboot"] = "true"
-	} else {
-		ret.Config["security.secureboot"] = "false"
-	}
-
+	ret.Config["security.secureboot"] = "false"
path: internal/target/incus.go
user: login: masnax
body: Done.
created_at: 2025-03-06T22:31:10Z
author_association: COLLABORATOR

PR comments:
user: login: masnax
created_at: 2025-03-01T02:20:58Z
body: At this point, functionally this does work, but there's an odd error where after the disk import begins, the filesystem on the storage volume becomes corrupted and cannot be used again, even with `readonly=true` set. 
author_association: COLLABORATOR
user: login: masnax
created_at: 2025-03-06T20:33:33Z
body: This is now ready for review. 

* Issue with the breaking volumes was on the Incus side.
* Added support for local-only storage pools (we create the volume on all cluster members)
* Added a batch lock to the daemon which is keyed by target name, storage pool name, and project name. This is to ensure creation of the same storage volumes in the same target/pool/project combo is synchronous across concurrently running batches. 
* Added additional nbdcopy logging. 
author_association: COLLABORATOR