Add initial QVM CLI, Nix flake, scripts and README
This commit is contained in:
parent
25b1cca0e6
commit
8534f7efb9
14 changed files with 2359 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.sisyphus
|
||||
549
README.md
Normal file
549
README.md
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
# QVM - Lightweight QEMU Development VM Wrapper
|
||||
|
||||
A standalone CLI tool for running commands in an isolated NixOS VM with persistent state and shared caches.
|
||||
|
||||
## Motivation
|
||||
|
||||
Running AI coding agents in isolation presents a security challenge. Containers provide some isolation, but kernel exploits remain a real attack surface. QVM uses full VM isolation for stronger security guarantees while maintaining developer ergonomics.
|
||||
|
||||
**Why QVM?**
|
||||
|
||||
- **VM isolation over container isolation** - Hypervisor boundary is fundamentally stronger than kernel namespaces
|
||||
- **One master image, shared caches** - Single ~7GB base image instead of per-project images
|
||||
- **Transparent workspace mounting** - Current directory automatically available in VM
|
||||
- **Persistent state** - VM overlay preserves installed tools and configuration
|
||||
- **Shared build caches** - Cargo, pnpm, and sccache caches shared across all projects
|
||||
|
||||
**Primary use case:** Running AI coding agents (opencode, aider, cursor) in isolation to prevent unintended host filesystem access while maintaining build cache performance.
|
||||
|
||||
## Installation
|
||||
|
||||
### System-wide installation (NixOS flake)
|
||||
|
||||
Add QVM to your NixOS configuration:
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs.qvm.url = "github:yourusername/qvm";
|
||||
|
||||
environment.systemPackages = [
|
||||
inputs.qvm.packages.${system}.default
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Direct execution
|
||||
|
||||
Run without installation:
|
||||
|
||||
```bash
|
||||
nix run github:yourusername/qvm -- start
|
||||
```
|
||||
|
||||
### Development shell
|
||||
|
||||
For local development:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/qvm
|
||||
cd qvm
|
||||
nix develop
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Start the VM (auto-builds base image on first run)
|
||||
qvm start
|
||||
|
||||
# Run commands in VM with current directory mounted
|
||||
qvm run opencode
|
||||
qvm run cargo build --release
|
||||
qvm run npm install
|
||||
|
||||
# Interactive SSH session
|
||||
qvm ssh
|
||||
|
||||
# Check VM status
|
||||
qvm status
|
||||
|
||||
# Stop VM
|
||||
qvm stop
|
||||
```
|
||||
|
||||
The first `qvm start` will build the base image, which takes several minutes. Subsequent starts are fast (3-5 seconds).
|
||||
|
||||
## Commands
|
||||
|
||||
### qvm start
|
||||
|
||||
Start the VM daemon. Creates base image and overlay if they don't exist.
|
||||
|
||||
```bash
|
||||
qvm start
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Checks if VM already running (no-op if yes)
|
||||
- Builds base image if missing (via `qvm rebuild`)
|
||||
- Creates overlay.qcow2 if missing (copy-on-write backed by base image)
|
||||
- Launches QEMU with KVM acceleration
|
||||
- Mounts all registered workspaces and cache directories via 9p
|
||||
- Waits for SSH to become available
|
||||
- Exits when SSH is ready (VM runs as background daemon)
|
||||
|
||||
### qvm stop
|
||||
|
||||
Gracefully stop the running VM.
|
||||
|
||||
```bash
|
||||
qvm stop
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Sends ACPI shutdown signal to VM
|
||||
- Waits up to 30 seconds for graceful shutdown
|
||||
- Force kills QEMU if timeout exceeded
|
||||
- Cleans up PID file
|
||||
- No-op if VM not running
|
||||
|
||||
### qvm run
|
||||
|
||||
Execute a command in the VM with current directory mounted as workspace.
|
||||
|
||||
```bash
|
||||
qvm run <command> [args...]
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
qvm run cargo build
|
||||
qvm run npm install
|
||||
qvm run "ls -la"
|
||||
qvm run bash # Interactive shell in VM
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
1. Generates hash from current directory absolute path
|
||||
2. Registers workspace in `~/.local/state/qvm/workspaces.json`
|
||||
3. Auto-starts VM if not running
|
||||
4. Checks if workspace is mounted (warns to restart VM if newly registered)
|
||||
5. SSHs into VM and executes: `cd /workspace/{hash} && <command>`
|
||||
6. Streams stdout/stderr to terminal
|
||||
7. Exits with command's exit code
|
||||
8. VM stays running for next command
|
||||
|
||||
**Note:** Workspaces are mounted at VM startup. If you run from a new directory, you'll need to restart the VM to mount it.
|
||||
|
||||
### qvm ssh
|
||||
|
||||
Open interactive SSH session or run command in VM.
|
||||
|
||||
```bash
|
||||
qvm ssh # Interactive shell
|
||||
qvm ssh -c "command" # Run single command
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
qvm ssh # Drop into root shell
|
||||
qvm ssh -c "systemctl status" # Check systemd status
|
||||
qvm ssh -c "df -h" # Check disk usage
|
||||
```
|
||||
|
||||
Connects as root user. Password: `root` (if needed, though SSH key auth is configured).
|
||||
|
||||
### qvm status
|
||||
|
||||
Show VM state, SSH port, and mounted workspaces.
|
||||
|
||||
```bash
|
||||
qvm status
|
||||
```
|
||||
|
||||
**Example output:**
|
||||
```
|
||||
VM Status: Running
|
||||
PID: 12345
|
||||
SSH Port: 2222
|
||||
|
||||
Mounted Workspaces:
|
||||
abc12345 -> /home/josh/projects/qvm
|
||||
def67890 -> /home/josh/projects/myapp
|
||||
|
||||
Cache Directories:
|
||||
/cache/cargo -> ~/.cache/qvm/cargo-home
|
||||
/cache/target -> ~/.cache/qvm/cargo-target
|
||||
/cache/pnpm -> ~/.cache/qvm/pnpm-store
|
||||
/cache/sccache -> ~/.cache/qvm/sccache
|
||||
```
|
||||
|
||||
### qvm rebuild
|
||||
|
||||
Rebuild the base VM image from NixOS flake.
|
||||
|
||||
```bash
|
||||
qvm rebuild
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Runs `nix build` on `~/.config/qvm/flake`
|
||||
- Copies result to `~/.local/share/qvm/base.qcow2`
|
||||
- Warns if VM is running (restart required to use new image)
|
||||
|
||||
**Use when:**
|
||||
- Customizing VM configuration (edited `~/.config/qvm/flake/flake.nix`)
|
||||
- Updating base image with new packages or settings
|
||||
- Pulling latest changes from upstream NixOS
|
||||
|
||||
### qvm reset
|
||||
|
||||
Delete overlay and start fresh. Keeps base image intact.
|
||||
|
||||
```bash
|
||||
qvm reset
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Stops VM if running
|
||||
- Deletes `overlay.qcow2` (all VM state changes)
|
||||
- Deletes `workspaces.json` (registered workspaces)
|
||||
- Next `qvm start` creates fresh overlay from base image
|
||||
|
||||
**Use when:**
|
||||
- VM state is corrupted
|
||||
- Want to return to clean base image state
|
||||
- Testing fresh install scenarios
|
||||
|
||||
## Directory Layout
|
||||
|
||||
QVM uses XDG-compliant directories:
|
||||
|
||||
```
|
||||
~/.config/qvm/
|
||||
└── flake/ # User's customizable NixOS flake
|
||||
├── flake.nix # VM system configuration
|
||||
└── flake.lock # Pinned dependencies
|
||||
|
||||
~/.local/share/qvm/
|
||||
└── base.qcow2 # Base VM image (~7GB, read-only)
|
||||
|
||||
~/.local/state/qvm/
|
||||
├── overlay.qcow2 # Persistent VM state (copy-on-write)
|
||||
├── vm.pid # QEMU process ID
|
||||
├── ssh.port # SSH forwarded port
|
||||
├── serial.log # VM console output
|
||||
└── workspaces.json # Registered workspace mounts
|
||||
|
||||
~/.cache/qvm/
|
||||
├── cargo-home/ # Shared cargo registry/cache
|
||||
├── cargo-target/ # Shared cargo build artifacts
|
||||
├── pnpm-store/ # Shared pnpm content-addressable store
|
||||
└── sccache/ # Shared compilation cache
|
||||
```
|
||||
|
||||
### Inside the VM
|
||||
|
||||
```
|
||||
/workspace/
|
||||
├── abc12345/ # Mounted from /home/josh/projects/qvm
|
||||
└── def67890/ # Mounted from /home/josh/projects/myapp
|
||||
|
||||
/cache/
|
||||
├── cargo/ # Mounted from ~/.cache/qvm/cargo-home
|
||||
├── target/ # Mounted from ~/.cache/qvm/cargo-target
|
||||
├── pnpm/ # Mounted from ~/.cache/qvm/pnpm-store
|
||||
└── sccache/ # Mounted from ~/.cache/qvm/sccache
|
||||
```
|
||||
|
||||
Environment variables in VM:
|
||||
- `CARGO_HOME=/cache/cargo`
|
||||
- `CARGO_TARGET_DIR=/cache/target`
|
||||
- `PNPM_HOME=/cache/pnpm`
|
||||
- `SCCACHE_DIR=/cache/sccache`
|
||||
|
||||
## Workspace Management
|
||||
|
||||
When you run `qvm run` from different directories, each gets registered and mounted:
|
||||
|
||||
```bash
|
||||
cd ~/projects/myapp
|
||||
qvm run cargo build # Workspace abc12345
|
||||
|
||||
cd ~/projects/other
|
||||
qvm run npm install # Workspace def67890
|
||||
```
|
||||
|
||||
Both workspaces are mounted simultaneously in the VM. The hash is derived from the absolute path, so the same directory always maps to the same workspace ID.
|
||||
|
||||
**Important:** Workspaces are mounted at VM start time. If you run from a new directory:
|
||||
1. Workspace gets registered in `workspaces.json`
|
||||
2. `qvm run` will detect it's not mounted and warn you
|
||||
3. Restart VM to mount: `qvm stop && qvm start`
|
||||
|
||||
## Cache Sharing
|
||||
|
||||
Build caches are shared between host and VM via 9p mounts:
|
||||
|
||||
**Cargo:**
|
||||
- Registry and crate cache shared across all projects
|
||||
- Each project still uses its own `Cargo.lock`
|
||||
- Different versions coexist peacefully
|
||||
|
||||
**pnpm:**
|
||||
- Content-addressable store shared
|
||||
- Each project links to shared store
|
||||
- Massive disk space savings
|
||||
|
||||
**sccache:**
|
||||
- Compilation cache shared
|
||||
- Speeds up repeated builds across projects
|
||||
|
||||
All caches persist across VM restarts and resets (they live in `~/.cache/qvm`, not in the overlay).
|
||||
|
||||
## Customization
|
||||
|
||||
### Editing VM Configuration
|
||||
|
||||
The VM is defined by a NixOS flake at `~/.config/qvm/flake/flake.nix`. Edit this file to customize the VM.
|
||||
|
||||
**Default packages included:**
|
||||
- git, vim, tmux, htop
|
||||
- curl, wget, jq, ripgrep, fd
|
||||
- opencode (AI coding agent)
|
||||
- Language toolchains can be added
|
||||
|
||||
**Example: Add Rust and Node.js:**
|
||||
|
||||
```nix
|
||||
environment.systemPackages = with pkgs; [
|
||||
# ... existing packages ...
|
||||
|
||||
# Add language toolchains
|
||||
rustc
|
||||
cargo
|
||||
nodejs_22
|
||||
python3
|
||||
];
|
||||
```
|
||||
|
||||
**Example: Add custom shell aliases:**
|
||||
|
||||
```nix
|
||||
environment.shellAliases = {
|
||||
"ll" = "ls -lah";
|
||||
"gst" = "git status";
|
||||
};
|
||||
```
|
||||
|
||||
**Example: Include your dotfiles:**
|
||||
|
||||
```nix
|
||||
environment.etc."vimrc".source = /path/to/your/vimrc;
|
||||
```
|
||||
|
||||
**Apply changes:**
|
||||
|
||||
```bash
|
||||
# Edit the flake
|
||||
vim ~/.config/qvm/flake/flake.nix
|
||||
|
||||
# Rebuild base image
|
||||
qvm rebuild
|
||||
|
||||
# Restart VM to use new image
|
||||
qvm stop
|
||||
qvm start
|
||||
```
|
||||
|
||||
### Resource Allocation
|
||||
|
||||
Default resources:
|
||||
- **Memory:** 8GB
|
||||
- **CPUs:** 4 cores
|
||||
- **Disk:** 20GB
|
||||
|
||||
To customize, set environment variables before `qvm start`:
|
||||
|
||||
```bash
|
||||
export QVM_MEMORY="16G"
|
||||
export QVM_CPUS="8"
|
||||
qvm start
|
||||
```
|
||||
|
||||
These will be configurable in a config file in future versions.
|
||||
|
||||
## Security Model
|
||||
|
||||
### VM Isolation Benefits
|
||||
|
||||
**Why VM over containers?**
|
||||
- Container escapes via kernel exploits are well-documented
|
||||
- VM escapes require hypervisor exploits (far rarer)
|
||||
- For long-running unattended AI sessions, VM isolation provides stronger guarantees
|
||||
|
||||
### 9p Mount Security
|
||||
|
||||
Filesystem sharing uses `security_model=mapped-xattr`:
|
||||
- No direct passthrough to host filesystem
|
||||
- Only explicitly mounted directories are visible
|
||||
- Host filesystem outside mounts is completely invisible to VM
|
||||
- File ownership and permissions mapped via extended attributes
|
||||
|
||||
**What the VM can access:**
|
||||
- Registered workspaces (directories you ran `qvm run` from)
|
||||
- Shared cache directories
|
||||
- Nothing else on the host
|
||||
|
||||
**What the VM cannot access:**
|
||||
- Your home directory (except mounted workspaces)
|
||||
- System directories
|
||||
- Other users' files
|
||||
- Any unmounted host paths
|
||||
|
||||
### Network Isolation
|
||||
|
||||
VM uses QEMU user-mode networking:
|
||||
- VM can make outbound connections
|
||||
- No inbound connections to VM except forwarded SSH port
|
||||
- SSH port forwarded to random high port on localhost only
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### VM won't start
|
||||
|
||||
Check if QEMU/KVM is available:
|
||||
|
||||
```bash
|
||||
qemu-system-x86_64 --version
|
||||
lsmod | grep kvm
|
||||
```
|
||||
|
||||
Check the serial log for errors:
|
||||
|
||||
```bash
|
||||
cat ~/.local/state/qvm/serial.log
|
||||
```
|
||||
|
||||
### SSH timeout
|
||||
|
||||
If SSH doesn't become ready within 60 seconds:
|
||||
|
||||
1. Check if VM process is running: `ps aux | grep qemu`
|
||||
2. Check serial log: `cat ~/.local/state/qvm/serial.log`
|
||||
3. Try increasing timeout (future feature)
|
||||
|
||||
### Workspace not mounted
|
||||
|
||||
If you see "Workspace not mounted in VM":
|
||||
|
||||
```bash
|
||||
qvm stop
|
||||
qvm start
|
||||
qvm run <command>
|
||||
```
|
||||
|
||||
Workspaces must be registered before VM start.
|
||||
|
||||
### Build cache not working
|
||||
|
||||
Verify cache directories exist and are mounted:
|
||||
|
||||
```bash
|
||||
qvm ssh -c "ls -la /cache"
|
||||
qvm ssh -c "echo \$CARGO_HOME"
|
||||
```
|
||||
|
||||
Check that cache directories on host exist:
|
||||
|
||||
```bash
|
||||
ls -la ~/.cache/qvm/
|
||||
```
|
||||
|
||||
### VM is slow
|
||||
|
||||
Ensure KVM is enabled (not using emulation):
|
||||
|
||||
```bash
|
||||
lsmod | grep kvm_intel # or kvm_amd
|
||||
```
|
||||
|
||||
Check resource allocation:
|
||||
|
||||
```bash
|
||||
qvm status # Shows allocated CPUs/memory (future feature)
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
**Explicit exclusions:**
|
||||
- **Multi-VM:** Only one VM at a time
|
||||
- **Per-project configs:** Single global VM (use other tools for project-specific VMs)
|
||||
- **Cross-platform:** Linux + KVM only (no macOS/Windows)
|
||||
- **GUI:** Headless only, no desktop environment
|
||||
- **Snapshots:** Only overlay reset, no checkpoint/restore
|
||||
|
||||
## Dependencies
|
||||
|
||||
Required on host system:
|
||||
- `qemu` (with KVM support)
|
||||
- `nix` (for building VM images)
|
||||
- `openssh` (SSH client)
|
||||
- `jq` (JSON processing)
|
||||
- `nc` (netcat, for port checking)
|
||||
|
||||
All dependencies are included automatically when installing via Nix flake.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
HOST VM
|
||||
──────────────────────────────── ──────────────────────────
|
||||
|
||||
~/.local/share/qvm/base.qcow2 → (read-only base image)
|
||||
↓
|
||||
~/.local/state/qvm/overlay.qcow2 → (persistent changes)
|
||||
|
||||
|
||||
~/.cache/qvm/cargo-home/ ──9p──→ /cache/cargo/
|
||||
~/.cache/qvm/cargo-target/ ──9p──→ /cache/target/
|
||||
~/.cache/qvm/pnpm-store/ ──9p──→ /cache/pnpm/
|
||||
~/.cache/qvm/sccache/ ──9p──→ /cache/sccache/
|
||||
|
||||
$(pwd) ──9p──→ /workspace/{hash}/
|
||||
```
|
||||
|
||||
**Image layering:**
|
||||
- Base image contains NixOS system from flake
|
||||
- Overlay is copy-on-write layer for runtime changes
|
||||
- `qvm reset` deletes overlay, preserves base
|
||||
- `qvm rebuild` updates base, keeps overlay
|
||||
|
||||
**9p virtfs mounts:**
|
||||
- Used for workspace and cache sharing
|
||||
- `security_model=mapped-xattr` for security
|
||||
- `msize=104857600` for performance (100MB transfer size)
|
||||
- Mounts configured at VM start, no hotplug
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions welcome! This is a simple Bash-based tool designed to be readable and hackable.
|
||||
|
||||
**Key files:**
|
||||
- `bin/qvm` - Main dispatcher
|
||||
- `bin/qvm-*` - Subcommand implementations
|
||||
- `lib/common.sh` - Shared utilities and paths
|
||||
- `flake/default-vm/flake.nix` - Default VM template
|
||||
|
||||
**Development:**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/qvm
|
||||
cd qvm
|
||||
nix develop
|
||||
./bin/qvm start
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
94
bin/qvm
Executable file
94
bin/qvm
Executable file
|
|
@ -0,0 +1,94 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# qvm - Main dispatcher for QVM (QEMU Development VM) commands
|
||||
#
|
||||
# This script routes subcommands to their respective qvm-* implementations.
|
||||
# It sources common.sh for shared configuration and utility functions.
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Source common library (use QVM_LIB_DIR from wrapper or relative path for dev)
|
||||
source "${QVM_LIB_DIR:-$(dirname "$0")/../lib}/common.sh"
|
||||
|
||||
readonly VERSION="0.1.0"
|
||||
|
||||
#
|
||||
# show_help - Display usage information
|
||||
#
|
||||
show_help() {
|
||||
cat <<EOF
|
||||
qvm - QEMU Development VM Manager
|
||||
|
||||
USAGE:
|
||||
qvm <command> [args...]
|
||||
|
||||
COMMANDS:
|
||||
start Start the VM (create if needed)
|
||||
stop Stop the running VM
|
||||
run Execute a command in the VM
|
||||
ssh Open SSH session or run command in VM
|
||||
status Show VM status and information
|
||||
rebuild Rebuild the base VM image from flake
|
||||
reset Delete overlay and start fresh (keeps base image)
|
||||
|
||||
OPTIONS:
|
||||
-h, --help Show this help message
|
||||
-v, --version Show version information
|
||||
|
||||
EXAMPLES:
|
||||
qvm start Start the VM
|
||||
qvm ssh Open interactive SSH session
|
||||
qvm run 'ls -la' Run command in VM
|
||||
qvm status Check if VM is running
|
||||
qvm stop Stop the VM
|
||||
|
||||
For more information on a specific command, run:
|
||||
qvm <command> --help
|
||||
EOF
|
||||
}
|
||||
|
||||
#
|
||||
# show_version - Display version information
|
||||
#
|
||||
show_version() {
|
||||
echo "qvm version ${VERSION}"
|
||||
}
|
||||
|
||||
#
|
||||
# main - Parse arguments and route to subcommand
|
||||
#
|
||||
main() {
|
||||
# Handle no arguments
|
||||
if [[ $# -eq 0 ]]; then
|
||||
show_help
|
||||
exit 0
|
||||
fi
|
||||
|
||||
local subcommand="$1"
|
||||
shift
|
||||
|
||||
case "$subcommand" in
|
||||
start|stop|run|ssh|status|rebuild|reset)
|
||||
# Route to the appropriate qvm-* script
|
||||
# Use exec to replace this process with the subcommand
|
||||
exec "qvm-${subcommand}" "$@"
|
||||
;;
|
||||
help|--help|-h)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
--version|-v)
|
||||
show_version
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown command: ${subcommand}"
|
||||
echo "" >&2
|
||||
show_help >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
137
bin/qvm-rebuild
Executable file
137
bin/qvm-rebuild
Executable file
|
|
@ -0,0 +1,137 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# qvm-rebuild - Build the base qcow2 image from user's flake
|
||||
#
|
||||
# This script builds the QVM base image by:
|
||||
# - Ensuring a user flake exists (copying default if needed)
|
||||
# - Running nix build on the user's flake configuration
|
||||
# - Copying the resulting qcow2 to the base image location
|
||||
# - Optionally warning if VM is running
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Source common library
|
||||
QVM_LIB_DIR="${QVM_LIB_DIR:-$(dirname "$(readlink -f "$0")")/../lib}"
|
||||
# shellcheck source=lib/common.sh
|
||||
source "$QVM_LIB_DIR/common.sh"
|
||||
|
||||
#
|
||||
# ensure_user_flake - Ensure user's flake exists, copy default if missing
|
||||
#
|
||||
ensure_user_flake() {
|
||||
if [[ -f "$QVM_USER_FLAKE/flake.nix" ]]; then
|
||||
log_info "Using existing user flake: $QVM_USER_FLAKE/flake.nix"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "User flake not found, copying default template..."
|
||||
|
||||
# Determine default flake location
|
||||
# In installed version: $QVM_LIB_DIR/../flake/default-vm/
|
||||
# In development: $(dirname "$0")/../flake/default-vm/
|
||||
local default_flake_dir="$QVM_LIB_DIR/../flake/default-vm"
|
||||
|
||||
if [[ ! -d "$default_flake_dir" ]]; then
|
||||
die "Default flake template not found at: $default_flake_dir"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$default_flake_dir/flake.nix" ]]; then
|
||||
die "Default flake.nix not found at: $default_flake_dir/flake.nix"
|
||||
fi
|
||||
|
||||
# Create user flake directory and copy template
|
||||
mkdir -p "$QVM_USER_FLAKE"
|
||||
cp -r "$default_flake_dir"/* "$QVM_USER_FLAKE/"
|
||||
|
||||
log_info "Default flake copied to: $QVM_USER_FLAKE"
|
||||
echo ""
|
||||
echo "You can customize your VM by editing: $QVM_USER_FLAKE/flake.nix"
|
||||
echo ""
|
||||
}
|
||||
|
||||
#
|
||||
# build_base_image - Build the base image using nix
|
||||
#
|
||||
build_base_image() {
|
||||
log_info "Building base image from flake..."
|
||||
|
||||
# Build the qcow2 output from user's flake
|
||||
local build_result="$QVM_STATE_DIR/result"
|
||||
|
||||
if ! nix build "$QVM_USER_FLAKE#qcow2" --out-link "$build_result"; then
|
||||
die "Failed to build base image. Check your flake configuration at: $QVM_USER_FLAKE/flake.nix"
|
||||
fi
|
||||
|
||||
# Verify the result contains nixos.qcow2
|
||||
local qcow2_path="$build_result/nixos.qcow2"
|
||||
if [[ ! -f "$qcow2_path" ]]; then
|
||||
die "Build succeeded but nixos.qcow2 not found at: $qcow2_path"
|
||||
fi
|
||||
|
||||
# Copy the qcow2 to base image location
|
||||
log_info "Copying image to: $QVM_BASE_IMAGE"
|
||||
cp -L "$qcow2_path" "$QVM_BASE_IMAGE"
|
||||
|
||||
# Remove the result symlink
|
||||
rm -f "$build_result"
|
||||
|
||||
# Get image size for informational output
|
||||
local image_size
|
||||
image_size=$(du -h "$QVM_BASE_IMAGE" | cut -f1)
|
||||
|
||||
log_info "Base image built successfully"
|
||||
echo ""
|
||||
echo "Base image: $QVM_BASE_IMAGE"
|
||||
echo "Image size: $image_size"
|
||||
}
|
||||
|
||||
#
|
||||
# warn_if_running - Warn user if VM is currently running
|
||||
#
|
||||
warn_if_running() {
|
||||
if is_vm_running; then
|
||||
log_warn "VM is currently running"
|
||||
echo ""
|
||||
echo "The new base image will only take effect after restarting the VM:"
|
||||
echo " qvm stop"
|
||||
echo " qvm start"
|
||||
echo ""
|
||||
echo "Note: Changes to your VM overlay will be preserved."
|
||||
echo " Use 'qvm reset' to start fresh with the new base image."
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
#
|
||||
# main - Main execution flow
|
||||
#
|
||||
main() {
|
||||
log_info "Rebuilding QVM base image..."
|
||||
|
||||
# Ensure required directories exist
|
||||
ensure_dirs
|
||||
|
||||
# Ensure user has a flake configuration
|
||||
ensure_user_flake
|
||||
|
||||
# Build the base image
|
||||
build_base_image
|
||||
|
||||
# Warn if VM is running
|
||||
warn_if_running
|
||||
|
||||
# Print next steps
|
||||
echo "Next steps:"
|
||||
if is_vm_running; then
|
||||
echo " 1. Stop the VM: qvm stop"
|
||||
echo " 2. Start the VM: qvm start"
|
||||
else
|
||||
echo " - Start the VM: qvm start"
|
||||
fi
|
||||
echo " - Customize the VM: edit $QVM_USER_FLAKE/flake.nix"
|
||||
echo " - Reset to fresh state: qvm reset"
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
116
bin/qvm-reset
Executable file
116
bin/qvm-reset
Executable file
|
|
@ -0,0 +1,116 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# qvm-reset - Wipe VM overlay and workspace registry
|
||||
#
|
||||
# This script resets the VM to a clean state by deleting the overlay.qcow2
|
||||
# and workspaces.json files. This is useful when you want to start fresh
|
||||
# or if the VM state has become corrupted.
|
||||
#
|
||||
# IMPORTANT: This does NOT delete the base image (base.qcow2), so you won't
|
||||
# need to re-download or rebuild the NixOS image.
|
||||
#
|
||||
# Usage: qvm reset [-f|--force]
|
||||
# -f, --force Skip confirmation prompt
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Source common library
|
||||
readonly QVM_LIB_DIR="${QVM_LIB_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" && pwd)}"
|
||||
source "${QVM_LIB_DIR}/common.sh"
|
||||
|
||||
# Get path to qvm-stop script
|
||||
readonly QVM_BIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly QVM_STOP="${QVM_BIN_DIR}/qvm-stop"
|
||||
|
||||
#
|
||||
# confirm_reset - Prompt user for confirmation
|
||||
# Returns: 0 if user confirms, exits script if user cancels
|
||||
#
|
||||
confirm_reset() {
|
||||
echo
|
||||
log_warn "This will delete:"
|
||||
|
||||
if [[ -f "$QVM_OVERLAY" ]]; then
|
||||
echo " - $QVM_OVERLAY"
|
||||
fi
|
||||
|
||||
if [[ -f "$QVM_WORKSPACES_FILE" ]]; then
|
||||
echo " - $QVM_WORKSPACES_FILE"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$QVM_OVERLAY" ]] && [[ ! -f "$QVM_WORKSPACES_FILE" ]]; then
|
||||
log_info "No files to delete (already clean)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "The base image (base.qcow2) and cache directories will NOT be deleted."
|
||||
echo
|
||||
read -p "Continue with reset? [y/N] " -n 1 -r
|
||||
echo
|
||||
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
log_info "Reset cancelled"
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
#
|
||||
# main - Main reset orchestration
|
||||
#
|
||||
main() {
|
||||
local force=false
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-f|--force)
|
||||
force=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
die "Unknown option: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Confirm unless --force is used
|
||||
if [[ "$force" != true ]]; then
|
||||
confirm_reset
|
||||
fi
|
||||
|
||||
# Stop VM if running
|
||||
if is_vm_running; then
|
||||
log_info "VM is running, stopping it first..."
|
||||
"$QVM_STOP"
|
||||
fi
|
||||
|
||||
# Delete overlay if it exists
|
||||
if [[ -f "$QVM_OVERLAY" ]]; then
|
||||
log_info "Deleting overlay: $QVM_OVERLAY"
|
||||
rm -f "$QVM_OVERLAY"
|
||||
else
|
||||
log_info "Overlay does not exist (nothing to delete)"
|
||||
fi
|
||||
|
||||
# Delete workspaces registry if it exists
|
||||
if [[ -f "$QVM_WORKSPACES_FILE" ]]; then
|
||||
log_info "Deleting workspaces registry: $QVM_WORKSPACES_FILE"
|
||||
rm -f "$QVM_WORKSPACES_FILE"
|
||||
else
|
||||
log_info "Workspaces registry does not exist (nothing to delete)"
|
||||
fi
|
||||
|
||||
# Print success message with next steps
|
||||
echo
|
||||
log_info "Reset complete!"
|
||||
echo
|
||||
echo "Next steps:"
|
||||
echo " - Run 'qvm start' to boot the VM with a fresh overlay"
|
||||
echo " - Your base image (base.qcow2) is still intact"
|
||||
echo " - Cache directories (cargo-home, pnpm-store, etc.) are preserved"
|
||||
echo
|
||||
}
|
||||
|
||||
main "$@"
|
||||
235
bin/qvm-run
Executable file
235
bin/qvm-run
Executable file
|
|
@ -0,0 +1,235 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# qvm-run - Execute a command in the VM workspace
|
||||
#
|
||||
# This script:
|
||||
# 1. Ensures VM is running (auto-starts if needed)
|
||||
# 2. Registers current $PWD as a workspace in workspaces.json
|
||||
# 3. SSHes into VM and executes command in workspace mount point
|
||||
# 4. Streams output and preserves exit code
|
||||
#
|
||||
# Usage: qvm-run <command> [args...]
|
||||
#
|
||||
# Notes:
|
||||
# - Workspaces are pre-mounted at VM start time (no dynamic 9p hotplug)
|
||||
# - If workspace not already registered, warns to restart VM
|
||||
# - Uses workspace hash for mount tag and path
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Source common library
|
||||
QVM_LIB_DIR="${QVM_LIB_DIR:-$(dirname "$(readlink -f "$0")")/../lib}"
|
||||
# shellcheck source=lib/common.sh
|
||||
source "$QVM_LIB_DIR/common.sh"
|
||||
|
||||
#
|
||||
# show_usage - Display usage information
|
||||
#
|
||||
show_usage() {
|
||||
cat <<EOF
|
||||
Usage: qvm run <command> [args...]
|
||||
|
||||
Execute a command in the VM at the current workspace directory.
|
||||
|
||||
The current directory (\$PWD) is automatically registered as a workspace
|
||||
and mounted into the VM. Commands run in the mounted workspace directory.
|
||||
|
||||
Examples:
|
||||
qvm run cargo build
|
||||
qvm run npm install
|
||||
qvm run ls -la
|
||||
qvm run bash -c "pwd && ls"
|
||||
|
||||
Notes:
|
||||
- Workspaces are mounted at VM start time
|
||||
- If this workspace is new, you'll need to restart the VM
|
||||
- Command output streams to your terminal
|
||||
- Exit code matches the command's exit code
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
#
|
||||
# register_workspace - Add workspace to registry if not already present
|
||||
# Args: $1 - absolute path to workspace
|
||||
# $2 - workspace hash
|
||||
# Returns: 0 if already registered, 1 if newly added (requires VM restart)
|
||||
#
|
||||
register_workspace() {
|
||||
local workspace_path="$1"
|
||||
local hash="$2"
|
||||
local mount_tag="ws_${hash}"
|
||||
local guest_path="/workspace/${hash}"
|
||||
|
||||
# Create workspaces.json if it doesn't exist
|
||||
if [[ ! -f "$QVM_WORKSPACES_FILE" ]]; then
|
||||
echo '[]' > "$QVM_WORKSPACES_FILE"
|
||||
fi
|
||||
|
||||
# Check if workspace already registered
|
||||
if jq -e --arg path "$workspace_path" '.[] | select(.host_path == $path)' "$QVM_WORKSPACES_FILE" >/dev/null 2>&1; then
|
||||
log_info "Workspace already registered: $workspace_path"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Add new workspace to registry
|
||||
log_info "Registering new workspace: $workspace_path"
|
||||
local temp_file
|
||||
temp_file=$(mktemp)
|
||||
|
||||
jq --arg path "$workspace_path" \
|
||||
--arg hash "$hash" \
|
||||
--arg tag "$mount_tag" \
|
||||
--arg guest "$guest_path" \
|
||||
'. += [{
|
||||
host_path: $path,
|
||||
hash: $hash,
|
||||
mount_tag: $tag,
|
||||
guest_path: $guest
|
||||
}]' "$QVM_WORKSPACES_FILE" > "$temp_file"
|
||||
|
||||
mv "$temp_file" "$QVM_WORKSPACES_FILE"
|
||||
log_info "Workspace registered as $mount_tag -> $guest_path"
|
||||
|
||||
return 1 # Indicate new workspace added
|
||||
}
|
||||
|
||||
#
|
||||
# is_workspace_mounted - Check if workspace is actually mounted in VM
|
||||
# Args: $1 - SSH port
|
||||
# $2 - guest path
|
||||
# Returns: 0 if mounted, 1 if not
|
||||
#
|
||||
is_workspace_mounted() {
|
||||
local ssh_port="$1"
|
||||
local guest_path="$2"
|
||||
|
||||
# SSH into VM and check if guest path exists and is a directory
|
||||
if ssh -o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null \
|
||||
-o LogLevel=ERROR \
|
||||
-p "$ssh_port" \
|
||||
root@localhost \
|
||||
"test -d '$guest_path'" 2>/dev/null; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
#
|
||||
# main - Main execution flow
|
||||
#
|
||||
main() {
|
||||
# Show usage if no arguments
|
||||
if [[ $# -eq 0 ]]; then
|
||||
show_usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Handle help flags
|
||||
if [[ "$1" == "-h" || "$1" == "--help" ]]; then
|
||||
show_usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get current workspace (absolute path)
|
||||
local workspace_path
|
||||
workspace_path="$(pwd)"
|
||||
|
||||
# Generate workspace hash
|
||||
local hash
|
||||
hash=$(workspace_hash "$workspace_path")
|
||||
|
||||
local guest_path="/workspace/${hash}"
|
||||
|
||||
log_info "Workspace: $workspace_path"
|
||||
log_info "Guest path: $guest_path"
|
||||
|
||||
# Register workspace in registry
|
||||
local newly_added=0
|
||||
if ! register_workspace "$workspace_path" "$hash"; then
|
||||
newly_added=1
|
||||
fi
|
||||
|
||||
# Ensure VM is running
|
||||
if ! is_vm_running; then
|
||||
log_info "VM not running, starting..."
|
||||
|
||||
# Path to qvm-start script
|
||||
local qvm_start="${QVM_LIB_DIR}/../bin/qvm-start"
|
||||
|
||||
if ! "$qvm_start"; then
|
||||
die "Failed to start VM"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Get SSH port
|
||||
local ssh_port
|
||||
ssh_port=$(get_ssh_port)
|
||||
|
||||
# Check if workspace is actually mounted in VM
|
||||
if ! is_workspace_mounted "$ssh_port" "$guest_path"; then
|
||||
log_error "Workspace not mounted in VM"
|
||||
echo ""
|
||||
echo "This workspace was just registered but is not mounted in the VM."
|
||||
echo "Workspaces must be mounted at VM start time."
|
||||
echo ""
|
||||
echo "Please restart the VM to mount this workspace:"
|
||||
echo " qvm stop"
|
||||
echo " qvm start"
|
||||
echo ""
|
||||
echo "Then try your command again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build SSH command
|
||||
# - Use -t if stdin is a TTY (for interactive commands)
|
||||
# - Suppress SSH warnings (ephemeral VM, host keys change)
|
||||
# - cd to guest path and execute command
|
||||
local ssh_cmd=(
|
||||
ssh
|
||||
-o StrictHostKeyChecking=no
|
||||
-o UserKnownHostsFile=/dev/null
|
||||
-o LogLevel=ERROR
|
||||
-p "$ssh_port"
|
||||
)
|
||||
|
||||
# Add -t flag if stdin is a TTY
|
||||
if [[ -t 0 ]]; then
|
||||
ssh_cmd+=(-t)
|
||||
fi
|
||||
|
||||
# Add connection target
|
||||
ssh_cmd+=(root@localhost)
|
||||
|
||||
# Build remote command: cd to workspace and execute user's command
|
||||
# Quote each argument properly to handle spaces and special chars
|
||||
local remote_cmd="cd '$guest_path' && "
|
||||
|
||||
# Append user's command with proper quoting
|
||||
local first_arg=1
|
||||
for arg in "$@"; do
|
||||
if [[ $first_arg -eq 1 ]]; then
|
||||
remote_cmd+="$arg"
|
||||
first_arg=0
|
||||
else
|
||||
# Quote arguments that contain spaces or special characters
|
||||
if [[ "$arg" =~ [[:space:]] ]]; then
|
||||
remote_cmd+=" '$arg'"
|
||||
else
|
||||
remote_cmd+=" $arg"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Add the remote command as final SSH argument
|
||||
ssh_cmd+=("$remote_cmd")
|
||||
|
||||
# Execute SSH command (replaces current process)
|
||||
exec "${ssh_cmd[@]}"
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
118
bin/qvm-ssh
Executable file
118
bin/qvm-ssh
Executable file
|
|
@ -0,0 +1,118 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# qvm-ssh - Direct SSH access to the VM
|
||||
#
|
||||
# This script provides SSH access to the running VM:
|
||||
# - Auto-starts VM if not running
|
||||
# - Interactive shell by default (detects TTY)
|
||||
# - Single command execution with -c flag
|
||||
# - Passes through additional SSH arguments
|
||||
# - Uses StrictHostKeyChecking=no for host key management
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Source common library
|
||||
QVM_LIB_DIR="${QVM_LIB_DIR:-$(dirname "$(readlink -f "$0")")/../lib}"
|
||||
# shellcheck source=lib/common.sh
|
||||
source "$QVM_LIB_DIR/common.sh"
|
||||
|
||||
#
|
||||
# show_usage - Display help text
|
||||
#
|
||||
show_usage() {
|
||||
cat <<EOF
|
||||
Usage: qvm ssh [OPTIONS] [SSH_ARGS...]
|
||||
|
||||
Open an SSH session to the VM or run a single command.
|
||||
|
||||
OPTIONS:
|
||||
-c COMMAND Run a single command instead of interactive shell
|
||||
-h, --help Show this help message
|
||||
|
||||
EXAMPLES:
|
||||
qvm ssh # Open interactive shell
|
||||
qvm ssh -c "ls -la" # Run single command
|
||||
qvm ssh -c "pwd" -v # Run command with SSH verbose flag
|
||||
|
||||
NOTES:
|
||||
- VM will auto-start if not running
|
||||
- SSH connects as root@localhost
|
||||
- Host key checking is disabled (VM overlay is ephemeral)
|
||||
- Additional SSH arguments are passed through
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
#
|
||||
# main - Main execution flow
|
||||
#
|
||||
main() {
|
||||
local command_mode=false
|
||||
local command=""
|
||||
local ssh_args=()
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
show_usage
|
||||
exit 0
|
||||
;;
|
||||
-c)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
die "Option -c requires a command argument"
|
||||
fi
|
||||
command_mode=true
|
||||
command="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
# Collect remaining arguments for SSH
|
||||
ssh_args+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Ensure VM is running (auto-start if needed)
|
||||
if ! is_vm_running; then
|
||||
log_info "VM is not running, starting it..."
|
||||
"$QVM_LIB_DIR/../bin/qvm-start"
|
||||
fi
|
||||
|
||||
# Get SSH port
|
||||
local port
|
||||
port=$(get_ssh_port)
|
||||
|
||||
# Build SSH command
|
||||
local ssh_cmd=(
|
||||
ssh
|
||||
-o StrictHostKeyChecking=no
|
||||
-o UserKnownHostsFile=/dev/null
|
||||
-o LogLevel=ERROR # Suppress host key warnings
|
||||
-p "$port"
|
||||
root@localhost
|
||||
)
|
||||
|
||||
# Add TTY flag for interactive sessions (not in command mode)
|
||||
if [[ "$command_mode" = false ]] && [[ -t 0 ]]; then
|
||||
ssh_cmd+=(-t)
|
||||
fi
|
||||
|
||||
# Add any pass-through SSH arguments
|
||||
if [[ ${#ssh_args[@]} -gt 0 ]]; then
|
||||
ssh_cmd+=("${ssh_args[@]}")
|
||||
fi
|
||||
|
||||
# Add command if in command mode
|
||||
if [[ "$command_mode" = true ]]; then
|
||||
ssh_cmd+=("$command")
|
||||
fi
|
||||
|
||||
# Execute SSH (replace shell process)
|
||||
exec "${ssh_cmd[@]}"
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
224
bin/qvm-start
Executable file
224
bin/qvm-start
Executable file
|
|
@ -0,0 +1,224 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# qvm-start - Launch the QEMU VM with all required configuration
|
||||
#
|
||||
# This script starts the QVM virtual machine with:
|
||||
# - KVM acceleration and host CPU passthrough
|
||||
# - Configurable memory and CPU count
|
||||
# - Overlay disk backed by base.qcow2 (copy-on-write)
|
||||
# - SSH port forwarding on auto-selected port
|
||||
# - 9p mounts for shared caches (cargo, pnpm, sccache)
|
||||
# - Serial console logging
|
||||
# - Daemonized execution with PID file
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Source common library
|
||||
QVM_LIB_DIR="${QVM_LIB_DIR:-$(dirname "$(readlink -f "$0")")/../lib}"
|
||||
# shellcheck source=lib/common.sh
|
||||
source "$QVM_LIB_DIR/common.sh"
|
||||
|
||||
#
|
||||
# find_available_port - Find an available TCP port starting from base
|
||||
# Args: $1 - starting port number (default: 2222)
|
||||
# Returns: available port number on stdout
|
||||
#
|
||||
find_available_port() {
|
||||
local port="${1:-2222}"
|
||||
local max_attempts=100
|
||||
local attempt=0
|
||||
|
||||
while (( attempt < max_attempts )); do
|
||||
if ! nc -z localhost "$port" 2>/dev/null; then
|
||||
echo "$port"
|
||||
return 0
|
||||
fi
|
||||
(( port++ ))
|
||||
(( attempt++ ))
|
||||
done
|
||||
|
||||
die "Could not find available port after $max_attempts attempts"
|
||||
}
|
||||
|
||||
#
|
||||
# mount_workspaces - Add virtfs entries for registered workspaces
|
||||
# Args: $1 - name of array variable to append to
|
||||
# Usage: mount_workspaces qemu_cmd
|
||||
#
|
||||
mount_workspaces() {
|
||||
local -n cmd_array=$1
|
||||
|
||||
# Check if workspaces registry exists
|
||||
if [[ ! -f "$QVM_WORKSPACES_FILE" ]]; then
|
||||
log_info "No workspaces registry found, skipping workspace mounts"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check if file is empty or invalid JSON
|
||||
if [[ ! -s "$QVM_WORKSPACES_FILE" ]]; then
|
||||
log_info "Workspaces registry is empty, skipping workspace mounts"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Parse workspaces and add virtfs entries
|
||||
local workspace_count
|
||||
workspace_count=$(jq -r 'length' "$QVM_WORKSPACES_FILE" 2>/dev/null || echo "0")
|
||||
|
||||
if [[ "$workspace_count" -eq 0 ]]; then
|
||||
log_info "No workspaces registered, skipping workspace mounts"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "Mounting $workspace_count workspace(s)..."
|
||||
|
||||
# Iterate through workspaces and add virtfs entries
|
||||
local i=0
|
||||
while (( i < workspace_count )); do
|
||||
local path mount_tag
|
||||
path=$(jq -r ".[$i].path" "$QVM_WORKSPACES_FILE")
|
||||
mount_tag=$(jq -r ".[$i].mount_tag" "$QVM_WORKSPACES_FILE")
|
||||
|
||||
if [[ -z "$path" || -z "$mount_tag" || "$path" == "null" || "$mount_tag" == "null" ]]; then
|
||||
log_warn "Skipping invalid workspace entry at index $i"
|
||||
(( i++ ))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Verify path exists
|
||||
if [[ ! -d "$path" ]]; then
|
||||
log_warn "Workspace path does not exist: $path (skipping)"
|
||||
(( i++ ))
|
||||
continue
|
||||
fi
|
||||
|
||||
log_info " - $path -> $mount_tag"
|
||||
cmd_array+=(-virtfs "local,path=$path,mount_tag=$mount_tag,security_model=mapped-xattr,trans=virtio,version=9p2000.L,msize=104857600")
|
||||
|
||||
(( i++ ))
|
||||
done
|
||||
}
|
||||
|
||||
#
|
||||
# cleanup_on_failure - Clean up state files if VM start fails
|
||||
#
|
||||
cleanup_on_failure() {
|
||||
log_warn "Cleaning up after failed start..."
|
||||
rm -f "$QVM_PID_FILE" "$QVM_SSH_PORT_FILE"
|
||||
}
|
||||
|
||||
#
|
||||
# main - Main execution flow
|
||||
#
|
||||
main() {
|
||||
log_info "Starting QVM..."
|
||||
|
||||
# Check if VM is already running
|
||||
if is_vm_running; then
|
||||
log_info "VM is already running"
|
||||
local port
|
||||
port=$(get_ssh_port)
|
||||
echo "SSH available on port: $port"
|
||||
echo "Use 'qvm ssh' to connect or 'qvm status' for details"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# First-run initialization
|
||||
ensure_dirs
|
||||
|
||||
if [[ ! -f "$QVM_BASE_IMAGE" ]]; then
|
||||
log_info "First run detected - building base image..."
|
||||
log_info "This may take several minutes."
|
||||
|
||||
# Call qvm-rebuild to build the image
|
||||
SCRIPT_DIR="$(dirname "$0")"
|
||||
if ! "$SCRIPT_DIR/qvm-rebuild"; then
|
||||
die "Failed to build base image. Run 'qvm rebuild' manually to debug."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create overlay image if it doesn't exist
|
||||
if [[ ! -f "$QVM_OVERLAY" ]]; then
|
||||
log_info "Creating overlay disk..."
|
||||
if ! qemu-img create -f qcow2 -b "$QVM_BASE_IMAGE" -F qcow2 "$QVM_OVERLAY"; then
|
||||
die "Failed to create overlay disk"
|
||||
fi
|
||||
else
|
||||
log_info "Using existing overlay disk"
|
||||
fi
|
||||
|
||||
# Find available SSH port
|
||||
local ssh_port
|
||||
ssh_port=$(find_available_port 2222)
|
||||
log_info "Using SSH port: $ssh_port"
|
||||
|
||||
# Get memory and CPU settings from environment or use defaults
|
||||
local memory="${QVM_MEMORY:-8G}"
|
||||
local cpus="${QVM_CPUS:-4}"
|
||||
log_info "VM resources: ${memory} memory, ${cpus} CPUs"
|
||||
|
||||
# Build QEMU command
|
||||
local qemu_cmd=(
|
||||
qemu-system-x86_64
|
||||
-enable-kvm
|
||||
-cpu host
|
||||
-m "$memory"
|
||||
-smp "$cpus"
|
||||
|
||||
# Overlay disk (virtio for performance)
|
||||
-drive "file=$QVM_OVERLAY,if=virtio,format=qcow2"
|
||||
|
||||
# User-mode networking with SSH port forward
|
||||
-netdev "user,id=net0,hostfwd=tcp::${ssh_port}-:22"
|
||||
-device "virtio-net-pci,netdev=net0"
|
||||
|
||||
# 9p mounts for shared caches (security_model=mapped-xattr for proper permissions)
|
||||
-virtfs "local,path=$QVM_CARGO_HOME,mount_tag=cargo_home,security_model=mapped-xattr,trans=virtio,version=9p2000.L,msize=104857600"
|
||||
-virtfs "local,path=$QVM_CARGO_TARGET,mount_tag=cargo_target,security_model=mapped-xattr,trans=virtio,version=9p2000.L,msize=104857600"
|
||||
-virtfs "local,path=$QVM_PNPM_STORE,mount_tag=pnpm_store,security_model=mapped-xattr,trans=virtio,version=9p2000.L,msize=104857600"
|
||||
-virtfs "local,path=$QVM_SCCACHE,mount_tag=sccache,security_model=mapped-xattr,trans=virtio,version=9p2000.L,msize=104857600"
|
||||
)
|
||||
|
||||
# Add workspace mounts from registry
|
||||
mount_workspaces qemu_cmd
|
||||
|
||||
# Continue building QEMU command
|
||||
qemu_cmd+=(
|
||||
# Serial console to log file
|
||||
-serial "file:$QVM_SERIAL_LOG"
|
||||
|
||||
# No graphics
|
||||
-nographic
|
||||
|
||||
# Daemonize with PID file
|
||||
-daemonize
|
||||
-pidfile "$QVM_PID_FILE"
|
||||
)
|
||||
|
||||
# Launch QEMU
|
||||
log_info "Launching QEMU..."
|
||||
if ! "${qemu_cmd[@]}"; then
|
||||
cleanup_on_failure
|
||||
die "Failed to start QEMU"
|
||||
fi
|
||||
|
||||
# Save SSH port to file
|
||||
echo "$ssh_port" > "$QVM_SSH_PORT_FILE"
|
||||
|
||||
# Wait for SSH to become available
|
||||
if ! wait_for_ssh "$ssh_port" 60; then
|
||||
cleanup_on_failure
|
||||
die "VM started but SSH did not become available"
|
||||
fi
|
||||
|
||||
# Success!
|
||||
log_info "VM started successfully"
|
||||
echo ""
|
||||
echo "SSH available on port: $ssh_port"
|
||||
echo "Connect with: qvm ssh"
|
||||
echo "Check status: qvm status"
|
||||
echo "Serial log: $QVM_SERIAL_LOG"
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
228
bin/qvm-status
Executable file
228
bin/qvm-status
Executable file
|
|
@ -0,0 +1,228 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# qvm-status - Display VM state, configuration, and connection information
|
||||
#
|
||||
# Shows current VM status including:
|
||||
# - Running state (PID, uptime, SSH port)
|
||||
# - Mounted workspaces from workspaces.json
|
||||
# - Cache directory status
|
||||
# - Base image and overlay details
|
||||
# - Connection hints for SSH and run commands
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 - VM is running
|
||||
# 1 - VM is stopped
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Source common library for shared functions and constants
|
||||
readonly QVM_LIB_DIR="${QVM_LIB_DIR:-$(dirname "$0")/../lib}"
|
||||
source "${QVM_LIB_DIR}/common.sh"
|
||||
|
||||
# Additional color codes for status display
|
||||
if [[ -t 1 ]]; then
|
||||
readonly COLOR_SUCCESS='\033[0;32m' # Green
|
||||
readonly COLOR_HEADER='\033[1;37m' # Bold White
|
||||
readonly COLOR_DIM='\033[0;90m' # Dim Gray
|
||||
else
|
||||
readonly COLOR_SUCCESS=''
|
||||
readonly COLOR_HEADER=''
|
||||
readonly COLOR_DIM=''
|
||||
fi
|
||||
|
||||
#
|
||||
# format_bytes - Convert bytes to human-readable format
|
||||
# Args: $1 - size in bytes
|
||||
# Returns: formatted string (e.g., "1.5G", "256M", "4.0K")
|
||||
#
|
||||
format_bytes() {
|
||||
local bytes="$1"
|
||||
if (( bytes >= 1073741824 )); then
|
||||
printf "%.1fG" "$(echo "scale=1; $bytes / 1073741824" | bc)"
|
||||
elif (( bytes >= 1048576 )); then
|
||||
printf "%.1fM" "$(echo "scale=1; $bytes / 1048576" | bc)"
|
||||
elif (( bytes >= 1024 )); then
|
||||
printf "%.1fK" "$(echo "scale=1; $bytes / 1024" | bc)"
|
||||
else
|
||||
printf "%dB" "$bytes"
|
||||
fi
|
||||
}
|
||||
|
||||
#
|
||||
# get_uptime - Calculate VM uptime from PID
|
||||
# Args: $1 - process PID
|
||||
# Returns: uptime string (e.g., "2h 15m", "45m", "30s")
|
||||
#
|
||||
get_uptime() {
|
||||
local pid="$1"
|
||||
|
||||
# Get process start time in seconds since epoch
|
||||
local start_time
|
||||
start_time=$(ps -p "$pid" -o lstart= 2>/dev/null | xargs -I{} date -d "{}" +%s)
|
||||
|
||||
if [[ -z "$start_time" ]]; then
|
||||
echo "unknown"
|
||||
return
|
||||
fi
|
||||
|
||||
local current_time
|
||||
current_time=$(date +%s)
|
||||
local uptime_seconds=$((current_time - start_time))
|
||||
|
||||
# Format uptime
|
||||
local hours=$((uptime_seconds / 3600))
|
||||
local minutes=$(( (uptime_seconds % 3600) / 60 ))
|
||||
local seconds=$((uptime_seconds % 60))
|
||||
|
||||
if (( hours > 0 )); then
|
||||
printf "%dh %dm" "$hours" "$minutes"
|
||||
elif (( minutes > 0 )); then
|
||||
printf "%dm %ds" "$minutes" "$seconds"
|
||||
else
|
||||
printf "%ds" "$seconds"
|
||||
fi
|
||||
}
|
||||
|
||||
#
|
||||
# show_file_info - Display file status with size and modification time
|
||||
# Args: $1 - file path
|
||||
# $2 - label (e.g., "Base Image")
|
||||
#
|
||||
show_file_info() {
|
||||
local file="$1"
|
||||
local label="$2"
|
||||
|
||||
if [[ -f "$file" ]]; then
|
||||
local size_bytes
|
||||
size_bytes=$(stat -c %s "$file" 2>/dev/null || echo "0")
|
||||
local size_human
|
||||
size_human=$(format_bytes "$size_bytes")
|
||||
|
||||
local mod_time
|
||||
mod_time=$(stat -c %y "$file" 2>/dev/null | cut -d. -f1)
|
||||
|
||||
echo -e " ${COLOR_SUCCESS}✓${COLOR_RESET} $label: ${COLOR_INFO}$size_human${COLOR_RESET} ${COLOR_DIM}(modified: $mod_time)${COLOR_RESET}"
|
||||
else
|
||||
echo -e " ${COLOR_WARN}✗${COLOR_RESET} $label: ${COLOR_WARN}missing${COLOR_RESET}"
|
||||
fi
|
||||
}
|
||||
|
||||
#
|
||||
# show_dir_info - Display directory status
|
||||
# Args: $1 - directory path
|
||||
# $2 - label (e.g., "Cargo Home")
|
||||
#
|
||||
show_dir_info() {
|
||||
local dir="$1"
|
||||
local label="$2"
|
||||
|
||||
if [[ -d "$dir" ]]; then
|
||||
echo -e " ${COLOR_SUCCESS}✓${COLOR_RESET} $label: ${COLOR_DIM}$dir${COLOR_RESET}"
|
||||
else
|
||||
echo -e " ${COLOR_WARN}✗${COLOR_RESET} $label: ${COLOR_WARN}not created${COLOR_RESET}"
|
||||
fi
|
||||
}
|
||||
|
||||
#
|
||||
# show_workspaces - Display mounted workspaces from workspaces.json
|
||||
#
|
||||
show_workspaces() {
|
||||
if [[ ! -f "$QVM_WORKSPACES_FILE" ]]; then
|
||||
echo -e " ${COLOR_DIM}No workspaces mounted${COLOR_RESET}"
|
||||
return
|
||||
fi
|
||||
|
||||
# Check if file is valid JSON and has workspaces
|
||||
local workspace_count
|
||||
workspace_count=$(jq 'length' "$QVM_WORKSPACES_FILE" 2>/dev/null || echo "0")
|
||||
|
||||
if (( workspace_count == 0 )); then
|
||||
echo -e " ${COLOR_DIM}No workspaces mounted${COLOR_RESET}"
|
||||
return
|
||||
fi
|
||||
|
||||
# Parse and display each workspace
|
||||
jq -r 'to_entries[] | "\(.key)|\(.value.host_path)|\(.value.guest_path)"' "$QVM_WORKSPACES_FILE" 2>/dev/null | while IFS='|' read -r hash host_path guest_path; do
|
||||
echo -e " ${COLOR_SUCCESS}✓${COLOR_RESET} $hash: ${COLOR_INFO}$host_path${COLOR_RESET} → ${COLOR_DIM}$guest_path${COLOR_RESET}"
|
||||
done
|
||||
}
|
||||
|
||||
#
|
||||
# main - Main status display logic
|
||||
#
|
||||
main() {
|
||||
# Header
|
||||
echo -e "${COLOR_HEADER}QVM Status${COLOR_RESET}"
|
||||
echo ""
|
||||
|
||||
# VM State
|
||||
echo -e "${COLOR_HEADER}VM State:${COLOR_RESET}"
|
||||
|
||||
if is_vm_running; then
|
||||
local pid
|
||||
pid=$(cat "$QVM_PID_FILE")
|
||||
|
||||
local ssh_port
|
||||
if [[ -f "$QVM_SSH_PORT_FILE" ]]; then
|
||||
ssh_port=$(cat "$QVM_SSH_PORT_FILE")
|
||||
else
|
||||
ssh_port="unknown"
|
||||
fi
|
||||
|
||||
local uptime
|
||||
uptime=$(get_uptime "$pid")
|
||||
|
||||
echo -e " ${COLOR_SUCCESS}✓${COLOR_RESET} Running"
|
||||
echo -e " ${COLOR_DIM}PID:${COLOR_RESET} $pid"
|
||||
echo -e " ${COLOR_DIM}SSH:${COLOR_RESET} localhost:$ssh_port"
|
||||
echo -e " ${COLOR_DIM}Uptime:${COLOR_RESET} $uptime"
|
||||
else
|
||||
echo -e " ${COLOR_WARN}✗${COLOR_RESET} Stopped"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Workspaces
|
||||
echo -e "${COLOR_HEADER}Mounted Workspaces:${COLOR_RESET}"
|
||||
show_workspaces
|
||||
echo ""
|
||||
|
||||
# Cache Directories
|
||||
echo -e "${COLOR_HEADER}Cache Directories:${COLOR_RESET}"
|
||||
show_dir_info "$QVM_CARGO_HOME" "Cargo Home"
|
||||
show_dir_info "$QVM_CARGO_TARGET" "Cargo Target"
|
||||
show_dir_info "$QVM_PNPM_STORE" "PNPM Store"
|
||||
show_dir_info "$QVM_SCCACHE" "SCCache"
|
||||
echo ""
|
||||
|
||||
# VM Images
|
||||
echo -e "${COLOR_HEADER}VM Images:${COLOR_RESET}"
|
||||
show_file_info "$QVM_BASE_IMAGE" "Base Image"
|
||||
show_file_info "$QVM_OVERLAY" "Overlay"
|
||||
echo ""
|
||||
|
||||
# Connection Hints (only if VM is running)
|
||||
if is_vm_running; then
|
||||
local ssh_port
|
||||
ssh_port=$(cat "$QVM_SSH_PORT_FILE" 2>/dev/null || echo "unknown")
|
||||
|
||||
echo -e "${COLOR_HEADER}Connection:${COLOR_RESET}"
|
||||
echo -e " ${COLOR_INFO}SSH:${COLOR_RESET} qvm ssh"
|
||||
echo -e " ${COLOR_INFO}Run cmd:${COLOR_RESET} qvm run <command>"
|
||||
echo -e " ${COLOR_INFO}Direct:${COLOR_RESET} ssh -p $ssh_port root@localhost"
|
||||
echo ""
|
||||
|
||||
# Exit success if running
|
||||
exit 0
|
||||
else
|
||||
echo -e "${COLOR_HEADER}Quick Start:${COLOR_RESET}"
|
||||
echo -e " ${COLOR_INFO}Start VM:${COLOR_RESET} qvm start"
|
||||
echo ""
|
||||
|
||||
# Exit failure if stopped
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Execute main
|
||||
main "$@"
|
||||
94
bin/qvm-stop
Executable file
94
bin/qvm-stop
Executable file
|
|
@ -0,0 +1,94 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# qvm-stop - Gracefully shut down the QEMU VM
|
||||
#
|
||||
# This script stops the running VM by sending SIGTERM first for graceful
|
||||
# shutdown, waiting up to 30 seconds, then sending SIGKILL if necessary.
|
||||
# It cleans up state files (vm.pid, ssh.port) after shutdown completes.
|
||||
#
|
||||
# Usage: qvm stop
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Source common library
|
||||
readonly QVM_LIB_DIR="${QVM_LIB_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" && pwd)}"
|
||||
source "${QVM_LIB_DIR}/common.sh"
|
||||
|
||||
# Timeout for graceful shutdown in seconds
|
||||
readonly SHUTDOWN_TIMEOUT=30
|
||||
|
||||
#
|
||||
# wait_for_process_exit - Wait for process to terminate
|
||||
# Args: $1 - PID to wait for
|
||||
# $2 - timeout in seconds
|
||||
# Returns: 0 if process exits, 1 on timeout
|
||||
#
|
||||
wait_for_process_exit() {
|
||||
local pid="$1"
|
||||
local timeout="$2"
|
||||
local elapsed=0
|
||||
|
||||
while (( elapsed < timeout )); do
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
(( elapsed++ ))
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
#
|
||||
# main - Main shutdown orchestration
|
||||
#
|
||||
main() {
|
||||
# Check if VM is running
|
||||
if ! is_vm_running; then
|
||||
log_info "VM is not running"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get VM process PID
|
||||
local vm_pid
|
||||
vm_pid=$(cat "$QVM_PID_FILE")
|
||||
|
||||
log_info "Shutting down VM (PID: $vm_pid)..."
|
||||
|
||||
# Send SIGTERM for graceful shutdown
|
||||
if kill -TERM "$vm_pid" 2>/dev/null; then
|
||||
log_info "Sent SIGTERM, waiting up to ${SHUTDOWN_TIMEOUT}s for graceful shutdown..."
|
||||
|
||||
if wait_for_process_exit "$vm_pid" "$SHUTDOWN_TIMEOUT"; then
|
||||
log_info "VM shut down gracefully"
|
||||
else
|
||||
log_warn "Graceful shutdown timeout, forcefully terminating..."
|
||||
|
||||
# Send SIGKILL to force termination
|
||||
if kill -KILL "$vm_pid" 2>/dev/null; then
|
||||
# Wait briefly to ensure process is dead
|
||||
sleep 1
|
||||
|
||||
# Verify process is actually dead
|
||||
if kill -0 "$vm_pid" 2>/dev/null; then
|
||||
die "Failed to kill VM process $vm_pid"
|
||||
fi
|
||||
|
||||
log_info "VM forcefully terminated"
|
||||
else
|
||||
log_warn "Process $vm_pid already terminated"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
log_warn "Process $vm_pid already terminated (could not send SIGTERM)"
|
||||
fi
|
||||
|
||||
# Clean up state files
|
||||
log_info "Cleaning up state files..."
|
||||
rm -f "$QVM_PID_FILE" "$QVM_SSH_PORT_FILE"
|
||||
|
||||
log_info "VM stopped successfully"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1769170682,
|
||||
"narHash": "sha256-oMmN1lVQU0F0W2k6OI3bgdzp2YOHWYUAw79qzDSjenU=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "c5296fdd05cfa2c187990dd909864da9658df755",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
121
flake.nix
Normal file
121
flake.nix
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
{
|
||||
description = "QVM - Quick development VMs for AI-assisted workflows";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
|
||||
qvm = pkgs.stdenv.mkDerivation {
|
||||
pname = "qvm";
|
||||
version = "0.1.0";
|
||||
|
||||
src = ./.;
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
makeWrapper
|
||||
installShellFiles
|
||||
];
|
||||
|
||||
buildInputs = with pkgs; [
|
||||
bash
|
||||
];
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
# Create output directories
|
||||
mkdir -p $out/bin
|
||||
mkdir -p $out/lib/qvm
|
||||
|
||||
# Install library files
|
||||
install -Dm755 lib/common.sh $out/lib/qvm/common.sh
|
||||
|
||||
# Install all scripts from bin/
|
||||
for script in bin/*; do
|
||||
if [ -f "$script" ]; then
|
||||
install -Dm755 "$script" "$out/bin/$(basename "$script")"
|
||||
fi
|
||||
done
|
||||
|
||||
# Wrap all scripts with PATH containing required dependencies
|
||||
for script in $out/bin/*; do
|
||||
wrapProgram "$script" \
|
||||
--prefix PATH : ${pkgs.lib.makeBinPath [
|
||||
pkgs.qemu
|
||||
pkgs.openssh
|
||||
pkgs.jq
|
||||
pkgs.coreutils
|
||||
pkgs.gnused
|
||||
pkgs.gnugrep
|
||||
pkgs.nix
|
||||
pkgs.netcat-gnu
|
||||
pkgs.bc
|
||||
pkgs.procps
|
||||
]} \
|
||||
--set QVM_LIB_DIR "$out/lib/qvm"
|
||||
done
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "Quick development VMs for AI-assisted workflows";
|
||||
homepage = "https://github.com/josh/qvm";
|
||||
license = licenses.mit;
|
||||
maintainers = [];
|
||||
platforms = platforms.linux;
|
||||
mainProgram = "qvm";
|
||||
};
|
||||
};
|
||||
|
||||
in {
|
||||
packages = {
|
||||
inherit qvm;
|
||||
default = qvm;
|
||||
};
|
||||
|
||||
apps = {
|
||||
qvm = {
|
||||
type = "app";
|
||||
program = "${qvm}/bin/qvm";
|
||||
};
|
||||
default = {
|
||||
type = "app";
|
||||
program = "${qvm}/bin/qvm";
|
||||
};
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
# Runtime dependencies for development
|
||||
qemu
|
||||
openssh
|
||||
jq
|
||||
coreutils
|
||||
gnused
|
||||
gnugrep
|
||||
nix
|
||||
netcat-gnu
|
||||
bc
|
||||
procps
|
||||
|
||||
# Development tools
|
||||
shellcheck
|
||||
shfmt
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
export QVM_LIB_DIR="$(pwd)/lib"
|
||||
echo "QVM development environment"
|
||||
echo "Library directory: $QVM_LIB_DIR"
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
212
flake/default-vm/flake.nix
Normal file
212
flake/default-vm/flake.nix
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
{
|
||||
description = "Default NixOS VM template for QVM development environments";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
nixos-generators = {
|
||||
url = "github:nix-community/nixos-generators";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
home-manager = {
|
||||
url = "github:nix-community/home-manager";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
opencode.url = "github:anomalyco/opencode";
|
||||
};
|
||||
|
||||
outputs = {
|
||||
self,
|
||||
nixpkgs,
|
||||
nixos-generators,
|
||||
home-manager,
|
||||
opencode,
|
||||
...
|
||||
}: let
|
||||
system = "x86_64-linux";
|
||||
stateVersion = "24.11";
|
||||
|
||||
vmModule = { config, pkgs, lib, ... }: {
|
||||
imports = [
|
||||
home-manager.nixosModules.home-manager
|
||||
];
|
||||
|
||||
nixpkgs.config = {
|
||||
allowUnfree = true;
|
||||
allowUnfreePredicate = (_: true);
|
||||
};
|
||||
|
||||
# Distinctive hostname for easy identification
|
||||
networking.hostName = "qvm-dev";
|
||||
|
||||
# SSH enabled with password auth for root
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
settings.PasswordAuthentication = true;
|
||||
settings.PermitRootLogin = "yes";
|
||||
};
|
||||
|
||||
# Root user with password and zsh
|
||||
users.users.root = {
|
||||
password = "root";
|
||||
shell = pkgs.zsh;
|
||||
};
|
||||
|
||||
programs.zsh.enable = true;
|
||||
|
||||
# Home manager configuration for nice shell
|
||||
home-manager = {
|
||||
useUserPackages = true;
|
||||
useGlobalPkgs = true;
|
||||
backupFileExtension = "bak";
|
||||
|
||||
users.root = {
|
||||
home.stateVersion = stateVersion;
|
||||
programs.home-manager.enable = true;
|
||||
|
||||
# Starship prompt that shows we're in qvm-dev
|
||||
programs.starship = {
|
||||
enable = true;
|
||||
settings = {
|
||||
add_newline = false;
|
||||
format = lib.concatStrings [
|
||||
"[┌─](bold green)"
|
||||
"[$hostname](bold red)"
|
||||
"[$directory](bold blue)"
|
||||
"$git_branch"
|
||||
"$git_status"
|
||||
"\n"
|
||||
"[└─>](bold green) "
|
||||
];
|
||||
hostname = {
|
||||
ssh_only = false;
|
||||
format = "[@$hostname](bold red) ";
|
||||
disabled = false;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
programs.zsh = {
|
||||
enable = true;
|
||||
enableCompletion = true;
|
||||
autosuggestion.enable = true;
|
||||
syntaxHighlighting.enable = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Avoid slow boots due to wait-online
|
||||
systemd.network.wait-online.enable = false;
|
||||
systemd.services.NetworkManager-wait-online.enable = lib.mkForce false;
|
||||
systemd.services.systemd-networkd-wait-online.enable = lib.mkForce false;
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 22 ];
|
||||
|
||||
# Enable flakes
|
||||
nix.settings.experimental-features = [
|
||||
"nix-command"
|
||||
"flakes"
|
||||
];
|
||||
|
||||
# Josh's timezone
|
||||
time.timeZone = "America/Chicago";
|
||||
|
||||
# Git safe.directory for 9p ownership issues
|
||||
environment.etc."gitconfig".text = ''
|
||||
[safe]
|
||||
directory = *
|
||||
'';
|
||||
|
||||
# 9p mount points for caches (must match qvm-start mount tags)
|
||||
fileSystems."/cache/cargo" = {
|
||||
device = "cargo_home";
|
||||
fsType = "9p";
|
||||
options = [ "trans=virtio" "version=9p2000.L" "msize=104857600" "_netdev" "nofail" ];
|
||||
};
|
||||
|
||||
fileSystems."/cache/target" = {
|
||||
device = "cargo_target";
|
||||
fsType = "9p";
|
||||
options = [ "trans=virtio" "version=9p2000.L" "msize=104857600" "_netdev" "nofail" ];
|
||||
};
|
||||
|
||||
fileSystems."/cache/pnpm" = {
|
||||
device = "pnpm_store";
|
||||
fsType = "9p";
|
||||
options = [ "trans=virtio" "version=9p2000.L" "msize=104857600" "_netdev" "nofail" ];
|
||||
};
|
||||
|
||||
fileSystems."/cache/sccache" = {
|
||||
device = "sccache";
|
||||
fsType = "9p";
|
||||
options = [ "trans=virtio" "version=9p2000.L" "msize=104857600" "_netdev" "nofail" ];
|
||||
};
|
||||
|
||||
# Environment variables for cache directories
|
||||
environment.variables = {
|
||||
CARGO_HOME = "/cache/cargo";
|
||||
CARGO_TARGET_DIR = "/cache/target";
|
||||
PNPM_HOME = "/cache/pnpm";
|
||||
SCCACHE_DIR = "/cache/sccache";
|
||||
};
|
||||
|
||||
# Ensure workspace directory exists
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /workspace 0755 root root -"
|
||||
];
|
||||
|
||||
# Essential packages for development
|
||||
environment.systemPackages = with pkgs; [
|
||||
git
|
||||
vim
|
||||
tmux
|
||||
htop
|
||||
curl
|
||||
jq
|
||||
ripgrep
|
||||
fd
|
||||
opencode.packages.${system}.default
|
||||
];
|
||||
|
||||
# Opencode aliases without proxy interference
|
||||
environment.shellAliases = {
|
||||
"oc" = "all_proxy='' http_proxy='' https_proxy='' opencode";
|
||||
"occ" = "oc -c";
|
||||
};
|
||||
|
||||
# MOTD to clearly show this is qvm-dev
|
||||
users.motd = ''
|
||||
╔════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ QVM Development VM ║
|
||||
║ Hostname: qvm-dev ║
|
||||
║ ║
|
||||
║ Caches: /cache/{cargo,target,...} ║
|
||||
║ Workspace: /workspace ║
|
||||
║ ║
|
||||
╚════════════════════════════════════════╝
|
||||
'';
|
||||
|
||||
# 20GB disk size
|
||||
virtualisation.diskSize = 20 * 1024;
|
||||
|
||||
system.stateVersion = stateVersion;
|
||||
};
|
||||
|
||||
in {
|
||||
# Export the qcow2 image
|
||||
packages.${system}.qcow2 = nixos-generators.nixosGenerate {
|
||||
inherit system;
|
||||
format = "qcow";
|
||||
modules = [ vmModule ];
|
||||
};
|
||||
|
||||
# Also export a default package
|
||||
packages.${system}.default = self.packages.${system}.qcow2;
|
||||
|
||||
# Export the module for reuse
|
||||
nixosModules.default = vmModule;
|
||||
};
|
||||
}
|
||||
169
lib/common.sh
Normal file
169
lib/common.sh
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# common.sh - Shared functions and configuration for QVM CLI tool
|
||||
#
|
||||
# This file defines XDG-compliant directory paths, constants, and utility
|
||||
# functions used across all qvm-* commands. It should be sourced by each
|
||||
# command script via: source "${QVM_LIB_DIR}/common.sh"
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# XDG-compliant directory paths
|
||||
readonly QVM_DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/qvm"
|
||||
readonly QVM_STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/qvm"
|
||||
readonly QVM_CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/qvm"
|
||||
readonly QVM_CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/qvm"
|
||||
|
||||
# Path constants for VM artifacts
|
||||
readonly QVM_BASE_IMAGE="$QVM_DATA_DIR/base.qcow2"
|
||||
readonly QVM_OVERLAY="$QVM_STATE_DIR/overlay.qcow2"
|
||||
readonly QVM_PID_FILE="$QVM_STATE_DIR/vm.pid"
|
||||
readonly QVM_SSH_PORT_FILE="$QVM_STATE_DIR/ssh.port"
|
||||
readonly QVM_SERIAL_LOG="$QVM_STATE_DIR/serial.log"
|
||||
readonly QVM_WORKSPACES_FILE="$QVM_STATE_DIR/workspaces.json"
|
||||
readonly QVM_USER_FLAKE="$QVM_CONFIG_DIR/flake"
|
||||
|
||||
# Cache directories for 9p mounts (shared between host and VM)
|
||||
readonly QVM_CARGO_HOME="$QVM_CACHE_DIR/cargo-home"
|
||||
readonly QVM_CARGO_TARGET="$QVM_CACHE_DIR/cargo-target"
|
||||
readonly QVM_PNPM_STORE="$QVM_CACHE_DIR/pnpm-store"
|
||||
readonly QVM_SCCACHE="$QVM_CACHE_DIR/sccache"
|
||||
|
||||
# Color codes (only used if stdout is a TTY)
|
||||
if [[ -t 1 ]]; then
|
||||
readonly COLOR_INFO='\033[0;36m' # Cyan
|
||||
readonly COLOR_WARN='\033[0;33m' # Yellow
|
||||
readonly COLOR_ERROR='\033[0;31m' # Red
|
||||
readonly COLOR_RESET='\033[0m' # Reset
|
||||
else
|
||||
readonly COLOR_INFO=''
|
||||
readonly COLOR_WARN=''
|
||||
readonly COLOR_ERROR=''
|
||||
readonly COLOR_RESET=''
|
||||
fi
|
||||
|
||||
#
|
||||
# log_info - Print informational message in cyan
|
||||
# Usage: log_info "message"
|
||||
#
|
||||
log_info() {
|
||||
echo -e "${COLOR_INFO}[INFO]${COLOR_RESET} $*" >&2
|
||||
}
|
||||
|
||||
#
|
||||
# log_warn - Print warning message in yellow
|
||||
# Usage: log_warn "message"
|
||||
#
|
||||
log_warn() {
|
||||
echo -e "${COLOR_WARN}[WARN]${COLOR_RESET} $*" >&2
|
||||
}
|
||||
|
||||
#
|
||||
# log_error - Print error message in red
|
||||
# Usage: log_error "message"
|
||||
#
|
||||
log_error() {
|
||||
echo -e "${COLOR_ERROR}[ERROR]${COLOR_RESET} $*" >&2
|
||||
}
|
||||
|
||||
#
|
||||
# die - Print error message and exit with status 1
|
||||
# Usage: die "error message"
|
||||
#
|
||||
die() {
|
||||
log_error "$@"
|
||||
exit 1
|
||||
}
|
||||
|
||||
#
|
||||
# ensure_dirs - Create all required QVM directories
|
||||
# Usage: ensure_dirs
|
||||
#
|
||||
ensure_dirs() {
|
||||
mkdir -p "$QVM_DATA_DIR" \
|
||||
"$QVM_STATE_DIR" \
|
||||
"$QVM_CACHE_DIR" \
|
||||
"$QVM_CONFIG_DIR" \
|
||||
"$QVM_CARGO_HOME" \
|
||||
"$QVM_CARGO_TARGET" \
|
||||
"$QVM_PNPM_STORE" \
|
||||
"$QVM_SCCACHE"
|
||||
}
|
||||
|
||||
#
|
||||
# is_vm_running - Check if VM process is running
|
||||
# Returns: 0 if running, 1 if not
|
||||
# Usage: if is_vm_running; then ... fi
|
||||
#
|
||||
is_vm_running() {
|
||||
if [[ ! -f "$QVM_PID_FILE" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
local pid
|
||||
pid=$(cat "$QVM_PID_FILE")
|
||||
|
||||
# Check if process exists and is a QEMU process
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
return 0
|
||||
else
|
||||
# Stale PID file, remove it
|
||||
rm -f "$QVM_PID_FILE"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
#
|
||||
# get_ssh_port - Read SSH port from state file
|
||||
# Returns: SSH port number on stdout
|
||||
# Usage: port=$(get_ssh_port)
|
||||
#
|
||||
get_ssh_port() {
|
||||
if [[ ! -f "$QVM_SSH_PORT_FILE" ]]; then
|
||||
die "SSH port file not found. Is the VM running?"
|
||||
fi
|
||||
cat "$QVM_SSH_PORT_FILE"
|
||||
}
|
||||
|
||||
#
|
||||
# workspace_hash - Generate short hash from absolute path
|
||||
# Args: $1 - absolute path to workspace
|
||||
# Returns: 8-character hash on stdout
|
||||
# Usage: hash=$(workspace_hash "/path/to/workspace")
|
||||
#
|
||||
workspace_hash() {
|
||||
local path="$1"
|
||||
echo -n "$path" | sha256sum | cut -c1-8
|
||||
}
|
||||
|
||||
#
|
||||
# wait_for_ssh - Wait for SSH to become available on VM
|
||||
# Args: $1 - SSH port number
|
||||
# $2 - timeout in seconds (default: 60)
|
||||
# Returns: 0 if SSH is available, 1 on timeout
|
||||
# Usage: wait_for_ssh "$port" 30
|
||||
#
|
||||
wait_for_ssh() {
|
||||
local port="${1:-}"
|
||||
local timeout="${2:-60}"
|
||||
local elapsed=0
|
||||
|
||||
if [[ -z "$port" ]]; then
|
||||
die "wait_for_ssh requires port argument"
|
||||
fi
|
||||
|
||||
log_info "Waiting for SSH on port $port (timeout: ${timeout}s)..."
|
||||
|
||||
while (( elapsed < timeout )); do
|
||||
if nc -z -w 1 localhost "$port" 2>/dev/null; then
|
||||
log_info "SSH is ready"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
(( elapsed++ ))
|
||||
done
|
||||
|
||||
log_error "SSH did not become available within ${timeout}s"
|
||||
return 1
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue