I run VMs constantly. Testing Kubernetes deployments, trying out a distro I read about, running Windows for that one stubborn application, isolating an experiment so I don’t wreck my actual machine. KVM/QEMU is the right tool for all of it. Near-native performance, completely open source, baked straight into the Linux kernel. Nothing to phone home, nothing to license, no vendor deciding what I’m allowed to do with my own hardware.
The commands, though. The commands are where it falls apart.
virt-install --name test --ram 2048 --vcpus 2 --disk path=/var/lib/libvirt/images/test.qcow2,size=20 --cdrom /home/user/Downloads/ubuntu-22.04.iso --os-variant ubuntu22.04 --network bridge=virbr0 --graphics spice --console pty,target_type=serial
Nobody types that from memory. You copy it off a wiki, swap a few values, get the disk path wrong, and try again. By the time the VM boots, whatever curiosity made you want to test something has already cooled off. That gap, between “I should check this in a VM” and the VM actually running, is exactly where good intentions go to die.
So at some point I stopped fighting it and wrote a small pile of scripts and aliases. Quick VM creation from an ISO with defaults I never have to think about, snapshots that take one word, management that doesn’t require a manual. The bar I set was simple: if I can type it faster than I can click through virt-manager, I’ll actually use it. Everything below clears that bar.
Why scripts and not the GUI
I lean on this kind of tooling for reasons I’ve gone into in Working with an AuDHD Brain. Every GUI hides its functionality behind a layout I have to reload into my head each time. Where’s the snapshot button again? Which tab had the disk settings? That lookup costs me focus I’d rather spend on the actual problem.
A script doesn’t make me remember anything. It runs the same way every time, the file itself documents what it does, tab completion is faster than any mouse, and I can pipe one into the next or call it from a bigger script without a second thought.
The boring practical win matters too: scripts work over SSH. Most of my VMs live on a headless box in the corner, and virt-manager is not much use there.
Start with the aliases
The aliases are the cheapest possible win, so start there. Drop these in your .bashrc or .zshrc:
# Quick status
alias vms='virsh list --all'
alias vmsr='virsh list' # Running only
alias vmnets='virsh net-list --all'
alias vmpools='virsh pool-list --all'
# Power management
alias vmstart='virsh start'
alias vmstop='virsh shutdown'
alias vmkill='virsh destroy' # Force stop
alias vmreboot='virsh reboot'
# Console access
alias vmconsole='virsh console'
alias vmviewer='virt-viewer'
# Quick info
alias vminfo='virsh dominfo'
alias vmip='virsh domifaddr'
alias vmblk='virsh domblklist'
# Snapshots
alias vmsnap='virsh snapshot-create-as'
alias vmsnaps='virsh snapshot-list'
alias vmrevert='virsh snapshot-revert'
alias vmsnapdel='virsh snapshot-delete'
# Edit
alias vmedit='virsh edit'
Now virsh list --all becomes vms. On its own that’s nothing. But you run a handful of these dozens of times a day, and the saved keystrokes plus the saved “what was that flag again” lookups quietly change how often you reach for VMs at all.
The workhorse: vmquick
Aliases shorten things you already know. The real friction is creation, so this is the script I use most. Make a VM from an ISO with as little typing as I could get away with:
#!/bin/bash
# vmquick - Quick VM creation with sane defaults
# Usage: vmquick <name> <iso-path> [ram-gb] [cpus] [disk-gb]
set -euo pipefail
# Defaults (easily upgradeable later)
DEFAULT_RAM=2 # GB
DEFAULT_CPUS=2
DEFAULT_DISK=20 # GB
VM_PATH="/var/lib/libvirt/images"
NETWORK="default" # Or your bridge name
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
usage() {
echo "Usage: vmquick <name> <iso-path> [ram-gb] [cpus] [disk-gb]"
echo ""
echo "Examples:"
echo " vmquick ubuntu ubuntu-22.04.iso # 2GB RAM, 2 CPUs, 20GB disk"
echo " vmquick fedora fedora-39.iso 4 # 4GB RAM"
echo " vmquick arch archlinux.iso 4 4 50 # 4GB RAM, 4 CPUs, 50GB disk"
echo ""
echo "Defaults: ${DEFAULT_RAM}GB RAM, ${DEFAULT_CPUS} CPUs, ${DEFAULT_DISK}GB disk"
exit 1
}
# Check arguments
[[ $# -lt 2 ]] && usage
NAME="$1"
ISO="$2"
RAM="${3:-$DEFAULT_RAM}"
CPUS="${4:-$DEFAULT_CPUS}"
DISK="${5:-$DEFAULT_DISK}"
# Convert RAM to MB for virt-install
RAM_MB=$((RAM * 1024))
# Validate ISO exists
if [[ ! -f "$ISO" ]]; then
# Check common download locations
for dir in ~/Downloads ~/ISOs /tmp; do
if [[ -f "$dir/$ISO" ]]; then
ISO="$dir/$ISO"
break
fi
done
fi
if [[ ! -f "$ISO" ]]; then
echo -e "${RED}Error: ISO not found: $ISO${NC}"
echo "Checked: $ISO, ~/Downloads/$ISO, ~/ISOs/$ISO, /tmp/$ISO"
exit 1
fi
# Check if VM already exists
if virsh dominfo "$NAME" &>/dev/null; then
echo -e "${RED}Error: VM '$NAME' already exists${NC}"
echo "Use 'vmrm $NAME' to remove it first"
exit 1
fi
DISK_PATH="${VM_PATH}/${NAME}.qcow2"
echo -e "${GREEN}Creating VM: $NAME${NC}"
echo " RAM: ${RAM}GB (${RAM_MB}MB)"
echo " CPUs: $CPUS"
echo " Disk: ${DISK}GB at $DISK_PATH"
echo " ISO: $ISO"
echo ""
# Detect OS variant (best effort)
OS_VARIANT="linux2022"
case "$ISO" in
*ubuntu*22*|*jammy*) OS_VARIANT="ubuntu22.04" ;;
*ubuntu*24*|*noble*) OS_VARIANT="ubuntu24.04" ;;
*ubuntu*20*|*focal*) OS_VARIANT="ubuntu20.04" ;;
*debian*12*|*bookworm*) OS_VARIANT="debian12" ;;
*debian*11*|*bullseye*) OS_VARIANT="debian11" ;;
*fedora*39*) OS_VARIANT="fedora39" ;;
*fedora*40*) OS_VARIANT="fedora40" ;;
*arch*) OS_VARIANT="archlinux" ;;
*alpine*) OS_VARIANT="alpinelinux3.18" ;;
*centos*9*|*stream*9*) OS_VARIANT="centos-stream9" ;;
*rocky*9*) OS_VARIANT="rocky9" ;;
*alma*9*) OS_VARIANT="almalinux9" ;;
*windows*11*) OS_VARIANT="win11" ;;
*windows*10*) OS_VARIANT="win10" ;;
*talos*) OS_VARIANT="linux2022" ;;
esac
echo -e "${YELLOW}Detected OS variant: $OS_VARIANT${NC}"
echo ""
# Create the VM
virt-install \
--name "$NAME" \
--ram "$RAM_MB" \
--vcpus "$CPUS" \
--disk "path=$DISK_PATH,size=$DISK,format=qcow2,bus=virtio" \
--cdrom "$ISO" \
--os-variant "$OS_VARIANT" \
--network "network=$NETWORK,model=virtio" \
--graphics spice,listen=none \
--video virtio \
--channel spicevmc \
--console pty,target_type=serial \
--boot cdrom,hd \
--noautoconsole
echo ""
echo -e "${GREEN}VM '$NAME' created and started!${NC}"
echo ""
echo "Next steps:"
echo " vmview $NAME # GUI console (virt-viewer)"
echo " vmcon $NAME # Serial console (if OS supports)"
echo " vmip $NAME # Get IP address (after OS install)"
echo ""
echo "To upgrade later:"
echo " vmupgrade $NAME 8 # Set RAM to 8GB"
echo " vmupgrade $NAME 8 4 # Set RAM to 8GB, CPUs to 4"
echo " vmupgrade $NAME 8 4 50 # Also expand disk to 50GB"
Save it as ~/.local/bin/vmquick, chmod +x it, and you’re done.
Usage examples
# Minimal - just name and ISO
vmquick testvm ubuntu-22.04.4-live-server-amd64.iso
# With more RAM
vmquick k8s-node talos-amd64.iso 8
# Full specs for a beefy workload
vmquick gitlab fedora-server-40.iso 16 8 100
It hunts for the ISO in the usual places, so if the file is sitting in ~/Downloads or ~/ISOs you can just give the filename and skip the full path.
Resizing later: vmupgrade
I start every VM small on purpose. 2GB and two cores is plenty to find out whether something works, and if I need more later, bumping it up is one command instead of a reinstall. That’s what this script handles:
#!/bin/bash
# vmupgrade - Upgrade VM resources (requires VM to be stopped)
# Usage: vmupgrade <name> [ram-gb] [cpus] [disk-gb]
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
usage() {
echo "Usage: vmupgrade <name> [ram-gb] [cpus] [disk-gb]"
echo ""
echo "Examples:"
echo " vmupgrade myvm 8 # Set RAM to 8GB"
echo " vmupgrade myvm 8 4 # Set RAM to 8GB, CPUs to 4"
echo " vmupgrade myvm 8 4 50 # Also expand disk to 50GB"
echo ""
echo "Note: VM must be stopped. Disk can only be increased, not decreased."
exit 1
}
[[ $# -lt 2 ]] && usage
NAME="$1"
RAM="${2:-}"
CPUS="${3:-}"
DISK="${4:-}"
# Check VM exists
if ! virsh dominfo "$NAME" &>/dev/null; then
echo -e "${RED}Error: VM '$NAME' not found${NC}"
exit 1
fi
# Check VM is stopped
STATE=$(virsh domstate "$NAME" 2>/dev/null)
if [[ "$STATE" != "shut off" ]]; then
echo -e "${RED}Error: VM must be stopped first${NC}"
echo "Current state: $STATE"
echo "Run: vmstop $NAME"
exit 1
fi
echo -e "${GREEN}Upgrading VM: $NAME${NC}"
echo ""
# Update RAM
if [[ -n "$RAM" ]]; then
RAM_MB=$((RAM * 1024))
echo "Setting RAM to ${RAM}GB..."
virsh setmaxmem "$NAME" "${RAM_MB}M" --config
virsh setmem "$NAME" "${RAM_MB}M" --config
echo -e "${GREEN} RAM: ${RAM}GB${NC}"
fi
# Update CPUs
if [[ -n "$CPUS" ]]; then
echo "Setting CPUs to $CPUS..."
virsh setvcpus "$NAME" "$CPUS" --config --maximum
virsh setvcpus "$NAME" "$CPUS" --config
echo -e "${GREEN} CPUs: $CPUS${NC}"
fi
# Expand disk
if [[ -n "$DISK" ]]; then
# Find the disk
DISK_PATH=$(virsh domblklist "$NAME" | grep -E '\.qcow2|\.img' | awk '{print $2}' | head -1)
if [[ -z "$DISK_PATH" ]]; then
echo -e "${YELLOW}Warning: Could not find disk to resize${NC}"
else
CURRENT_SIZE=$(qemu-img info "$DISK_PATH" | grep 'virtual size' | grep -oP '\d+(?= GiB)')
if [[ "$DISK" -le "$CURRENT_SIZE" ]]; then
echo -e "${YELLOW}Warning: New size ($DISK GB) is not larger than current ($CURRENT_SIZE GB)${NC}"
echo "Disk resize skipped (can only increase)"
else
echo "Expanding disk from ${CURRENT_SIZE}GB to ${DISK}GB..."
qemu-img resize "$DISK_PATH" "${DISK}G"
echo -e "${GREEN} Disk: ${DISK}GB${NC}"
echo -e "${YELLOW} Note: You'll need to expand the partition inside the VM${NC}"
fi
fi
fi
echo ""
echo -e "${GREEN}Upgrade complete!${NC}"
echo "Start the VM with: vmstart $NAME"
Snapshots without the ceremony: vmsnap
A snapshot is the safety net that lets me do reckless things on purpose. Break the VM, revert, try again, no harm done. The raw virsh snapshot-* commands are fine but verbose, so this wrapper takes the friction out:
#!/bin/bash
# vmsnap - Easy snapshot management
# Usage: vmsnap <command> <vm-name> [snapshot-name]
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
usage() {
echo "Usage: vmsnap <command> <vm-name> [snapshot-name]"
echo ""
echo "Commands:"
echo " create <vm> [name] Create snapshot (default name: timestamp)"
echo " list <vm> List all snapshots"
echo " revert <vm> [name] Revert to snapshot (default: latest)"
echo " delete <vm> <name> Delete a snapshot"
echo " info <vm> <name> Show snapshot details"
echo ""
echo "Examples:"
echo " vmsnap create myvm # Create with timestamp name"
echo " vmsnap create myvm before-upgrade # Create with custom name"
echo " vmsnap list myvm # Show all snapshots"
echo " vmsnap revert myvm # Revert to latest"
echo " vmsnap revert myvm before-upgrade # Revert to specific snapshot"
exit 1
}
[[ $# -lt 2 ]] && usage
CMD="$1"
VM="$2"
SNAP_NAME="${3:-}"
# Verify VM exists
if ! virsh dominfo "$VM" &>/dev/null; then
echo -e "${RED}Error: VM '$VM' not found${NC}"
exit 1
fi
case "$CMD" in
create)
# Generate name if not provided
if [[ -z "$SNAP_NAME" ]]; then
SNAP_NAME="snap-$(date +%Y%m%d-%H%M%S)"
fi
echo -e "${GREEN}Creating snapshot '$SNAP_NAME' for VM '$VM'...${NC}"
virsh snapshot-create-as "$VM" "$SNAP_NAME" --description "Created by vmsnap on $(date)"
echo -e "${GREEN}Snapshot created!${NC}"
;;
list)
echo -e "${CYAN}Snapshots for VM '$VM':${NC}"
echo ""
virsh snapshot-list "$VM" --tree 2>/dev/null || virsh snapshot-list "$VM"
;;
revert)
if [[ -z "$SNAP_NAME" ]]; then
# Get the latest snapshot
SNAP_NAME=$(virsh snapshot-list "$VM" --name | tail -1)
if [[ -z "$SNAP_NAME" ]]; then
echo -e "${RED}Error: No snapshots found for VM '$VM'${NC}"
exit 1
fi
echo -e "${YELLOW}No snapshot specified, using latest: $SNAP_NAME${NC}"
fi
echo -e "${GREEN}Reverting VM '$VM' to snapshot '$SNAP_NAME'...${NC}"
virsh snapshot-revert "$VM" "$SNAP_NAME"
echo -e "${GREEN}Reverted!${NC}"
;;
delete)
if [[ -z "$SNAP_NAME" ]]; then
echo -e "${RED}Error: Snapshot name required for delete${NC}"
usage
fi
echo -e "${YELLOW}Deleting snapshot '$SNAP_NAME' from VM '$VM'...${NC}"
virsh snapshot-delete "$VM" "$SNAP_NAME"
echo -e "${GREEN}Deleted!${NC}"
;;
info)
if [[ -z "$SNAP_NAME" ]]; then
echo -e "${RED}Error: Snapshot name required for info${NC}"
usage
fi
virsh snapshot-info "$VM" "$SNAP_NAME"
;;
*)
echo -e "${RED}Unknown command: $CMD${NC}"
usage
;;
esac
How that plays out
# Before doing something risky
vmsnap create testvm before-experiment
# Do risky thing...
# Oh no, it broke!
# Revert to safety
vmsnap revert testvm before-experiment
# Or just revert to the latest snapshot
vmsnap revert testvm
Finding the IP: vmip
This one exists because virsh domifaddr lied to me one too many times. It only works once the guest agent is up, which means right after boot, when I actually want the IP, it tells me nothing. So this script tries the guest agent, then the DHCP lease, then the ARP table, and returns whatever answers first:
#!/bin/bash
# vmip - Get VM IP address(es)
# Usage: vmip <vm-name> [-w]
set -euo pipefail
VM="${1:-}"
WAIT="${2:-}"
if [[ -z "$VM" ]]; then
echo "Usage: vmip <vm-name> [-w]"
echo " -w Wait for IP to become available"
exit 1
fi
get_ip() {
# Try multiple methods
# Method 1: Guest agent (most reliable if installed)
IP=$(virsh domifaddr "$VM" --source agent 2>/dev/null | grep -oP '\d+\.\d+\.\d+\.\d+' | head -1)
[[ -n "$IP" ]] && echo "$IP" && return 0
# Method 2: DHCP lease (works for NAT network)
IP=$(virsh domifaddr "$VM" --source lease 2>/dev/null | grep -oP '\d+\.\d+\.\d+\.\d+' | head -1)
[[ -n "$IP" ]] && echo "$IP" && return 0
# Method 3: ARP table
MAC=$(virsh domiflist "$VM" 2>/dev/null | grep -oP '([0-9a-f]{2}:){5}[0-9a-f]{2}' | head -1)
if [[ -n "$MAC" ]]; then
IP=$(arp -an | grep -i "$MAC" | grep -oP '\d+\.\d+\.\d+\.\d+' | head -1)
[[ -n "$IP" ]] && echo "$IP" && return 0
fi
return 1
}
if [[ "$WAIT" == "-w" ]]; then
echo "Waiting for IP address for '$VM'..." >&2
for i in {1..60}; do
if IP=$(get_ip); then
echo "$IP"
exit 0
fi
sleep 2
done
echo "Timeout waiting for IP" >&2
exit 1
else
if IP=$(get_ip); then
echo "$IP"
else
echo "No IP found for '$VM'" >&2
echo "VM might not be running or IP not yet assigned" >&2
exit 1
fi
fi
Use it like this:
# Get IP immediately (if available)
vmip myvm
# Wait for IP (useful right after boot)
vmip myvm -w
# SSH directly
ssh user@$(vmip myvm)
Doing things in bulk: vmall
Once you’re running a cluster of test VMs, operating on them one at a time gets old fast. Spinning up four k8s nodes and starting them by hand is the kind of repetitive thing my brain refuses to do reliably, so vmall does it for me:
#!/bin/bash
# vmall - Run operation on all VMs (or filtered set)
# Usage: vmall <command> [filter]
set -euo pipefail
CMD="${1:-list}"
FILTER="${2:-}"
case "$CMD" in
list|ls)
virsh list --all
;;
start)
for vm in $(virsh list --name --state-shutoff | grep -E "${FILTER:-.}"); do
echo "Starting $vm..."
virsh start "$vm"
done
;;
stop|shutdown)
for vm in $(virsh list --name --state-running | grep -E "${FILTER:-.}"); do
echo "Stopping $vm..."
virsh shutdown "$vm"
done
;;
kill|destroy)
for vm in $(virsh list --name --state-running | grep -E "${FILTER:-.}"); do
echo "Force stopping $vm..."
virsh destroy "$vm"
done
;;
ips)
for vm in $(virsh list --name --state-running | grep -E "${FILTER:-.}"); do
IP=$(vmip "$vm" 2>/dev/null || echo "N/A")
printf "%-20s %s\n" "$vm" "$IP"
done
;;
snap)
SNAP_NAME="bulk-$(date +%Y%m%d-%H%M%S)"
for vm in $(virsh list --name | grep -E "${FILTER:-.}"); do
echo "Snapshotting $vm..."
virsh snapshot-create-as "$vm" "$SNAP_NAME" 2>/dev/null || echo " Failed (external disk?)"
done
;;
*)
echo "Usage: vmall <command> [filter]"
echo ""
echo "Commands:"
echo " list List all VMs"
echo " start [filter] Start stopped VMs matching filter"
echo " stop [filter] Gracefully stop running VMs"
echo " kill [filter] Force stop running VMs"
echo " ips [filter] Show IPs of running VMs"
echo " snap [filter] Snapshot all VMs"
echo ""
echo "Filter is a regex pattern (e.g., 'k8s' for all k8s-* VMs)"
;;
esac
In practice
# Start all k8s nodes
vmall start k8s
# Get IPs for all running VMs
vmall ips
# Snapshot everything before cluster upgrade
vmall snap
# Stop all test VMs
vmall stop test
Skipping the installer entirely: vmcloud
For anything that speaks cloud-init, which is most modern distros, sitting through an installer is wasted time. A cloud image is already a working system. You hand it a bit of config (hostname, your SSH key, the packages you want) and it boots straight into a usable machine. This script wires that up:
#!/bin/bash
# vmcloud - Create VM from cloud image with cloud-init
# Usage: vmcloud <name> <cloud-image> [ram-gb] [cpus] [disk-gb]
set -euo pipefail
VM_PATH="/var/lib/libvirt/images"
CLOUD_INIT_PATH="/tmp/cloud-init-$$"
DEFAULT_RAM=2
DEFAULT_CPUS=2
DEFAULT_DISK=20
DEFAULT_USER="admin"
DEFAULT_PASSWORD="changeme" # Override with env var
NAME="${1:-}"
IMAGE="${2:-}"
RAM="${3:-$DEFAULT_RAM}"
CPUS="${4:-$DEFAULT_CPUS}"
DISK="${5:-$DEFAULT_DISK}"
SSH_KEY="${SSH_KEY:-$(cat ~/.ssh/id_ed25519.pub 2>/dev/null || cat ~/.ssh/id_rsa.pub 2>/dev/null || echo '')}"
PASSWORD="${VM_PASSWORD:-$DEFAULT_PASSWORD}"
usage() {
echo "Usage: vmcloud <name> <cloud-image> [ram-gb] [cpus] [disk-gb]"
echo ""
echo "Environment variables:"
echo " SSH_KEY SSH public key to inject (default: ~/.ssh/id_ed25519.pub)"
echo " VM_PASSWORD Password for default user (default: changeme)"
echo ""
echo "Examples:"
echo " vmcloud myvm ubuntu-22.04-minimal-cloudimg-amd64.img"
echo " vmcloud myvm debian-12-genericcloud-amd64.qcow2 4 4 50"
exit 1
}
[[ $# -lt 2 ]] && usage
# Create disk from cloud image
DISK_PATH="${VM_PATH}/${NAME}.qcow2"
if [[ -f "$DISK_PATH" ]]; then
echo "Error: Disk already exists: $DISK_PATH"
exit 1
fi
echo "Creating disk from cloud image..."
cp "$IMAGE" "$DISK_PATH"
qemu-img resize "$DISK_PATH" "${DISK}G"
# Create cloud-init config
mkdir -p "$CLOUD_INIT_PATH"
cat > "$CLOUD_INIT_PATH/meta-data" <<EOF
instance-id: ${NAME}
local-hostname: ${NAME}
EOF
cat > "$CLOUD_INIT_PATH/user-data" <<EOF
#cloud-config
hostname: ${NAME}
manage_etc_hosts: true
users:
- name: ${DEFAULT_USER}
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
lock_passwd: false
passwd: $(openssl passwd -6 "$PASSWORD")
ssh_authorized_keys:
- ${SSH_KEY}
package_update: true
package_upgrade: false
packages:
- qemu-guest-agent
runcmd:
- systemctl enable --now qemu-guest-agent
EOF
# Create cloud-init ISO
genisoimage -output "$CLOUD_INIT_PATH/cloud-init.iso" \
-volid cidata -joliet -rock \
"$CLOUD_INIT_PATH/user-data" \
"$CLOUD_INIT_PATH/meta-data" 2>/dev/null
CLOUD_INIT_ISO="${VM_PATH}/${NAME}-cloud-init.iso"
mv "$CLOUD_INIT_PATH/cloud-init.iso" "$CLOUD_INIT_ISO"
rm -rf "$CLOUD_INIT_PATH"
# Create VM
RAM_MB=$((RAM * 1024))
virt-install \
--name "$NAME" \
--ram "$RAM_MB" \
--vcpus "$CPUS" \
--disk "path=$DISK_PATH,format=qcow2,bus=virtio" \
--disk "path=$CLOUD_INIT_ISO,device=cdrom" \
--os-variant linux2022 \
--network network=default,model=virtio \
--graphics spice,listen=none \
--console pty,target_type=serial \
--import \
--noautoconsole
echo ""
echo "VM '$NAME' created!"
echo ""
echo "Default credentials:"
echo " User: $DEFAULT_USER"
echo " Password: $PASSWORD (if SSH key didn't work)"
echo ""
echo "Connect:"
echo " ssh $DEFAULT_USER@\$(vmip $NAME -w)"
Download the cloud image once, and from then on a fresh VM is a few seconds away:
# Download a cloud image once
wget https://cloud-images.ubuntu.com/minimal/releases/jammy/release/ubuntu-22.04-minimal-cloudimg-amd64.img -O ~/ISOs/ubuntu-cloud.img
# Spin up VMs instantly
vmcloud web-server ~/ISOs/ubuntu-cloud.img 2 2 20
vmcloud db-server ~/ISOs/ubuntu-cloud.img 8 4 100
# SSH in immediately
ssh admin@$(vmip web-server -w)
Moving VMs around: vmexport and vmimport
I shuffle VMs between machines often enough that this matters. A test VM graduates from my workstation to the server. A VM gets backed up before I do something I might regret. The native virsh commands can do all of it, but it’s a sequence of dumpxml, copy disks, fix paths, redefine, and I will forget a step every single time. So I wrapped the whole dance.
Export: vmexport
#!/bin/bash
# vmexport - Export VM to portable archive (disk + XML)
# Usage: vmexport <vm-name> [output-dir]
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
NAME="${1:-}"
OUTPUT_DIR="${2:-.}"
usage() {
echo "Usage: vmexport <vm-name> [output-dir]"
echo ""
echo "Exports VM definition and disks to a directory."
echo "The VM should be shut down for consistent export."
echo ""
echo "Examples:"
echo " vmexport myvm # Export to current directory"
echo " vmexport myvm /backup/vms # Export to specific directory"
echo " vmexport myvm - # Stream to stdout (for piping to ssh)"
exit 1
}
# Interactive selection with fzf if no VM specified
if [[ -z "$NAME" ]]; then
if command -v fzf &>/dev/null; then
NAME=$(virsh list --all --name | grep -v '^$' | \
fzf --prompt="Select VM to export: " \
--preview 'virsh dominfo {} 2>/dev/null; echo "---"; virsh domblklist {} 2>/dev/null')
[[ -z "$NAME" ]] && exit 1
else
usage
fi
fi
# Verify VM exists
if ! virsh dominfo "$NAME" &>/dev/null; then
echo -e "${RED}Error: VM '$NAME' not found${NC}"
exit 1
fi
# Warn if VM is running
STATE=$(virsh domstate "$NAME" 2>/dev/null)
if [[ "$STATE" != "shut off" ]]; then
echo -e "${YELLOW}Warning: VM is $STATE. For consistent export, shut it down first.${NC}"
read -p "Continue anyway? [y/N] " confirm
[[ ! "$confirm" =~ ^[Yy]$ ]] && exit 1
fi
# Create export directory
EXPORT_DIR="${OUTPUT_DIR}/${NAME}-export-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$EXPORT_DIR"
echo -e "${GREEN}Exporting VM: $NAME${NC}"
echo " Output: $EXPORT_DIR"
echo ""
# Export XML definition
echo -e "${CYAN}Exporting VM definition...${NC}"
virsh dumpxml "$NAME" > "$EXPORT_DIR/${NAME}.xml"
# Export each disk
echo -e "${CYAN}Exporting disks...${NC}"
while IFS= read -r line; do
TARGET=$(echo "$line" | awk '{print $1}')
SOURCE=$(echo "$line" | awk '{print $2}')
# Skip empty lines and CDROMs
[[ -z "$SOURCE" || "$SOURCE" == "-" ]] && continue
[[ ! -f "$SOURCE" ]] && continue
DISK_NAME=$(basename "$SOURCE")
echo " $TARGET: $SOURCE -> $DISK_NAME"
# Use qemu-img convert to compress and potentially convert format
qemu-img convert -O qcow2 -c "$SOURCE" "$EXPORT_DIR/$DISK_NAME"
done < <(virsh domblklist "$NAME" --details | grep -E "file\s+disk" | awk '{print $3, $4}')
# Create metadata file
cat > "$EXPORT_DIR/metadata.txt" <<EOF
VM Export
=========
Name: $NAME
Date: $(date)
Source host: $(hostname)
Original state: $STATE
Files:
$(ls -lh "$EXPORT_DIR")
EOF
# Calculate total size
TOTAL_SIZE=$(du -sh "$EXPORT_DIR" | cut -f1)
echo ""
echo -e "${GREEN}Export complete!${NC}"
echo " Location: $EXPORT_DIR"
echo " Size: $TOTAL_SIZE"
echo ""
echo "To import on another host:"
echo " scp -r $EXPORT_DIR user@target:/path/"
echo " vmimport /path/$(basename "$EXPORT_DIR")"
Import: vmimport
#!/bin/bash
# vmimport - Import VM from exported archive
# Usage: vmimport <export-dir> [new-name]
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
VM_PATH="/var/lib/libvirt/images"
EXPORT_DIR="${1:-}"
NEW_NAME="${2:-}"
usage() {
echo "Usage: vmimport <export-dir> [new-name]"
echo ""
echo "Imports a VM from vmexport archive."
echo ""
echo "Examples:"
echo " vmimport ./myvm-export-20240101-120000"
echo " vmimport ./myvm-export-20240101-120000 myvm-restored"
exit 1
}
[[ -z "$EXPORT_DIR" ]] && usage
[[ ! -d "$EXPORT_DIR" ]] && { echo -e "${RED}Error: Directory not found: $EXPORT_DIR${NC}"; exit 1; }
# Find the XML file
XML_FILE=$(find "$EXPORT_DIR" -name "*.xml" -type f | head -1)
[[ -z "$XML_FILE" ]] && { echo -e "${RED}Error: No XML definition found in $EXPORT_DIR${NC}"; exit 1; }
# Get original name from XML
ORIG_NAME=$(grep -oP "(?<=<name>)[^<]+" "$XML_FILE")
NAME="${NEW_NAME:-$ORIG_NAME}"
echo -e "${GREEN}Importing VM: $NAME${NC}"
echo " Source: $EXPORT_DIR"
echo " Original name: $ORIG_NAME"
[[ -n "$NEW_NAME" ]] && echo " New name: $NEW_NAME"
echo ""
# Check if VM already exists
if virsh dominfo "$NAME" &>/dev/null; then
echo -e "${RED}Error: VM '$NAME' already exists${NC}"
echo "Use a different name: vmimport $EXPORT_DIR different-name"
exit 1
fi
# Copy disks to libvirt images directory
echo -e "${CYAN}Copying disks...${NC}"
declare -A DISK_MAP
for disk in "$EXPORT_DIR"/*.qcow2 "$EXPORT_DIR"/*.img; do
[[ ! -f "$disk" ]] && continue
DISK_NAME=$(basename "$disk")
# If renaming VM, also rename disk files
if [[ -n "$NEW_NAME" ]]; then
NEW_DISK_NAME="${DISK_NAME/$ORIG_NAME/$NEW_NAME}"
else
NEW_DISK_NAME="$DISK_NAME"
fi
DEST_PATH="$VM_PATH/$NEW_DISK_NAME"
# Check if destination exists
if [[ -f "$DEST_PATH" ]]; then
echo -e "${YELLOW}Warning: $DEST_PATH already exists${NC}"
read -p "Overwrite? [y/N] " confirm
[[ ! "$confirm" =~ ^[Yy]$ ]] && { echo "Skipping..."; continue; }
fi
echo " $DISK_NAME -> $DEST_PATH"
cp "$disk" "$DEST_PATH"
# Store mapping for XML update
DISK_MAP["$DISK_NAME"]="$DEST_PATH"
done
# Prepare XML with updated paths and name
echo -e "${CYAN}Preparing VM definition...${NC}"
TEMP_XML=$(mktemp)
cp "$XML_FILE" "$TEMP_XML"
# Update VM name if changed
if [[ -n "$NEW_NAME" ]]; then
sed -i "s|<name>$ORIG_NAME</name>|<name>$NEW_NAME</name>|g" "$TEMP_XML"
fi
# Update disk paths
for orig_disk in "${!DISK_MAP[@]}"; do
new_path="${DISK_MAP[$orig_disk]}"
# Update any path ending with the original disk name
sed -i "s|file='[^']*${orig_disk}'|file='${new_path}'|g" "$TEMP_XML"
sed -i "s|source file=\"[^\"]*${orig_disk}\"|source file=\"${new_path}\"|g" "$TEMP_XML"
done
# Remove UUID (let libvirt generate new one)
sed -i '/<uuid>/d' "$TEMP_XML"
# Remove MAC addresses (let libvirt generate new ones to avoid conflicts)
sed -i '/<mac address=/d' "$TEMP_XML"
# Define the VM
echo -e "${CYAN}Defining VM...${NC}"
virsh define "$TEMP_XML"
rm "$TEMP_XML"
echo ""
echo -e "${GREEN}Import complete!${NC}"
echo ""
echo "Start the VM with: vmstart $NAME"
Migrating live: vmmigrate
Sometimes the VM needs to move while it’s still running, or at least without me babysitting the export/import shuffle. This script handles the three cases I actually hit: a true live migration when there’s shared storage, a live migration that drags the disk along when there isn’t, and a plain offline move that stops, copies, and restarts on the far side:
#!/bin/bash
# vmmigrate - Migrate VM to another libvirt host
# Usage: vmmigrate <vm-name> <target-host> [--live] [--offline]
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
NAME="${1:-}"
TARGET="${2:-}"
MODE="${3:---offline}"
# Known hosts for fzf selection (customize this)
KNOWN_HOSTS="${VM_MIGRATE_HOSTS:-}"
usage() {
echo "Usage: vmmigrate <vm-name> <target-host> [--live|--offline]"
echo ""
echo "Migrate a VM to another libvirt host."
echo ""
echo "Options:"
echo " --live Live migration (VM keeps running, requires shared storage"
echo " or same CPU model)"
echo " --offline Offline migration (default, VM is stopped, disk copied)"
echo " --copy Live migration with disk copy (slower but no shared storage needed)"
echo ""
echo "Examples:"
echo " vmmigrate myvm server2 --offline # Stop, copy, start on server2"
echo " vmmigrate myvm server2 --live # Live migrate (shared storage)"
echo " vmmigrate myvm server2 --copy # Live migrate with disk copy"
echo ""
echo "Environment:"
echo " VM_MIGRATE_HOSTS Space-separated list of hosts for fzf selection"
exit 1
}
# Interactive VM selection
if [[ -z "$NAME" ]] && command -v fzf &>/dev/null; then
NAME=$(virsh list --all --name | grep -v '^$' | \
fzf --prompt="Select VM to migrate: " \
--preview 'virsh dominfo {} 2>/dev/null')
[[ -z "$NAME" ]] && exit 1
fi
# Interactive host selection
if [[ -z "$TARGET" ]] && command -v fzf &>/dev/null; then
if [[ -n "$KNOWN_HOSTS" ]]; then
TARGET=$(echo "$KNOWN_HOSTS" | tr ' ' '\n' | \
fzf --prompt="Select target host: " \
--preview 'ssh {} "virsh list --all" 2>/dev/null || echo "Cannot connect"')
else
read -p "Target host: " TARGET
fi
[[ -z "$TARGET" ]] && exit 1
fi
[[ -z "$NAME" || -z "$TARGET" ]] && usage
# Verify VM exists
if ! virsh dominfo "$NAME" &>/dev/null; then
echo -e "${RED}Error: VM '$NAME' not found${NC}"
exit 1
fi
# Test SSH connectivity
echo -e "${CYAN}Testing connection to $TARGET...${NC}"
if ! ssh -o ConnectTimeout=5 "$TARGET" "virsh version" &>/dev/null; then
echo -e "${RED}Error: Cannot connect to libvirt on $TARGET${NC}"
echo "Ensure SSH works and libvirt is running: ssh $TARGET 'virsh version'"
exit 1
fi
STATE=$(virsh domstate "$NAME" 2>/dev/null)
echo -e "${GREEN}Migrating VM: $NAME${NC}"
echo " From: $(hostname)"
echo " To: $TARGET"
echo " Mode: $MODE"
echo " Current state: $STATE"
echo ""
case "$MODE" in
--live)
if [[ "$STATE" != "running" ]]; then
echo -e "${RED}Error: Live migration requires VM to be running${NC}"
exit 1
fi
echo -e "${CYAN}Starting live migration...${NC}"
echo "This requires shared storage between hosts."
virsh migrate --live --persistent --undefinesource \
"$NAME" "qemu+ssh://$TARGET/system"
echo -e "${GREEN}Live migration complete!${NC}"
;;
--copy)
if [[ "$STATE" != "running" ]]; then
echo -e "${RED}Error: Copy migration requires VM to be running${NC}"
exit 1
fi
echo -e "${CYAN}Starting live migration with disk copy...${NC}"
echo "This will copy all disks over the network (can be slow for large disks)."
virsh migrate --live --persistent --undefinesource \
--copy-storage-all \
"$NAME" "qemu+ssh://$TARGET/system"
echo -e "${GREEN}Migration with disk copy complete!${NC}"
;;
--offline|*)
echo -e "${CYAN}Starting offline migration...${NC}"
# Shut down if running
if [[ "$STATE" == "running" ]]; then
echo "Shutting down VM..."
virsh shutdown "$NAME"
# Wait for shutdown
for i in {1..60}; do
STATE=$(virsh domstate "$NAME" 2>/dev/null)
[[ "$STATE" == "shut off" ]] && break
sleep 2
done
if [[ "$STATE" != "shut off" ]]; then
echo -e "${YELLOW}VM didn't shut down gracefully, forcing...${NC}"
virsh destroy "$NAME"
fi
fi
# Export
TEMP_DIR=$(mktemp -d)
echo "Exporting to $TEMP_DIR..."
virsh dumpxml "$NAME" > "$TEMP_DIR/${NAME}.xml"
# Copy disks
while IFS= read -r line; do
SOURCE=$(echo "$line" | awk '{print $2}')
[[ -z "$SOURCE" || "$SOURCE" == "-" || ! -f "$SOURCE" ]] && continue
DISK_NAME=$(basename "$SOURCE")
echo " Copying $DISK_NAME..."
cp "$SOURCE" "$TEMP_DIR/"
done < <(virsh domblklist "$NAME" --details | grep -E "file\s+disk" | awk '{print $3, $4}')
# Transfer to remote
echo "Transferring to $TARGET..."
REMOTE_PATH="/var/lib/libvirt/images"
# Copy disks first
for disk in "$TEMP_DIR"/*.qcow2 "$TEMP_DIR"/*.img; do
[[ ! -f "$disk" ]] && continue
scp "$disk" "$TARGET:$REMOTE_PATH/"
done
# Update XML with new paths and copy
sed -i "s|/var/lib/libvirt/images/|$REMOTE_PATH/|g" "$TEMP_DIR/${NAME}.xml"
sed -i '/<uuid>/d' "$TEMP_DIR/${NAME}.xml"
scp "$TEMP_DIR/${NAME}.xml" "$TARGET:/tmp/"
# Define on remote
ssh "$TARGET" "virsh define /tmp/${NAME}.xml && rm /tmp/${NAME}.xml"
# Clean up local
rm -rf "$TEMP_DIR"
# Optionally undefine locally
read -p "Remove VM from this host? [y/N] " confirm
if [[ "$confirm" =~ ^[Yy]$ ]]; then
virsh undefine "$NAME" --remove-all-storage
echo "VM removed from local host."
fi
echo ""
echo -e "${GREEN}Offline migration complete!${NC}"
echo "Start the VM on $TARGET with: ssh $TARGET 'vmstart $NAME'"
;;
esac
Cloning: vmclone
When I want a throwaway copy of a known-good machine, cloning beats building from scratch. Keep a clean ubuntu-base around, clone it, and the copy is ready in seconds:
#!/bin/bash
# vmclone - Clone an existing VM
# Usage: vmclone <source-vm> <new-name>
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
SOURCE="${1:-}"
NEW_NAME="${2:-}"
usage() {
echo "Usage: vmclone <source-vm> <new-name>"
echo ""
echo "Creates a full clone of an existing VM."
echo "Source VM should be shut down for consistent clone."
echo ""
echo "Examples:"
echo " vmclone ubuntu-base ubuntu-test"
echo " vmclone k8s-template k8s-worker-3"
exit 1
}
# Interactive selection with fzf
if [[ -z "$SOURCE" ]] && command -v fzf &>/dev/null; then
SOURCE=$(virsh list --all --name | grep -v '^$' | \
fzf --prompt="Select VM to clone: " \
--preview 'virsh dominfo {} 2>/dev/null')
[[ -z "$SOURCE" ]] && exit 1
fi
if [[ -z "$NEW_NAME" ]]; then
read -p "New VM name: " NEW_NAME
[[ -z "$NEW_NAME" ]] && { echo "Name required"; exit 1; }
fi
[[ -z "$SOURCE" || -z "$NEW_NAME" ]] && usage
# Verify source exists
if ! virsh dominfo "$SOURCE" &>/dev/null; then
echo -e "${RED}Error: Source VM '$SOURCE' not found${NC}"
exit 1
fi
# Check if target already exists
if virsh dominfo "$NEW_NAME" &>/dev/null; then
echo -e "${RED}Error: VM '$NEW_NAME' already exists${NC}"
exit 1
fi
# Warn if source is running
STATE=$(virsh domstate "$SOURCE" 2>/dev/null)
if [[ "$STATE" != "shut off" ]]; then
echo -e "${YELLOW}Warning: Source VM is $STATE.${NC}"
echo "Clone may be inconsistent. Shut down source for best results."
read -p "Continue anyway? [y/N] " confirm
[[ ! "$confirm" =~ ^[Yy]$ ]] && exit 1
fi
echo -e "${GREEN}Cloning VM: $SOURCE -> $NEW_NAME${NC}"
echo ""
# Use virt-clone for the heavy lifting
virt-clone \
--original "$SOURCE" \
--name "$NEW_NAME" \
--auto-clone
echo ""
echo -e "${GREEN}Clone complete!${NC}"
echo ""
echo "Start the clone with: vmstart $NEW_NAME"
# Show disk locations
echo ""
echo "Clone disks:"
virsh domblklist "$NEW_NAME" --details | grep -E "file\s+disk" | awk '{print " " $4}'
Putting the migration tools together
# Clone a template for testing
vmclone ubuntu-base test-ubuntu
vmstart test-ubuntu
# Export VM for backup
vmexport important-vm /backup/vms/
# Move VM to another server (offline)
vmmigrate webserver server2.local --offline
# Live migration with shared storage (NFS/Ceph)
vmmigrate database server2.local --live
# Move VM to server without shared storage
vmmigrate webserver server2.local --copy
# Import a VM from backup
vmimport /backup/vms/important-vm-export-20240101/
vmimport /backup/vms/important-vm-export-20240101/ restored-vm # With new name
Installation
All scripts are available on GitHub: github.com/kapott/vm-scripts
# Clone and install
git clone https://github.com/kapott/vm-scripts.git
cd vm-scripts
./install.sh
Or do it by hand: drop the scripts in ~/.local/bin/ and make sure it’s on your PATH:
# In .bashrc or .zshrc
export PATH="$HOME/.local/bin:$PATH"
# Source the aliases
source ~/.local/share/vm-scripts/vm-aliases.sh
Going interactive with fzf
If you’ve got fzf around, the whole thing gets a lot nicer. Half the friction with virsh is remembering exact VM names, and fzf kills that off completely: type a couple of letters, see a live preview of the VM, hit enter. These functions wrap the scripts above with that selector:
# ~/.vm-aliases - fzf enhanced section
# Interactive VM selector (use in other scripts)
vmf() {
virsh list --all --name | grep -v '^$' | \
fzf --prompt="Select VM: " \
--preview 'virsh dominfo {} 2>/dev/null; echo "---"; virsh domifaddr {} 2>/dev/null' \
--preview-window=right:50%
}
# SSH into VM with fuzzy selection
vmssh() {
local user="${1:-root}"
local vm=$(virsh list --name | grep -v '^$' | \
fzf --prompt="SSH to VM: " \
--preview 'echo "IP: $(vmip {} 2>/dev/null || echo "N/A")"; echo "---"; virsh dominfo {} 2>/dev/null | grep -E "State|CPU|memory"')
[[ -z "$vm" ]] && return 1
local ip=$(vmip "$vm" 2>/dev/null)
[[ -z "$ip" ]] && { echo "No IP for $vm"; return 1; }
ssh "${user}@${ip}"
}
# Start VMs with multi-select
vmstartf() {
virsh list --name --state-shutoff | grep -v '^$' | \
fzf --multi --prompt="Start VMs (TAB to select multiple): " \
--preview 'virsh dominfo {} 2>/dev/null' | \
xargs -I{} virsh start {}
}
# Stop VMs with multi-select
vmstopf() {
virsh list --name --state-running | grep -v '^$' | \
fzf --multi --prompt="Stop VMs (TAB to select multiple): " \
--preview 'virsh dominfo {} 2>/dev/null; echo "---"; echo "IP: $(vmip {} 2>/dev/null)"' | \
xargs -I{} virsh shutdown {}
}
# Delete VM with safety preview
vmrmf() {
local vm=$(virsh list --all --name | grep -v '^$' | \
fzf --prompt="DELETE VM (careful!): " \
--preview 'echo "=== VM Info ==="; virsh dominfo {} 2>/dev/null; echo; echo "=== Disks (WILL BE DELETED) ==="; virsh domblklist {} 2>/dev/null' \
--preview-window=right:60% \
--header="WARNING: This will delete the VM and ALL storage")
[[ -z "$vm" ]] && return 0
echo "Delete '$vm' and all storage? [y/N]"
read -r confirm
[[ "$confirm" =~ ^[Yy]$ ]] && virsh undefine "$vm" --remove-all-storage
}
# Snapshot management with fzf
vmsnapf() {
local vm=$(vmf)
[[ -z "$vm" ]] && return 1
local action=$(echo -e "create\nlist\nrevert\ndelete" | fzf --prompt="Snapshot action for $vm: ")
case "$action" in
create)
read -p "Snapshot name (empty for timestamp): " name
vmsnap create "$vm" "$name"
;;
list)
vmsnap list "$vm"
;;
revert)
local snap=$(virsh snapshot-list "$vm" --name 2>/dev/null | grep -v '^$' | \
fzf --prompt="Revert to snapshot: " \
--preview "virsh snapshot-info $vm {} 2>/dev/null" \
--tac)
[[ -n "$snap" ]] && vmsnap revert "$vm" "$snap"
;;
delete)
local snap=$(virsh snapshot-list "$vm" --name 2>/dev/null | grep -v '^$' | \
fzf --prompt="Delete snapshot: " \
--preview "virsh snapshot-info $vm {} 2>/dev/null")
[[ -n "$snap" ]] && vmsnap delete "$vm" "$snap"
;;
esac
}
# Quick VM from ISO with fzf selection
vmquickf() {
local iso=$(find ~/Downloads ~/ISOs /var/lib/libvirt/images -maxdepth 2 \
\( -name "*.iso" -o -name "*.img" \) 2>/dev/null | \
fzf --prompt="Select ISO/image: " \
--preview 'ls -lh {}; file {}')
[[ -z "$iso" ]] && return 1
local suggested=$(basename "$iso" | sed 's/\.[^.]*$//' | tr '[:upper:]' '[:lower:]' | tr ' _' '-' | cut -c1-20)
read -p "VM name [$suggested]: " name
name="${name:-$suggested}"
read -p "RAM in GB [2]: " ram
read -p "CPUs [2]: " cpus
read -p "Disk in GB [20]: " disk
vmquick "$name" "$iso" "${ram:-2}" "${cpus:-2}" "${disk:-20}"
}
With these, I genuinely never type a VM name in full anymore. A few letters and a preview is all it takes.
The full aliases file
Here’s the complete .vm-aliases file in one place, ready to source:
# ~/.vm-aliases
# Source this in .bashrc: source ~/.vm-aliases
# === Status ===
alias vms='virsh list --all'
alias vmsr='virsh list'
alias vmnets='virsh net-list --all'
alias vmpools='virsh pool-list --all'
# === Power ===
alias vmstart='virsh start'
alias vmstop='virsh shutdown'
alias vmkill='virsh destroy'
alias vmreboot='virsh reboot'
alias vmsuspend='virsh suspend'
alias vmresume='virsh resume'
# === Console ===
alias vmcon='virsh console'
alias vmview='virt-viewer'
# === Info ===
alias vminfo='virsh dominfo'
alias vmblk='virsh domblklist'
alias vmnet='virsh domiflist'
# === Management ===
alias vmedit='virsh edit'
alias vmxml='virsh dumpxml'
alias vmrm='virsh undefine --remove-all-storage'
# === Completions (bash) ===
_vm_complete() {
local cur="${COMP_WORDS[COMP_CWORD]}"
COMPREPLY=($(compgen -W "$(virsh list --all --name 2>/dev/null)" -- "$cur"))
}
complete -F _vm_complete vmstart vmstop vmkill vmreboot vmcon vmview vminfo vmblk vmnet vmedit vmxml vmrm vmip vmsnap vmupgrade vmsuspend vmresume
Why bother with any of this
Go back to the question I started with: how do you make a powerful tool low-friction enough that you actually reach for it? The honest answer is that the friction was never about KVM being hard. KVM is great. The friction was the distance between wanting a VM and having one, and these scripts collapse that distance to about ten seconds.
That sounds small until you watch what it does to your behavior. When spinning up a clean VM is genuinely faster than making coffee, you stop testing things in production because you couldn’t be bothered to isolate them. You stop skipping the test entirely. You stop reaching for a container when a real VM was the right call and you only avoided it out of laziness. The tool stopped being a chore, so I use it constantly, and my work got safer almost by accident.
There’s a sovereignty angle here too, the kind I keep coming back to in Sovereign Infrastructure. These are a couple hundred lines of shell. I can read every one of them, change what I don’t like, and fix them when they break, because nothing in here is hiding from me. That’s the trade I keep making: a bit of upfront effort writing the scripts, in exchange for tooling I fully own and understand. virt-manager would have been faster to adopt and slower to live with.
The scripts are on GitHub, linked above. Take them, gut them, rename everything to match your own muscle memory. The specific commands matter less than the principle: when a tool is worth using, it’s worth the small effort to make using it effortless.
