diff --git a/README.md b/README.md index 2b29282..5d21cee 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,7 @@ See [docs/gpu-passthrough-gaming.md](docs/gpu-passthrough-gaming.md) for Sunshin | `vee check ` | Run health checks on an installed VM | | `vee backup ` | Back up directories from a running VM | | `vee autostart ` | Enable or disable autostart for a VM | +| `vee move ` | Move a VM's boot disk to another directory | | `vee delete ` | Wipe VM and all its disks | | `vee daemon` | Run the vee daemon (starts and watches autostart VMs) | | `vee dashboard` | Start a web dashboard for all VMs | diff --git a/cmd/move.go b/cmd/move.go new file mode 100644 index 0000000..bc4c8f8 --- /dev/null +++ b/cmd/move.go @@ -0,0 +1,119 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/Benehiko/vee/internal/vm" +) + +var ( + moveYes bool + moveNoStart bool +) + +var moveCmd = &cobra.Command{ + Use: "move ", + Short: "Move a VM's boot disk to another directory", + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + // First arg: VM name. Second arg: target directory (default dir completion). + if len(args) == 0 { + return completeVMNames(cmd, args, toComplete) + } + if len(args) == 1 { + return nil, cobra.ShellCompDirectiveFilterDirs + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + Long: `Relocates a VM's managed boot qcow2 disk image into target-dir and updates the +VM config to point at the new location. Only the boot disk image moves — vm.yaml, +logs, control sockets, UEFI vars and cidata.iso stay under /. + +The VM must be shut down while its disk is moved. If it is running, vee stops it +first (prompting for confirmation), performs the move, and starts it again. Use +--yes to skip all prompts (for scripting) and --no-start to leave the VM stopped +after the move. + +Examples: + vee move linux-gaming /mnt/nvme/vms — interactive: prompt to stop/restart + vee move linux-gaming /mnt/nvme/vms -y — non-interactive: stop, move, restart + vee move linux-gaming /mnt/nvme/vms --no-start — move, leave stopped`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + targetDir := args[1] + + mgr := vm.NewManager(prov) + + // Determine whether the VM is currently running. + state, err := mgr.LoadState(name) + if err != nil { + return err + } + wasRunning := state.Running + + if wasRunning { + if !moveYes { + fmt.Printf("VM %q is running and must be stopped to move its boot disk.\n", name) + if !confirm("Stop it now?", true) { + return fmt.Errorf("aborted") + } + } + fmt.Printf("Stopping %s…\n", name) + if err := mgr.Stop(cmd.Context(), name); err != nil { + return fmt.Errorf("stop VM: %w", err) + } + } + + oldPath, newPath, err := mgr.MoveBootDisk(name, targetDir) + if err != nil { + return err + } + fmt.Printf("Moved boot disk:\n %s\n → %s\n", oldPath, newPath) + + // Restart only if the VM was running before, and the caller didn't opt out. + if !wasRunning || moveNoStart { + return nil + } + + restart := moveYes + if !restart { + restart = confirm(fmt.Sprintf("Start %s again?", name), true) + } + if !restart { + return nil + } + + fmt.Printf("Starting %s…\n", name) + if err := mgr.Start(cmd.Context(), name, false); err != nil { + return fmt.Errorf("start VM: %w", err) + } + return nil + }, +} + +// confirm prompts the user for a yes/no answer on stdin. def is the default +// applied on an empty response. +func confirm(prompt string, def bool) bool { + suffix := "[y/N]" + if def { + suffix = "[Y/n]" + } + fmt.Printf("%s %s: ", prompt, suffix) + var answer string + _, _ = fmt.Scanln(&answer) + answer = strings.ToLower(strings.TrimSpace(answer)) + if answer == "" { + return def + } + return answer == "y" || answer == "yes" +} + +func init() { + moveCmd.Flags().BoolVarP(&moveYes, "yes", "y", false, + "Skip all confirmation prompts (stop and restart automatically); use for scripting") + moveCmd.Flags().BoolVar(&moveNoStart, "no-start", false, + "Do not start the VM again after the move, even if it was running") +} diff --git a/cmd/root.go b/cmd/root.go index 09ac69f..721b78a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -53,6 +53,7 @@ func init() { rootCmd.AddCommand(stopCmd) rootCmd.AddCommand(listCmd) rootCmd.AddCommand(deleteCmd) + rootCmd.AddCommand(moveCmd) rootCmd.AddCommand(sshShareCmd) rootCmd.AddCommand(sshCmd) rootCmd.AddCommand(logsCmd) diff --git a/internal/vm/manager.go b/internal/vm/manager.go index 3b29787..9dd76c7 100644 --- a/internal/vm/manager.go +++ b/internal/vm/manager.go @@ -14,6 +14,7 @@ import ( "path/filepath" "strings" "sync" + "syscall" "time" "go.uber.org/zap" @@ -1108,6 +1109,141 @@ func (m *Manager) cleanupStaleVM(name string, _ *VMConfig, state *VMState) { _ = m.saveState(name, preserved) } +// diskFileSuffixes are the extensions AbsolutePath treats as an explicit file +// path (rather than a directory to join the generated disk name into). Mirrors +// qemu.Disk.AbsolutePath. +var diskFileSuffixes = []string{"qcow2", "qcow", "img", "raw", "iso", "vmdk", "vdi", "vhd"} + +// managedBootDiskAbsPath resolves the on-disk path of a managed qcow2 disk the +// same way qemu.Disk.AbsolutePath / Disk.Name do: an explicit file path is used +// verbatim, otherwise Path is treated as a directory and the generated file name +// (disk--.) is joined onto it. +func managedBootDiskAbsPath(vmName string, d DiskConfig) string { + for _, suffix := range diskFileSuffixes { + if strings.HasSuffix(d.Path, suffix) { + return d.Path + } + } + format := d.Format + if format == "" { + format = "qcow2" + } + name := fmt.Sprintf("disk-%s-%s.%s", vmName, d.Size, format) + if d.Path == "" { + // Empty Path resolves relative to the VM directory (where qemu-img + // created the disk). + return name + } + return filepath.Join(d.Path, name) +} + +// findManagedBootDisk returns the index of the VM's managed boot qcow2 disk — +// the first writable, non-passthrough qcow2 "disk". This is the same predicate +// used by build.applyOverrides when relocating with --boot-disk-path. +func findManagedBootDisk(cfg *VMConfig) int { + for i := range cfg.Disks { + d := &cfg.Disks[i] + if d.Passthrough || d.Media != "disk" || d.Format != "qcow2" { + continue + } + return i + } + return -1 +} + +// MoveBootDisk relocates a VM's managed boot qcow2 disk image into targetDir and +// updates the persisted config to point at the new location. The VM must be +// stopped — callers are responsible for stopping it first. The disk file is +// moved via rename when possible, falling back to copy+delete across +// filesystems. Only the boot disk image moves; vm.yaml, logs, sockets, UEFI vars +// and cidata.iso stay under /. +func (m *Manager) MoveBootDisk(name, targetDir string) (oldPath, newPath string, err error) { + state, err := m.loadState(name) + if err != nil { + return "", "", err + } + if state.Running && isAlive(state.PID) { + return "", "", fmt.Errorf("VM %q is running; stop it first", name) + } + + cfg, err := m.loadConfig(name) + if err != nil { + return "", "", err + } + + idx := findManagedBootDisk(cfg) + if idx < 0 { + return "", "", fmt.Errorf("VM %q has no managed boot disk to move (raw-device --boot-disk VMs cannot be relocated)", name) + } + + // Resolve the source path relative to the VM directory so an empty or + // relative Path (stored verbatim by older configs) still resolves correctly. + src := managedBootDiskAbsPath(name, cfg.Disks[idx]) + if !filepath.IsAbs(src) { + src = filepath.Join(m.vmDir(name), src) + } + if _, statErr := os.Stat(src); statErr != nil { + return "", "", fmt.Errorf("boot disk not found at %s: %w", src, statErr) + } + + absTarget, err := filepath.Abs(targetDir) + if err != nil { + return "", "", fmt.Errorf("resolve target dir: %w", err) + } + dst := filepath.Join(absTarget, filepath.Base(src)) + + if dst == src { + return "", "", fmt.Errorf("boot disk is already at %s", dst) + } + if _, statErr := os.Stat(dst); statErr == nil { + return "", "", fmt.Errorf("a file already exists at target path %s", dst) + } + + if err := os.MkdirAll(absTarget, 0o750); err != nil { + return "", "", fmt.Errorf("create target dir %s: %w", absTarget, err) + } + + if err := moveFile(src, dst); err != nil { + return "", "", fmt.Errorf("move boot disk: %w", err) + } + + // Persist the explicit destination file path. Using the full path (rather than + // a bare directory) is correct whether the source was a generated name + // (disk--.) or a custom file name like disk-os.img — a bare + // directory would make AbsolutePath re-derive the generated name and lose a + // custom basename. + cfg.Disks[idx].Path = dst + if err := m.saveConfig(cfg); err != nil { + // Best-effort rollback: move the file back so config and disk stay in sync. + if backErr := moveFile(dst, src); backErr != nil { + return "", "", fmt.Errorf("save config after move failed (%w); disk left at %s — restore manually", err, dst) + } + return "", "", fmt.Errorf("save config: %w", err) + } + + return src, dst, nil +} + +// moveFile relocates src to dst, using rename when both live on the same +// filesystem and falling back to copy+remove across filesystems (rename returns +// EXDEV in that case). +func moveFile(src, dst string) error { + if err := os.Rename(src, dst); err == nil { + return nil + } else if !errors.Is(err, syscall.EXDEV) { + return err + } + // Cross-filesystem: copy then remove the source. + if err := copyFile(src, dst); err != nil { + _ = os.Remove(dst) + return err + } + if err := os.Remove(src); err != nil { + return fmt.Errorf("copied to %s but could not remove original %s: %w", dst, src, err) + } + return nil +} + // Delete removes a VM directory and its DB records. Refuses if the VM is running. func (m *Manager) Delete(name string) error { state, err := m.loadState(name) @@ -1146,18 +1282,7 @@ func (m *Manager) deleteScratchDisk(vmName string, d DiskConfig) { // scratchDiskPath resolves a scratch disk's backing file path, mirroring // qemu.Disk.AbsolutePath / Disk.Name (disk--.). func scratchDiskPath(vmName string, d DiskConfig) string { - diskSuffixes := []string{"qcow2", "qcow", "img", "raw", "iso", "vmdk", "vdi", "vhd"} - for _, suffix := range diskSuffixes { - if strings.HasSuffix(d.Path, suffix) { - return d.Path - } - } - format := d.Format - if format == "" { - format = "qcow2" - } - name := fmt.Sprintf("disk-%s-%s.%s", vmName, d.Size, format) - return filepath.Join(d.Path, name) + return managedBootDiskAbsPath(vmName, d) } // deleteVMDir removes all contents of dir except the backups/ subdirectory. diff --git a/internal/vm/move_test.go b/internal/vm/move_test.go new file mode 100644 index 0000000..2bf6276 --- /dev/null +++ b/internal/vm/move_test.go @@ -0,0 +1,129 @@ +package vm + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFindManagedBootDisk(t *testing.T) { + tests := []struct { + name string + cfg *VMConfig + want int + }{ + { + name: "single managed qcow2 disk", + cfg: &VMConfig{Disks: []DiskConfig{ + {Media: "disk", Format: "qcow2"}, + }}, + want: 0, + }, + { + name: "cidata cdrom precedes boot disk", + cfg: &VMConfig{Disks: []DiskConfig{ + {Media: "cdrom", Format: "qcow2", InstallISO: true}, + {Media: "disk", Format: "qcow2"}, + }}, + want: 1, + }, + { + name: "passthrough disk is skipped", + cfg: &VMConfig{Disks: []DiskConfig{ + {Media: "disk", Format: "raw", Passthrough: true}, + {Media: "disk", Format: "qcow2"}, + }}, + want: 1, + }, + { + name: "first managed qcow2 wins when several present", + cfg: &VMConfig{Disks: []DiskConfig{ + {Media: "disk", Format: "qcow2", Size: "40G"}, + {Media: "disk", Format: "qcow2", Size: "80G"}, + }}, + want: 0, + }, + { + name: "no managed disk (raw --boot-disk only)", + cfg: &VMConfig{Disks: []DiskConfig{ + {Media: "disk", Format: "raw", Passthrough: true, BootIndex: 1}, + }}, + want: -1, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := findManagedBootDisk(tt.cfg); got != tt.want { + t.Errorf("findManagedBootDisk() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestManagedBootDiskAbsPath(t *testing.T) { + tests := []struct { + name string + vm string + disk DiskConfig + want string + }{ + { + name: "empty path resolves to generated name", + vm: "linux", + disk: DiskConfig{Size: "40G", Format: "qcow2"}, + want: "disk-linux-40G.qcow2", + }, + { + name: "directory path gets generated name joined", + vm: "linux", + disk: DiskConfig{Path: "/mnt/nvme/vms", Size: "40G", Format: "qcow2"}, + want: "/mnt/nvme/vms/disk-linux-40G.qcow2", + }, + { + name: "explicit file path used verbatim", + vm: "linux", + disk: DiskConfig{Path: "/mnt/nvme/custom.qcow2", Size: "40G", Format: "qcow2"}, + want: "/mnt/nvme/custom.qcow2", + }, + { + name: "default format when unset", + vm: "linux", + disk: DiskConfig{Path: "/data", Size: "40G"}, + want: "/data/disk-linux-40G.qcow2", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := managedBootDiskAbsPath(tt.vm, tt.disk); got != tt.want { + t.Errorf("managedBootDiskAbsPath() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestMoveFile(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src.qcow2") + dst := filepath.Join(dir, "sub", "dst.qcow2") + + if err := os.WriteFile(src, []byte("disk-bytes"), 0o600); err != nil { + t.Fatalf("write src: %v", err) + } + if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil { + t.Fatalf("mkdir dst: %v", err) + } + + if err := moveFile(src, dst); err != nil { + t.Fatalf("moveFile: %v", err) + } + if _, err := os.Stat(src); !os.IsNotExist(err) { + t.Errorf("source still exists after move: %v", err) + } + got, err := os.ReadFile(dst) //nolint:gosec // test path + if err != nil { + t.Fatalf("read dst: %v", err) + } + if string(got) != "disk-bytes" { + t.Errorf("dst content = %q, want %q", got, "disk-bytes") + } +} diff --git a/site/content/commands/move.md b/site/content/commands/move.md new file mode 100644 index 0000000..6240fb7 --- /dev/null +++ b/site/content/commands/move.md @@ -0,0 +1,54 @@ +--- +title: vee move +weight: 105 +--- + +Move a VM's managed boot disk to another directory and update its config. + +``` +vee move +``` + +Relocates the VM's managed boot `qcow2` disk image into `target-dir` and rewrites +the VM configuration to point at the new location. This is useful for moving a +single VM onto a faster device (e.g. an NVMe drive) without changing the global +`storage_path` for every VM. + +Only the boot disk image moves. The rest of the VM's files — `vm.yaml`, logs, +control sockets, UEFI vars and the cidata seed ISO — stay under +`/`. This matches how `vee create --boot-disk-path` places a +new VM's disk. + +The VM must be shut down while its disk is moved. If it is running, `vee` stops +it first (prompting for confirmation), performs the move, then starts it again. + +The target directory is created on demand. The move uses a rename when the source +and target are on the same filesystem, and falls back to a copy-and-delete across +filesystems. + +## Flags + +| Flag | Description | +| --- | --- | +| `-y`, `--yes` | Skip all confirmation prompts. Stops the VM if running, moves the disk, and starts it again automatically. Use this for scripting. | +| `--no-start` | Do not start the VM again after the move, even if it was running before. | + +## Examples + +```bash +# Interactive: prompt before stopping and before restarting. +vee move linux-gaming /mnt/nvme/vms + +# Non-interactive: stop, move, and restart without prompts. +vee move linux-gaming /mnt/nvme/vms --yes + +# Move the disk but leave the VM stopped afterwards. +vee move linux-gaming /mnt/nvme/vms --no-start +``` + +## Notes + +- VMs booting from a raw host block device (`vee create --boot-disk /dev/...`) + have no managed disk image to move and are rejected. +- If a file already exists at the target path, the move is aborted so an existing + disk is never overwritten.