Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ See [docs/gpu-passthrough-gaming.md](docs/gpu-passthrough-gaming.md) for Sunshin
| `vee check <name>` | Run health checks on an installed VM |
| `vee backup <name>` | Back up directories from a running VM |
| `vee autostart <name>` | Enable or disable autostart for a VM |
| `vee move <name> <target-dir>` | Move a VM's boot disk to another directory |
| `vee delete <name>` | 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 |
Expand Down
119 changes: 119 additions & 0 deletions cmd/move.go
Original file line number Diff line number Diff line change
@@ -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 <name> <target-dir>",
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 <storage_path>/<name>.

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")
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
149 changes: 137 additions & 12 deletions internal/vm/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"path/filepath"
"strings"
"sync"
"syscall"
"time"

"go.uber.org/zap"
Expand Down Expand Up @@ -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-<vm>-<size>.<format>) 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 <storage_path>/<name>.
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-<vm>-<size>.<format>) 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)
Expand Down Expand Up @@ -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-<vm>-<size>.<format>).
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.
Expand Down
Loading
Loading