diff --git a/.gitignore b/.gitignore index 185ccab..f55663a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ result -qvm +^qvm vendor/ diff --git a/cmd/qvm/clean.go b/cmd/qvm/clean.go new file mode 100644 index 0000000..ae44efd --- /dev/null +++ b/cmd/qvm/clean.go @@ -0,0 +1,81 @@ +package main + +import ( + "fmt" + "os" + "qvm/internal/config" + "qvm/internal/logging" + "qvm/internal/vm" + + "github.com/spf13/cobra" +) + +var cleanCmd = &cobra.Command{ + Use: "clean", + Short: "Remove all QVM data", + Long: `Completely remove all QVM state, images, and caches. + +This is a destructive operation that removes: +- Base image +- VM overlay and state +- Build caches (cargo, pnpm, sccache) +- Configuration and flake + +WARNING: This cannot be undone!`, + Run: func(cmd *cobra.Command, args []string) { + logging.Warn("This will delete ALL QVM data") + fmt.Printf(" - Data directory: %s\n", config.DataDir) + fmt.Printf(" - State directory: %s\n", config.StateDir) + fmt.Printf(" - Cache directory: %s\n", config.CacheDir) + fmt.Printf(" - Config directory: %s\n", config.ConfigDir) + fmt.Println("") + logging.Warn("This operation CANNOT be undone!") + fmt.Println("") + + fmt.Print("Are you absolutely sure? [y/N] ") + var response string + fmt.Scanln(&response) + + if response != "y" && response != "Y" { + logging.Info("Clean cancelled") + return + } + + if vm.IsRunning() { + logging.Info("Stopping VM...") + stopResult := vm.Stop() + if stopResult.IsError() { + logging.Error(stopResult.Error().Error()) + os.Exit(1) + } + } + + logging.Info("Removing QVM data directories...") + + dirs := []struct { + path string + name string + }{ + {config.DataDir, "Data directory"}, + {config.StateDir, "State directory"}, + {config.CacheDir, "Cache directory"}, + {config.ConfigDir, "Config directory"}, + } + + for _, dir := range dirs { + if _, err := os.Stat(dir.path); err == nil { + logging.Info(fmt.Sprintf(" - Deleting: %s", dir.path)) + if err := os.RemoveAll(dir.path); err != nil { + logging.Error(fmt.Sprintf("Failed to delete %s: %v", dir.name, err)) + os.Exit(1) + } + } + } + + logging.Info("QVM cleaned successfully!") + fmt.Println("") + fmt.Println("All QVM data has been removed from your system.") + fmt.Println("Next run of 'qvm start' will initialize everything from scratch.") + fmt.Println("") + }, +} diff --git a/cmd/qvm/main.go b/cmd/qvm/main.go new file mode 100644 index 0000000..8e353e7 --- /dev/null +++ b/cmd/qvm/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +const version = "0.1.0-dev" + +var rootCmd = &cobra.Command{ + Use: "qvm", + Short: "QVM - QEMU Development VM Manager", + Long: `QVM is a lightweight CLI tool for running commands in an isolated +NixOS VM with persistent state and shared caches. + +Provides VM isolation with transparent workspace mounting and shared build caches.`, + Version: version, +} + +func init() { + rootCmd.AddCommand(startCmd) + rootCmd.AddCommand(stopCmd) + rootCmd.AddCommand(statusCmd) + rootCmd.AddCommand(sshCmd) + rootCmd.AddCommand(runCmd) + rootCmd.AddCommand(rebuildCmd) + rootCmd.AddCommand(resetCmd) + rootCmd.AddCommand(cleanCmd) +} + +func main() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/qvm/rebuild.go b/cmd/qvm/rebuild.go new file mode 100644 index 0000000..a96ac6f --- /dev/null +++ b/cmd/qvm/rebuild.go @@ -0,0 +1,177 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "qvm/internal/config" + "qvm/internal/logging" + "qvm/internal/vm" + + "github.com/spf13/cobra" +) + +// DefaultFlakePath is the path to the bundled default flake (set at build time or relative to binary) +var DefaultFlakePath string + +func init() { + // Try to find the default flake relative to the executable + if exe, err := os.Executable(); err == nil { + // When installed via Nix, the flake is at ../share/qvm/default-vm relative to bin/qvm + shareDir := filepath.Join(filepath.Dir(filepath.Dir(exe)), "share", "qvm", "default-vm") + if _, err := os.Stat(filepath.Join(shareDir, "flake.nix")); err == nil { + DefaultFlakePath = shareDir + } + } + // Fallback for development: look in the project directory + if DefaultFlakePath == "" { + // Check common development paths + candidates := []string{ + "flake/default-vm", + "../flake/default-vm", + filepath.Join(os.Getenv("HOME"), "projects/qvm/flake/default-vm"), + } + for _, candidate := range candidates { + if _, err := os.Stat(filepath.Join(candidate, "flake.nix")); err == nil { + DefaultFlakePath = candidate + break + } + } + } +} + +func ensureUserFlake() error { + // Check if user flake already exists + if _, err := os.Stat(filepath.Join(config.UserFlake, "flake.nix")); err == nil { + return nil // Already exists + } + + // Need to copy default flake + if DefaultFlakePath == "" { + return fmt.Errorf("default flake not found - please copy a flake to %s", config.UserFlake) + } + + logging.Info("Copying default flake to user config...") + + // Create the user flake directory + if err := os.MkdirAll(config.UserFlake, 0755); err != nil { + return fmt.Errorf("failed to create flake directory: %w", err) + } + + // Copy flake.nix + srcFlake := filepath.Join(DefaultFlakePath, "flake.nix") + dstFlake := filepath.Join(config.UserFlake, "flake.nix") + if err := copyFile(srcFlake, dstFlake); err != nil { + return fmt.Errorf("failed to copy flake.nix: %w", err) + } + + // Copy flake.lock if it exists + srcLock := filepath.Join(DefaultFlakePath, "flake.lock") + dstLock := filepath.Join(config.UserFlake, "flake.lock") + if _, err := os.Stat(srcLock); err == nil { + if err := copyFile(srcLock, dstLock); err != nil { + return fmt.Errorf("failed to copy flake.lock: %w", err) + } + } + + logging.Info(fmt.Sprintf("Default flake copied to %s", config.UserFlake)) + return nil +} + +func copyFile(src, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dst, data, 0644) +} + +var rebuildCmd = &cobra.Command{ + Use: "rebuild", + Short: "Rebuild the base VM image", + Long: `Rebuild the base VM image from the NixOS flake. + +Runs 'nix build' on ~/.config/qvm/flake and copies +the result to the base image location. + +If the flake doesn't exist, the default flake will be +copied to ~/.config/qvm/flake first. + +If the VM is running, you'll need to restart it to +use the new image.`, + Run: func(cmd *cobra.Command, args []string) { + logging.Info("Rebuilding base VM image...") + + if err := config.EnsureDirs(); err != nil { + logging.Error(fmt.Sprintf("Failed to create directories: %v", err)) + os.Exit(1) + } + + // Ensure user flake exists, copy default if not + if err := ensureUserFlake(); err != nil { + logging.Error(fmt.Sprintf("Failed to ensure user flake: %v", err)) + os.Exit(1) + } + + logging.Info("Building disk image from flake...") + + diskImageLink := filepath.Join(config.StateDir, "disk-image-result") + nixCmd := exec.Command("nix", "build", config.UserFlake+"#default", "--out-link", diskImageLink, "-L") + nixCmd.Stdout = os.Stdout + nixCmd.Stderr = os.Stderr + + if err := nixCmd.Run(); err != nil { + logging.Error(fmt.Sprintf("Failed to build disk image: %v", err)) + os.Exit(1) + } + + logging.Info("Copying qcow2 image to base image location...") + + qcowSource := filepath.Join(diskImageLink, "nixos.qcow2") + if _, err := os.Stat(qcowSource); os.IsNotExist(err) { + logging.Error(fmt.Sprintf("Built image missing nixos.qcow2 at %s", qcowSource)) + os.Exit(1) + } + + os.Remove(config.BaseImage) + + cpCmd := exec.Command("cp", qcowSource, config.BaseImage) + if err := cpCmd.Run(); err != nil { + logging.Error(fmt.Sprintf("Failed to copy base image: %v", err)) + os.Exit(1) + } + + logging.Info("Base disk image built successfully") + + logging.Info("Building VM runner script from flake...") + + vmRunnerLink := filepath.Join(config.StateDir, "vm-runner-result") + nixCmd2 := exec.Command("nix", "build", config.UserFlake+"#vm", "--out-link", vmRunnerLink, "-L") + nixCmd2.Stdout = os.Stdout + nixCmd2.Stderr = os.Stderr + + if err := nixCmd2.Run(); err != nil { + logging.Error(fmt.Sprintf("Failed to build VM runner: %v", err)) + os.Exit(1) + } + + os.Remove(config.VMRunner) + if err := os.Symlink(vmRunnerLink, config.VMRunner); err != nil { + logging.Error(fmt.Sprintf("Failed to create VM runner symlink: %v", err)) + os.Exit(1) + } + + logging.Info("VM runner script built successfully") + logging.Info("Rebuild complete!") + + if vm.IsRunning() { + logging.Warn("VM is currently running") + fmt.Println("") + fmt.Println("The new base image will only take effect after restarting the VM:") + fmt.Println(" qvm stop") + fmt.Println(" qvm start") + fmt.Println("") + } + }, +} diff --git a/cmd/qvm/reset.go b/cmd/qvm/reset.go new file mode 100644 index 0000000..ae87b9f --- /dev/null +++ b/cmd/qvm/reset.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "os" + "qvm/internal/config" + "qvm/internal/logging" + "qvm/internal/vm" + + "github.com/spf13/cobra" +) + +var resetCmd = &cobra.Command{ + Use: "reset", + Short: "Reset VM to fresh state", + Long: `Delete the VM overlay and workspace registry. + +This returns the VM to a clean state based on the base image. +The base image itself is not deleted. + +All cache directories are preserved.`, + Run: func(cmd *cobra.Command, args []string) { + logging.Info("Resetting VM state...") + + if vm.IsRunning() { + logging.Info("Stopping VM...") + stopResult := vm.Stop() + if stopResult.IsError() { + logging.Error(stopResult.Error().Error()) + os.Exit(1) + } + } + + if _, err := os.Stat(config.Overlay); err == nil { + logging.Info(fmt.Sprintf("Deleting overlay: %s", config.Overlay)) + if err := os.Remove(config.Overlay); err != nil { + logging.Error(fmt.Sprintf("Failed to delete overlay: %v", err)) + os.Exit(1) + } + } + + if _, err := os.Stat(config.WorkspacesFile); err == nil { + logging.Info(fmt.Sprintf("Deleting workspaces registry: %s", config.WorkspacesFile)) + if err := os.Remove(config.WorkspacesFile); err != nil { + logging.Error(fmt.Sprintf("Failed to delete workspaces: %v", err)) + os.Exit(1) + } + } + + logging.Info("Reset complete!") + fmt.Println("") + fmt.Println("Next steps:") + fmt.Println(" - Run 'qvm start' to boot the VM with a fresh overlay") + fmt.Println("") + }, +} diff --git a/cmd/qvm/run.go b/cmd/qvm/run.go new file mode 100644 index 0000000..bfa0f64 --- /dev/null +++ b/cmd/qvm/run.go @@ -0,0 +1,111 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "qvm/internal/config" + "qvm/internal/logging" + "qvm/internal/vm" + "qvm/internal/workspace" + "strconv" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var runCmd = &cobra.Command{ + Use: "run [args...]", + Short: "Run a command in the VM workspace", + Long: `Execute a command in the VM at the current directory. + +The current directory is automatically registered as a workspace +and mounted into the VM. The command runs in the mounted workspace.`, + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + cwd, err := os.Getwd() + if err != nil { + logging.Error(fmt.Sprintf("Failed to get current directory: %v", err)) + os.Exit(1) + } + + regResult := workspace.Load(config.WorkspacesFile) + if regResult.IsError() { + logging.Error(regResult.Error().Error()) + os.Exit(1) + } + reg := regResult.MustGet() + + wsResult := reg.Register(cwd) + if wsResult.IsError() { + logging.Error(wsResult.Error().Error()) + os.Exit(1) + } + ws := wsResult.MustGet() + + saveResult := reg.Save() + if saveResult.IsError() { + logging.Error(saveResult.Error().Error()) + os.Exit(1) + } + + wasRunning := vm.IsRunning() + + if !wasRunning { + logging.Info("VM is not running, starting it...") + cfg, err := config.Load() + if err != nil { + logging.Error(err.Error()) + os.Exit(1) + } + + startResult := vm.Start(cfg, reg) + if startResult.IsError() { + logging.Error(startResult.Error().Error()) + os.Exit(1) + } + } + + statusResult := vm.Status() + if statusResult.IsError() { + logging.Error(statusResult.Error().Error()) + os.Exit(1) + } + status := statusResult.MustGet() + + remoteCmd := fmt.Sprintf("mkdir -p '%s' && mount -t 9p ws_%s '%s' -o trans=virtio,version=9p2000.L,msize=104857600 2>/dev/null || true && cd '%s' && %s", + ws.GuestPath, ws.Hash, ws.GuestPath, ws.GuestPath, strings.Join(args, " ")) + + sshArgs := []string{ + "-p", "root", + "ssh", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", + } + + // Allocate TTY for interactive commands + if term.IsTerminal(int(os.Stdin.Fd())) { + sshArgs = append(sshArgs, "-t") + } + + sshArgs = append(sshArgs, + "-p", strconv.Itoa(status.SSHPort), + "root@localhost", + remoteCmd, + ) + + sshpassCmd := exec.Command("sshpass", sshArgs...) + sshpassCmd.Stdin = os.Stdin + sshpassCmd.Stdout = os.Stdout + sshpassCmd.Stderr = os.Stderr + + if err := sshpassCmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + os.Exit(1) + } + }, +} diff --git a/cmd/qvm/ssh.go b/cmd/qvm/ssh.go new file mode 100644 index 0000000..170c774 --- /dev/null +++ b/cmd/qvm/ssh.go @@ -0,0 +1,86 @@ +package main + +import ( + "os" + "os/exec" + "qvm/internal/config" + "qvm/internal/logging" + "qvm/internal/vm" + "qvm/internal/workspace" + "strconv" + + "github.com/spf13/cobra" +) + +var ( + sshCommand string +) + +var sshCmd = &cobra.Command{ + Use: "ssh", + Short: "SSH into the VM", + Long: `Open an SSH session to the running VM. + +By default opens an interactive shell. +Use -c to run a single command.`, + Run: func(cmd *cobra.Command, args []string) { + if !vm.IsRunning() { + logging.Info("VM is not running, starting it...") + cfg, err := config.Load() + if err != nil { + logging.Error(err.Error()) + os.Exit(1) + } + + regResult := workspace.Load(config.WorkspacesFile) + if regResult.IsError() { + logging.Error(regResult.Error().Error()) + os.Exit(1) + } + reg := regResult.MustGet() + + startResult := vm.Start(cfg, reg) + if startResult.IsError() { + logging.Error(startResult.Error().Error()) + os.Exit(1) + } + } + + statusResult := vm.Status() + if statusResult.IsError() { + logging.Error(statusResult.Error().Error()) + os.Exit(1) + } + status := statusResult.MustGet() + + sshArgs := []string{ + "-p", "root", + "ssh", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", + "-p", strconv.Itoa(status.SSHPort), + "root@localhost", + } + + if sshCommand != "" { + sshArgs = append(sshArgs, sshCommand) + } + + sshpassCmd := exec.Command("sshpass", sshArgs...) + sshpassCmd.Stdin = os.Stdin + sshpassCmd.Stdout = os.Stdout + sshpassCmd.Stderr = os.Stderr + + if err := sshpassCmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + os.Exit(1) + } + }, +} + +func init() { + sshCmd.Flags().StringVarP(&sshCommand, "command", "c", "", "Command to run instead of interactive shell") +} diff --git a/cmd/qvm/start.go b/cmd/qvm/start.go new file mode 100644 index 0000000..864d51d --- /dev/null +++ b/cmd/qvm/start.go @@ -0,0 +1,45 @@ +package main + +import ( + "os" + "qvm/internal/config" + "qvm/internal/logging" + "qvm/internal/vm" + "qvm/internal/workspace" + + "github.com/spf13/cobra" +) + +var startCmd = &cobra.Command{ + Use: "start", + Short: "Start the VM", + Long: `Start the QVM virtual machine. + +Creates base image and overlay if they don't exist. +Mounts all registered workspaces and cache directories. +Waits for SSH to become available.`, + Run: func(cmd *cobra.Command, args []string) { + cfg, err := config.Load() + if err != nil { + logging.Error(err.Error()) + os.Exit(1) + } + + regResult := workspace.Load(config.WorkspacesFile) + if regResult.IsError() { + logging.Error(regResult.Error().Error()) + os.Exit(1) + } + reg := regResult.MustGet() + + logging.Info("Starting VM...") + + result := vm.Start(cfg, reg) + if result.IsError() { + logging.Error(result.Error().Error()) + os.Exit(1) + } + + logging.Info("VM started successfully") + }, +} diff --git a/cmd/qvm/status.go b/cmd/qvm/status.go new file mode 100644 index 0000000..fbcf6a1 --- /dev/null +++ b/cmd/qvm/status.go @@ -0,0 +1,54 @@ +package main + +import ( + "fmt" + "os" + "qvm/internal/config" + "qvm/internal/logging" + "qvm/internal/vm" + "qvm/internal/workspace" + + "github.com/spf13/cobra" +) + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Show VM status", + Long: `Display VM state, SSH port, and mounted workspaces. + +Shows current running state, PID, SSH connection info, +and list of registered workspaces.`, + Run: func(cmd *cobra.Command, args []string) { + statusResult := vm.Status() + if statusResult.IsError() { + logging.Error(statusResult.Error().Error()) + os.Exit(1) + } + status := statusResult.MustGet() + + if status.Running { + fmt.Printf("VM Status: Running\n") + fmt.Printf("PID: %d\n", status.PID) + fmt.Printf("SSH Port: %d\n", status.SSHPort) + } else { + fmt.Printf("VM Status: Stopped\n") + } + + regResult := workspace.Load(config.WorkspacesFile) + if regResult.IsError() { + logging.Error(regResult.Error().Error()) + os.Exit(1) + } + reg := regResult.MustGet() + + workspaces := reg.List() + if len(workspaces) > 0 { + fmt.Printf("\nMounted Workspaces:\n") + for _, ws := range workspaces { + fmt.Printf(" %s -> %s\n", ws.Hash, ws.HostPath) + } + } else { + fmt.Printf("\nMounted Workspaces:\n (none)\n") + } + }, +} diff --git a/cmd/qvm/stop.go b/cmd/qvm/stop.go new file mode 100644 index 0000000..9405f99 --- /dev/null +++ b/cmd/qvm/stop.go @@ -0,0 +1,34 @@ +package main + +import ( + "os" + "qvm/internal/logging" + "qvm/internal/vm" + + "github.com/spf13/cobra" +) + +var stopCmd = &cobra.Command{ + Use: "stop", + Short: "Stop the VM", + Long: `Gracefully shut down the running VM. + +Sends ACPI shutdown signal and waits up to 30 seconds. +Force kills if timeout is exceeded.`, + Run: func(cmd *cobra.Command, args []string) { + if !vm.IsRunning() { + logging.Info("VM is not running") + return + } + + logging.Info("Stopping VM...") + + result := vm.Stop() + if result.IsError() { + logging.Error(result.Error().Error()) + os.Exit(1) + } + + logging.Info("VM stopped successfully") + }, +} diff --git a/flake.nix b/flake.nix index bd1f728..c7a7b39 100644 --- a/flake.nix +++ b/flake.nix @@ -62,7 +62,8 @@ # For development, use: go build ./cmd/qvm src = ./.; - vendorHash = "sha256-d6Z32nPDawwFqhKfVw/QwHUuDuMuTdQdHApmxcXzFng="; + # vendorHash = pkgs.lib.fakeHash; + vendorHash = "sha256-G/L3vzOampQK6vB12DdotBB/T8ojNkkrIy+8tQgQTI4="; subPackages = [ "cmd/qvm" ];