diff --git a/README.md b/README.md index b599ffb..97decc7 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,60 @@ to be able to use Migratekit: | Guest operations | Allow creation and management of VM snapshots for replication. | Virtual machines | VirtualMachine.GuestOperations.* | | Interaction Power Off | Permit powering off the VM during migration to Migratekit. | Virtual machines | VirtualMachine.Interact.PowerOff | +### Local Disk Backup + +Migratekit now supports local disk backup as an alternative to OpenStack targets. This is useful for creating local backups or for testing purposes. + +#### Local Disk Features: +- **CBT Support**: Uses VMware Changed Block Tracking for incremental backups +- **Change ID Storage**: Stores change IDs in local JSON metadata files +- **Raw Disk Images**: Creates raw disk images compatible with most virtualization platforms +- **Directory Structure**: Organizes backups by VM name and disk key + +#### Local Disk Usage: + +For the first backup (full copy): +```bash +migratekit migrate \ + --vmware-endpoint vmware.local \ + --vmware-username username \ + --vmware-password password \ + --vmware-path /ha-datacenter/vm/migration-test \ + --local-target-path /backup/vm-backups +``` + +For subsequent incremental backups: +```bash +migratekit migrate \ + --vmware-endpoint vmware.local \ + --vmware-username username \ + --vmware-password password \ + --vmware-path /ha-datacenter/vm/migration-test \ + --local-target-path /backup/vm-backups +``` + +For cutover (final sync): +```bash +migratekit cutover \ + --vmware-endpoint vmware.local \ + --vmware-username username \ + --vmware-password password \ + --vmware-path /ha-datacenter/vm/migration-test \ + --local-target-path /backup/vm-backups +``` + +#### Local Disk Output Structure: +``` +/backup/vm-backups/ +└── migration-test/ + ├── disk-2000.raw + ├── disk-2000.metadata.json + ├── disk-2001.raw + └── disk-2001.metadata.json +``` + +The metadata JSON files contain the change ID and other backup information for incremental backup tracking. + ### Running Migratekit Assuming that you already have Docker installed on your system, you can use the @@ -189,6 +243,7 @@ There are a few optional flags to define the following: Valid values for the most OpenStack installations are "linux" and "windows" - `--enable-qemu-guest-agent`: Sets the "hw_qemu_guest_agent" volume (image) metadata parameter to "yes". +- `--local-target-path`: Local directory path for storing VM disks (alternative to OpenStack) ## Contributing diff --git a/internal/target/local.go b/internal/target/local.go new file mode 100644 index 0000000..6498d5a --- /dev/null +++ b/internal/target/local.go @@ -0,0 +1,166 @@ +package target + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + log "github.com/sirupsen/logrus" + "github.com/vexxhost/migratekit/internal/vmware" + "github.com/vmware/govmomi/object" + "github.com/vmware/govmomi/vim25/types" +) + +type LocalDisk struct { + VirtualMachine *object.VirtualMachine + Disk *types.VirtualDisk + BasePath string +} + +type LocalDiskMetadata struct { + ChangeID string `json:"change_id"` + VMName string `json:"vm_name"` + DiskKey int `json:"disk_key"` + Size int64 `json:"size"` +} + +func NewLocalDisk(ctx context.Context, vm *object.VirtualMachine, disk *types.VirtualDisk, basePath string) (*LocalDisk, error) { + // Ensure base path exists + if err := os.MkdirAll(basePath, 0755); err != nil { + return nil, fmt.Errorf("failed to create base path %s: %w", basePath, err) + } + + return &LocalDisk{ + VirtualMachine: vm, + Disk: disk, + BasePath: basePath, + }, nil +} + +func (t *LocalDisk) GetDisk() *types.VirtualDisk { + return t.Disk +} + +func (t *LocalDisk) Connect(ctx context.Context) error { + // For local disk, we just ensure the target file exists + targetPath := t.getTargetPath() + dir := filepath.Dir(targetPath) + + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create target directory %s: %w", dir, err) + } + + // Create the target file if it doesn't exist + if _, err := os.Stat(targetPath); os.IsNotExist(err) { + file, err := os.Create(targetPath) + if err != nil { + return fmt.Errorf("failed to create target file %s: %w", targetPath, err) + } + defer file.Close() + + // Pre-allocate space by seeking to the end + if _, err := file.Seek(t.Disk.CapacityInBytes-1, 0); err != nil { + return fmt.Errorf("failed to seek to end of file: %w", err) + } + if _, err := file.Write([]byte{0}); err != nil { + return fmt.Errorf("failed to write to end of file: %w", err) + } + + log.WithFields(log.Fields{ + "file": targetPath, + "size": t.Disk.CapacityInBytes, + }).Info("Created target file") + } + + return nil +} + +func (t *LocalDisk) GetPath(ctx context.Context) (string, error) { + return t.getTargetPath(), nil +} + +func (t *LocalDisk) Disconnect(ctx context.Context) error { + // For local disk, no special disconnect needed + return nil +} + +func (t *LocalDisk) Exists(ctx context.Context) (bool, error) { + targetPath := t.getTargetPath() + if _, err := os.Stat(targetPath); os.IsNotExist(err) { + return false, nil + } else if err != nil { + return false, err + } + return true, nil +} + +func (t *LocalDisk) GetCurrentChangeID(ctx context.Context) (*vmware.ChangeID, error) { + metadataPath := t.getMetadataPath() + if _, err := os.Stat(metadataPath); os.IsNotExist(err) { + return &vmware.ChangeID{}, nil + } + + data, err := os.ReadFile(metadataPath) + if err != nil { + return nil, fmt.Errorf("failed to read metadata file: %w", err) + } + + var metadata LocalDiskMetadata + if err := json.Unmarshal(data, &metadata); err != nil { + return nil, fmt.Errorf("failed to parse metadata: %w", err) + } + + if metadata.ChangeID == "" { + return &vmware.ChangeID{}, nil + } + + return vmware.ParseChangeID(metadata.ChangeID) +} + +func (t *LocalDisk) WriteChangeID(ctx context.Context, changeID *vmware.ChangeID) error { + metadataPath := t.getMetadataPath() + + metadata := LocalDiskMetadata{ + ChangeID: changeID.Value, + VMName: t.VirtualMachine.Name(), + DiskKey: int(t.Disk.Key), + Size: t.Disk.CapacityInBytes, + } + + data, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal metadata: %w", err) + } + + // Ensure metadata directory exists + if err := os.MkdirAll(filepath.Dir(metadataPath), 0755); err != nil { + return fmt.Errorf("failed to create metadata directory: %w", err) + } + + if err := os.WriteFile(metadataPath, data, 0644); err != nil { + return fmt.Errorf("failed to write metadata file: %w", err) + } + + log.WithFields(log.Fields{ + "metadata_file": metadataPath, + "change_id": changeID.Value, + }).Info("Wrote change ID to metadata file") + + return nil +} + +func (t *LocalDisk) getTargetPath() string { + vmName := strings.ReplaceAll(t.VirtualMachine.Name(), "/", "_") + diskKey := strconv.Itoa(int(t.Disk.Key)) + return filepath.Join(t.BasePath, vmName, fmt.Sprintf("disk-%s.raw", diskKey)) +} + +func (t *LocalDisk) getMetadataPath() string { + vmName := strings.ReplaceAll(t.VirtualMachine.Name(), "/", "_") + diskKey := strconv.Itoa(int(t.Disk.Key)) + return filepath.Join(t.BasePath, vmName, fmt.Sprintf("disk-%s.metadata.json", diskKey)) +} diff --git a/internal/vmware_nbdkit/vmware_nbdkit.go b/internal/vmware_nbdkit/vmware_nbdkit.go index 78b5134..70b80ef 100644 --- a/internal/vmware_nbdkit/vmware_nbdkit.go +++ b/internal/vmware_nbdkit/vmware_nbdkit.go @@ -185,7 +185,16 @@ func (s *NbdkitServers) MigrationCycle(ctx context.Context, runV2V bool) error { }() for index, server := range s.Servers { - t, err := target.NewOpenStack(ctx, s.VirtualMachine, server.Disk) + var t target.Target + var err error + + // Check if we're using local disk target + if localTargetPath := ctx.Value("localTargetPath"); localTargetPath != nil { + t, err = target.NewLocalDisk(ctx, s.VirtualMachine, server.Disk, localTargetPath.(string)) + } else { + t, err = target.NewOpenStack(ctx, s.VirtualMachine, server.Disk) + } + if err != nil { return err } @@ -357,23 +366,28 @@ func (s *NbdkitServer) SyncToTarget(ctx context.Context, t target.Target, runV2V } if runV2V { - log.Info("Running virt-v2v-in-place") + // Check if we're using local disk - virt-v2v-in-place may not be needed for local backups + if _, isLocalDisk := t.(*target.LocalDisk); isLocalDisk { + log.Info("Skipping virt-v2v-in-place for local disk target") + } else { + log.Info("Running virt-v2v-in-place") - os.Setenv("LIBGUESTFS_BACKEND", "direct") + os.Setenv("LIBGUESTFS_BACKEND", "direct") - var cmd *exec.Cmd - if s.Servers.VddkConfig.Debug { - cmd = exec.Command("virt-v2v-in-place", "-v", "-x", "-i", "disk", path) - } else { - cmd = exec.Command("virt-v2v-in-place", "-i", "disk", path) - } + var cmd *exec.Cmd + if s.Servers.VddkConfig.Debug { + cmd = exec.Command("virt-v2v-in-place", "-v", "-x", "-i", "disk", path) + } else { + cmd = exec.Command("virt-v2v-in-place", "-i", "disk", path) + } - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr - err := cmd.Run() - if err != nil { - return err + err := cmd.Run() + if err != nil { + return err + } } err = t.WriteChangeID(ctx, &vmware.ChangeID{}) diff --git a/main.go b/main.go index e0244ec..0e4d924 100644 --- a/main.go +++ b/main.go @@ -72,7 +72,8 @@ var ( busType BusTypeOpts vzUnsafeVolumeByName bool osType string - enableQemuGuestAgent bool + enableQemuGuestAgent bool + localTargetPath string ) var rootCmd = &cobra.Command{ @@ -197,9 +198,13 @@ var rootCmd = &cobra.Command{ ctx = context.WithValue(ctx, "osType", osType) - ctx = context.WithValue(ctx, "enableQemuGuestAgent", enableQemuGuestAgent) + ctx = context.WithValue(ctx, "enableQemuGuestAgent", enableQemuGuestAgent) - cmd.SetContext(ctx) + if localTargetPath != "" { + ctx = context.WithValue(ctx, "localTargetPath", localTargetPath) + } + + cmd.SetContext(ctx) return nil }, @@ -236,90 +241,142 @@ It handles the following additional cases as well: var cutoverCmd = &cobra.Command{ Use: "cutover", Short: "Cutover to the new virtual machine", - Long: `This commands will cutover into the OpenStack virtual machine from VMware by executing the following steps: + Long: `This commands will cutover into the target system from VMware by executing the following steps: +For OpenStack targets: - Run a migration cycle - Shut down the source virtual machine - Run a final migration cycle to capture missing changes & run virt-v2v-in-place -- Spin up the new OpenStack virtual machine with the migrated disk`, +- Spin up the new OpenStack virtual machine with the migrated disk + +For Local disk targets: +- Run a migration cycle +- Shut down the source virtual machine +- Run a final migration cycle to capture missing changes`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() vm := ctx.Value("vm").(*object.VirtualMachine) vddkConfig := ctx.Value("vddkConfig").(*vmware_nbdkit.VddkConfig) - clients, err := openstack.NewClientSet(ctx) - if err != nil { - return err - } + // Check if we're using local disk target + isLocalTarget := ctx.Value("localTargetPath") != nil - log.Info("Ensuring OpenStack resources exist") + if !isLocalTarget { + // OpenStack target path + clients, err := openstack.NewClientSet(ctx) + if err != nil { + return err + } - flavor, err := flavors.Get(ctx, clients.Compute, flavorId).Extract() - if err != nil { - return err - } + log.Info("Ensuring OpenStack resources exist") - log.WithFields(log.Fields{ - "flavor": flavor.Name, - }).Info("Flavor exists, ensuring network resources exist") + flavor, err := flavors.Get(ctx, clients.Compute, flavorId).Extract() + if err != nil { + return err + } - v := openstack.PortCreateOpts{} - if len(securityGroups) > 0 { - v.SecurityGroups = &securityGroups - } - ctx = context.WithValue(ctx, "portCreateOpts", &v) + log.WithFields(log.Fields{ + "flavor": flavor.Name, + }).Info("Flavor exists, ensuring network resources exist") - networks, err := clients.EnsurePortsForVirtualMachine(ctx, vm, &networkMapping) - if err != nil { - return err - } + v := openstack.PortCreateOpts{} + if len(securityGroups) > 0 { + v.SecurityGroups = &securityGroups + } + ctx = context.WithValue(ctx, "portCreateOpts", &v) - log.Info("Starting migration cycle") + networks, err := clients.EnsurePortsForVirtualMachine(ctx, vm, &networkMapping) + if err != nil { + return err + } - servers := vmware_nbdkit.NewNbdkitServers(vddkConfig, vm) - err = servers.MigrationCycle(ctx, false) - if err != nil { - return err - } + log.Info("Starting migration cycle") + + servers := vmware_nbdkit.NewNbdkitServers(vddkConfig, vm) + err = servers.MigrationCycle(ctx, false) + if err != nil { + return err + } - log.Info("Completed migration cycle, shutting down source VM") + log.Info("Completed migration cycle, shutting down source VM") - powerState, err := vm.PowerState(ctx) - if err != nil { - return err - } + powerState, err := vm.PowerState(ctx) + if err != nil { + return err + } - if powerState == types.VirtualMachinePowerStatePoweredOff { - log.Warn("Source VM is already off, skipping shutdown") + if powerState == types.VirtualMachinePowerStatePoweredOff { + log.Warn("Source VM is already off, skipping shutdown") + } else { + err := vm.ShutdownGuest(ctx) + if err != nil { + return err + } + + err = vm.WaitForPowerState(ctx, types.VirtualMachinePowerStatePoweredOff) + if err != nil { + return err + } + + log.Info("Source VM shut down, starting final migration cycle") + } + + servers = vmware_nbdkit.NewNbdkitServers(vddkConfig, vm) + err = servers.MigrationCycle(ctx, enablev2v) + if err != nil { + return err + } + + log.Info("Final migration cycle completed, spinning up new OpenStack VM") + + err = clients.CreateResourcesForVirtualMachine(ctx, vm, flavorId, networks, availabilityZone) + if err != nil { + return err + } + + log.Info("Cutover completed") } else { - err := vm.ShutdownGuest(ctx) + // Local disk target path + log.Info("Starting migration cycle for local disk backup") + + servers := vmware_nbdkit.NewNbdkitServers(vddkConfig, vm) + err := servers.MigrationCycle(ctx, false) if err != nil { return err } - err = vm.WaitForPowerState(ctx, types.VirtualMachinePowerStatePoweredOff) + log.Info("Completed migration cycle, shutting down source VM") + + powerState, err := vm.PowerState(ctx) if err != nil { return err } - log.Info("Source VM shut down, starting final migration cycle") - } + if powerState == types.VirtualMachinePowerStatePoweredOff { + log.Warn("Source VM is already off, skipping shutdown") + } else { + err := vm.ShutdownGuest(ctx) + if err != nil { + return err + } - servers = vmware_nbdkit.NewNbdkitServers(vddkConfig, vm) - err = servers.MigrationCycle(ctx, enablev2v) - if err != nil { - return err - } + err = vm.WaitForPowerState(ctx, types.VirtualMachinePowerStatePoweredOff) + if err != nil { + return err + } - log.Info("Final migration cycle completed, spinning up new OpenStack VM") + log.Info("Source VM shut down, starting final migration cycle") + } - err = clients.CreateResourcesForVirtualMachine(ctx, vm, flavorId, networks, availabilityZone) - if err != nil { - return err - } + servers = vmware_nbdkit.NewNbdkitServers(vddkConfig, vm) + err = servers.MigrationCycle(ctx, enablev2v) + if err != nil { + return err + } - log.Info("Cutover completed") + log.Info("Final migration cycle completed for local disk backup") + } return nil }, @@ -350,10 +407,12 @@ func init() { rootCmd.PersistentFlags().BoolVar(&vzUnsafeVolumeByName, "vz-unsafe-volume-by-name", false, "Only use the name to find a volume - workaround for virtuozzu - dangerous option") - rootCmd.PersistentFlags().StringVar(&osType, "os-type", "", "Set os_type in the volume (image) metadata, (if set to \"auto\", it tries to detect the type from VMware GuestId)") + rootCmd.PersistentFlags().StringVar(&osType, "os-type", "", "Set os_type in the volume (image) metadata, (if set to \"auto\", it tries to detect the type from VMware GuestId)") rootCmd.PersistentFlags().BoolVar(&enableQemuGuestAgent, "enable-qemu-guest-agent", false, "Sets the hw_qemu_guest_agent metadata parameter to yes") + rootCmd.PersistentFlags().StringVar(&localTargetPath, "local-target-path", "", "Local directory path for storing VM disks (alternative to OpenStack)") + cutoverCmd.Flags().StringVar(&flavorId, "flavor", "", "OpenStack Flavor ID") cutoverCmd.MarkFlagRequired("flavor")