fix commit

This commit is contained in:
Joshua Bell 2026-01-27 00:50:16 -06:00
parent f98da9c152
commit d5811679d6
11 changed files with 685 additions and 2 deletions

2
.gitignore vendored
View file

@ -1,3 +1,3 @@
result
qvm
^qvm
vendor/

81
cmd/qvm/clean.go Normal file
View file

@ -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("")
},
}

38
cmd/qvm/main.go Normal file
View file

@ -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)
}
}

177
cmd/qvm/rebuild.go Normal file
View file

@ -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("")
}
},
}

56
cmd/qvm/reset.go Normal file
View file

@ -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("")
},
}

111
cmd/qvm/run.go Normal file
View file

@ -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 <command> [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)
}
},
}

86
cmd/qvm/ssh.go Normal file
View file

@ -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")
}

45
cmd/qvm/start.go Normal file
View file

@ -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")
},
}

54
cmd/qvm/status.go Normal file
View file

@ -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")
}
},
}

34
cmd/qvm/stop.go Normal file
View file

@ -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")
},
}

View file

@ -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" ];