# Disposable KVM guest for running coding agents. # READ THIS FILE BEFORE RUNNING IT # # make deps install host packages, add you to libvirt/kvm (log out after) # make check verify hardware virtualization # make vm download + verify image, build overlay, boot the guest # make harden block the guest from your LAN and most host services # make snapshot shut down and snapshot a clean state # make revert roll back to the clean state # make start start the guest (e.g. after a host reboot) # make stop shut the guest down # make log follow the guest's console output without attaching # make console attach to the guest's serial console (Ctrl+] to detach) # make status show whether the guest is running, and why # make ssh connect to the guest # make destroy remove the guest and its disk (base image is kept) NAME ?= agent-vm MEM ?= 8192 CPUS ?= 4 CPU ?= host-passthrough DISK ?= 40G SHARE ?= $(HOME)/vm-share/project BUILD ?= $(HOME)/vm-images SSH_KEY ?= $(firstword $(wildcard $(HOME)/.ssh/id_ed25519.pub $(HOME)/.ssh/id_rsa.pub)) POOL := /var/lib/libvirt/images URL := https://cloud.debian.org/images/cloud/trixie/latest BASE := debian-13-generic-amd64.qcow2 OVERLAY := $(NAME).qcow2 SRC := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) USERDATA := $(BUILD)/$(NAME)-user-data.yaml NFT := /etc/nftables.d/agent-vm.nft CONSOLE_LOG := /var/log/libvirt/qemu/$(NAME)-console.log VIRSH := virsh -c qemu:///system FS_ARGS := $(if $(SHARE),--memorybacking source.type=memfd,access.mode=shared \ --filesystem source.dir=$(SHARE),target.dir=project$,driver.type=virtiofs) SHELL := bash .SHELLFLAGS := -eu -o pipefail -c .ONESHELL: .SILENT: .DEFAULT_GOAL := help .PHONY: help deps check net vm harden snapshot revert start stop console log wait-boot status ip ssh destroy help: @sed -n 's/^# //p' $(MAKEFILE_LIST) deps: sudo apt update sudo apt install -y qemu-system-x86 qemu-utils libvirt-daemon-system \ libvirt-clients virtinst ovmf cloud-image-utils virtiofsd nftables sudo usermod -aG libvirt,kvm "$$USER" echo "Log out and back in for the group change to take effect." check: n=$$(grep -cE 'vmx|svm' /proc/cpuinfo || true) [ "$$n" -gt 0 ] || { echo "No VT-x/AMD-V: enable it in BIOS/UEFI."; exit 1; } virt-host-validate qemu || true $(BUILD) $(SHARE): mkdir -p $@ # Download the base image $(BUILD)/$(BASE): | $(BUILD) cd $(BUILD) curl -fL -o $(BASE).part $(URL)/$(BASE) curl -fL -O $(URL)/SHA512SUMS sed -n 's/ $(BASE)$$/ $(BASE).part/p' SHA512SUMS | sha512sum -c - mv $(BASE).part $(BASE) # CoW overlay $(BUILD)/$(OVERLAY): $(BUILD)/$(BASE) cd $(BUILD) qemu-img create -f qcow2 -F qcow2 -b $(BASE) $(OVERLAY) $(DISK) $(POOL)/%.qcow2: $(BUILD)/%.qcow2 sudo install -m 0644 $< $@ # Copy user-data.yaml with your SSH public key filled in. $(USERDATA): $(SRC)user-data.yaml $(SSH_KEY) | $(BUILD) [ -n "$(SSH_KEY)" ] || { echo "No SSH public key found; set SSH_KEY=..."; exit 1; } key=$$(head -n1 "$(SSH_KEY)") sed "s|@SSH_KEY@|$$key|" $< > $@ net: active=$$($(VIRSH) net-info default | awk '$$1 == "Active:" {print $$2}') [ "$$active" = yes ] || $(VIRSH) net-start default $(VIRSH) net-autostart default >/dev/null # Follow the console log until the login prompt appears define WAIT_BOOT sudo -v echo "Following $(NAME)'s console (Ctrl+C stops following; the VM keeps running)." sudo tail -n +1 -F $(CONSOLE_LOG) 2>/dev/null & t=$$! trap 'kill $$t 2>/dev/null' EXIT for i in $$(seq 300); do if sudo grep -q 'login:' $(CONSOLE_LOG) 2>/dev/null; then sleep 1; echo; echo "$(NAME) is up; run 'make ssh'."; exit 0 fi sleep 1 done echo; echo "No login prompt after 5 minutes; check 'make log' or 'make console'." exit 1 endef # Print the guest's IPv4 address, or fail with a hint if it isn't running. define GUEST_IP state=$$($(VIRSH) domstate $(NAME) 2>/dev/null || echo "not defined") [ "$$state" = running ] || { echo "$(NAME) is $$state; run 'make start' (or 'make vm')." >&2; exit 1; } ip=$$($(VIRSH) domifaddr $(NAME) | awk '/ipv4/ {sub(/\/.*/, "", $$4); print $$4; exit}') endef vm: net $(POOL)/$(BASE) $(POOL)/$(OVERLAY) $(USERDATA) $(if $(SHARE),| $(SHARE)) if $(VIRSH) dominfo $(NAME) >/dev/null 2>&1; then echo "$(NAME) already exists."; exit 0 fi virt-install --connect qemu:///system --name $(NAME) \ --memory $(MEM) --vcpus $(CPUS) --cpu $(CPU) \ --osinfo debian13 \ --disk path=$(POOL)/$(OVERLAY),bus=virtio \ --network network=default,model=virtio \ $(FS_ARGS) \ --cloud-init user-data=$(USERDATA) \ --serial pty,log.file=$(CONSOLE_LOG),log.append=off \ --import --graphics none --video vga --noautoconsole $(WAIT_BOOT) wait-boot: $(WAIT_BOOT) log: sudo tail -n 50 -F $(CONSOLE_LOG) # Install and load agent-vm.nft. To persist across reboots, add # `include "/etc/nftables.d/*.nft"` to /etc/nftables.conf and enable nftables.service. harden: sudo install -D -m 0644 $(SRC)agent-vm.nft $(NFT) sudo nft -f $(NFT) echo "Loaded $(NFT)." # Live snapshots fail while a virtiofs share is attached, so shut down first. snapshot: $(VIRSH) shutdown $(NAME) >/dev/null 2>&1 || true while [ "$$($(VIRSH) domstate $(NAME))" = running ]; do sleep 1; done $(VIRSH) snapshot-create-as $(NAME) clean --description "clean state" $(VIRSH) start $(NAME) revert: $(VIRSH) destroy $(NAME) >/dev/null 2>&1 || true $(VIRSH) snapshot-revert $(NAME) clean $(VIRSH) start $(NAME) start: [ "$$($(VIRSH) domstate $(NAME))" = running ] || $(VIRSH) start $(NAME) stop: $(VIRSH) shutdown $(NAME) console: echo "Attached to $(NAME)'s console; press Ctrl+] to detach." exec $(VIRSH) console $(NAME) status: @$(VIRSH) domstate $(NAME) --reason ip: $(GUEST_IP) [ -n "$$ip" ] || { echo "No IP yet; is $(NAME) still booting?" >&2; exit 1; } echo "$$ip" ssh: $(GUEST_IP) [ -n "$$ip" ] || { echo "No IP yet; is $(NAME) still booting?" >&2; exit 1; } exec ssh dev@$$ip destroy: read -r -p "Destroy $(NAME) and its disk? [y/N] " ans [ "$$ans" = y ] $(VIRSH) destroy $(NAME) >/dev/null 2>&1 || true $(VIRSH) undefine $(NAME) --snapshots-metadata sudo rm -f $(POOL)/$(OVERLAY) rm -f $(BUILD)/$(OVERLAY) $(USERDATA)