a73x

scripts/devhost.sh

Ref:   Size: 16.1 KiB   History

#!/usr/bin/env bash
#
# The branch gate's fleet: a nested KVM host, defined here rather than
# hand-built, so a wedged dev host is a three-minute rebuild instead of a
# debugging session.
#
#   scripts/devhost.sh create  [name]   build it from nothing
#   scripts/devhost.sh recycle [name]   destroy, then create — the whole point
#   scripts/devhost.sh destroy [name]
#   scripts/devhost.sh address [name]   print its AGENT_HOSTS line
#
# The host runs on the dev machine rather than the workstation:
#
#   DEVHOST_REMOTE=ubuntu@192.168.0.193:2222 scripts/devhost.sh create
#
# With DEVHOST_REMOTE set the script copies itself and the seed there and
# re-execs under sudo, so every hypervisor call — libvirt, qemu-img, apt —
# happens on the machine that owns the guest, and the workstation stays free of
# both root and libvirt. Output streams back; the exit status is the remote's.
# Unset, the same four verbs build the host on this machine against the libvirt
# 'default' network.
#
# The gate's fleet has no coupling to any hosted thing and dies the moment
# `make deploy` is not running. Nested guests get hardware virtualization
# through --cpu host-passthrough, so the machine's CPU must expose nested KVM
# (kvm_amd nested=1 / kvm_intel nested=1). What nested CANNOT prove is bridged
# networking and real-hardware quirks — that coverage moved to stg, which gates
# pre-release tags on real metal.
#
# On the dev machine the guest is a macvtap child of the LAN uplink
# (DEVHOST_UPLINK, default enp4s0): the LAN's router leases it an address and
# the workstation dials it directly. macvtap isolates a guest from its own
# host by design, so the dev machine cannot reach the guest it runs — which
# costs nothing here, because every gate connection comes from the workstation
# anyway. It does mean the address is discovered after boot rather than
# assigned: the guest agent reports it, and this script waits for it.
#
# The seed installs nothing eitri needs: cloud-hypervisor, the guest firmware
# and the agent all arrive through `make deploy` and the agent's own bootstrap,
# so a cold first deploy exercises that path rather than skipping it.
#
# Disks live in the libvirt system pool, which is root-owned — the qemu-img and
# rm calls below use sudo for that and nothing else.
set -euo pipefail

# The pool dir and the 'default' network live on the SYSTEM daemon; a bare
# virsh from a user shell talks to qemu:///session and sees neither.
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"

# Pinned guest image. Bump deliberately: take the sha from the SHA256SUMS file
# beside the image in the same dated directory.
IMAGE_URL="${DEVHOST_IMAGE_URL:-https://cloud-images.ubuntu.com/noble/20260801/noble-server-cloudimg-amd64.img}"
IMAGE_SHA256="${DEVHOST_IMAGE_SHA256:-0533b0655c32e68b31d792ecd6ccfca95abdbc536c4446874fe0513bd4140ffe}"
OS_VARIANT="${DEVHOST_OS_VARIANT:-ubuntu24.04}"

# Sized so the agent's admission logic runs against a cap BELOW the machine's
# real size, the same shape a bare-metal host runs — which keeps the
# admission path honestly covered. The caps themselves go in deploy.env's
# AGENT_EXTRA_FLAGS; see the line this script prints.
MEM_MB="${DEVHOST_MEM_MB:-16384}"
VCPUS="${DEVHOST_VCPUS:-8}"
DISK_GB="${DEVHOST_DISK_GB:-120}"

NETWORK="${DEVHOST_NETWORK:-default}"
POOL_DIR="${DEVHOST_POOL_DIR:-/var/lib/libvirt/images}"
CACHE_DIR="${DEVHOST_CACHE_DIR:-$HOME/.cache/eitri/devhost}"
SSH_KEY="${DEVHOST_SSH_KEY:-$HOME/.ssh/id_ed25519.pub}"
SSH_PORT="${DEVHOST_SSH_PORT:-22}"
SEED_TEMPLATE="${DEVHOST_SEED:-$REPO_ROOT/scripts/devhost.cloud-init.yaml}"

# Where the host is built. REMOTE empty means this machine. MANAGED is set only
# on the copy that lands on the dev machine, and says two things: this machine
# is ours to prepare, and something upstream is waiting to print the result.
REMOTE="${DEVHOST_REMOTE:-}"
MANAGED="${DEVHOST_MANAGED:-}"

# A non-empty uplink switches the guest onto macvtap over that interface; empty
# keeps it on the libvirt network. Driving a remote implies the former, because
# a dev machine is a LAN citizen and its guest should be one too.
UPLINK="${DEVHOST_UPLINK:-}"
if [[ -n "$REMOTE" && -z "$UPLINK" ]]; then
	UPLINK=enp4s0
fi

# The key the guest must admit is the workstation's, wherever the guest is
# built: the driving side reads the file and hands the material over, so the
# dev machine never needs a key of its own.
SSH_PUBKEY="${DEVHOST_SSH_PUBKEY:-}"

bold() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
die() { printf 'devhost: %s\n' "$*" >&2; exit 1; }

VERB="${1:-}"
NAME="${2:-eitri-dev1}"

case "$VERB" in
create | recycle | destroy | address) ;;
*)
	sed -n '2,21p' "$0" | sed 's/^#\{1,2\} \{0,1\}//' >&2
	exit 1
	;;
esac

RTARGET=""
RPORT=22
if [[ -n "$REMOTE" ]]; then
	RTARGET="${REMOTE%:*}"
	if [[ "$REMOTE" == *:* ]]; then
		RPORT="${REMOTE##*:}"
	fi
	[[ "$RTARGET" == *@* ]] || die "DEVHOST_REMOTE must read user@host[:port] (got '$REMOTE')"
fi

# Where to look when a host will not answer. Named early because half the
# failure paths below want to say it.
CONSOLE_HINT="virsh console $NAME"
if [[ -n "$REMOTE" ]]; then
	CONSOLE_HINT="ssh -p $RPORT $RTARGET sudo virsh console $NAME"
fi

# The MAC is derived from the name, so a recycled host comes back the same
# machine to the router and to libvirt. Locally-administered QEMU prefix plus
# three bytes of the name's digest: stable per name, and distinct enough not to
# collide with anything else on the segment.
mac_tail="$(printf '%s' "$NAME" | sha256sum | cut -c1-6 | sed 's/../&:/g; s/:$//')"
MAC="${DEVHOST_MAC:-52:54:00:$mac_tail}"
DISK="$POOL_DIR/$NAME.qcow2"

# On the libvirt network the address comes from the name too, via a reservation
# this script owns, so AGENT_HOSTS never has to be edited. The index is the
# trailing digits of the name ("eitri-dev2" -> 2 -> .51). On the LAN uplink the
# router owns DHCP, so the address is whatever it leased and is discovered.
IP="${DEVHOST_IP:-}"
if [[ -z "$UPLINK" && -z "$IP" ]]; then
	index="$(printf '%s' "$NAME" | grep -oE '[0-9]+$' || echo 1)"
	IP="192.168.122.$((49 + index))"
fi

need() { command -v "$1" >/dev/null 2>&1 || die "$1 is not installed"; }

# The dev machine gets the hypervisor toolchain the first time it is asked for
# it, and nothing at all on every run after that. Only ever on a machine this
# script was copied to: the workstation's package set is the workstation's own
# business.
ensure_prereqs() {
	[[ -n "$MANAGED" ]] || return 0

	missing=()
	for cmd in virsh virt-install qemu-img curl; do
		command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd")
	done
	if ((${#missing[@]} > 0)); then
		# Named rather than pulled in by recommendation, and every one of them
		# is used: the emulator, qemu-img, the daemon, virsh, virt-install. A
		# guest on the LAN uplink needs no NAT network, so nothing here has to
		# drag in dnsmasq or the bridge tooling to serve one.
		bold "Installing the hypervisor toolchain (missing: ${missing[*]})"
		sudo -n env DEBIAN_FRONTEND=noninteractive apt-get update -qq
		sudo -n env DEBIAN_FRONTEND=noninteractive apt-get install -y \
			--no-install-recommends qemu-system-x86 qemu-utils \
			libvirt-daemon-system libvirt-clients virtinst curl
	fi
	if systemctl is-active --quiet libvirtd; then
		echo "libvirtd is running"
	else
		bold "Starting libvirtd"
		sudo -n systemctl enable --now libvirtd
	fi
}

# The script is its own payload. It and the seed go to the dev machine's mktemp
# dir and are re-exec'd there as root, which is what lets a single ssh session
# do libvirt, the pool and apt without the driving side holding any privilege.
# The remote's stdout is teed so the address it discovers can be read back;
# its stderr passes straight through untouched.
run_remote() {
	if [[ "$VERB" == create || "$VERB" == recycle ]]; then
		[[ -f "$SSH_KEY" ]] || die "no public key at $SSH_KEY (set DEVHOST_SSH_KEY)"
		SSH_PUBKEY="$(cat "$SSH_KEY")"
	fi

	# ServerAliveInterval, because a create is ten quiet minutes of image
	# fetching and first boot on the far end, and a session that times out
	# during them abandons a guest that is already running.
	ssh_opts=(-o BatchMode=yes -o ConnectTimeout=10 -o ServerAliveInterval=30)
	tmp="$(ssh -p "$RPORT" "${ssh_opts[@]}" "$RTARGET" 'mktemp -d /tmp/devhost.XXXXXX')" ||
		die "$REMOTE did not answer ssh"
	scp -P "$RPORT" -q "${ssh_opts[@]}" "$SCRIPT" "$SEED_TEMPLATE" "$RTARGET:$tmp/" ||
		die "could not copy the script to $REMOTE"

	envs=(
		"DEVHOST_REMOTE="
		"DEVHOST_MANAGED=1"
		"DEVHOST_SEED=$tmp/$(basename "$SEED_TEMPLATE")"
		"DEVHOST_UPLINK=$UPLINK"
		"DEVHOST_MAC=$MAC"
		"DEVHOST_MEM_MB=$MEM_MB"
		"DEVHOST_VCPUS=$VCPUS"
		"DEVHOST_DISK_GB=$DISK_GB"
		"DEVHOST_IMAGE_URL=$IMAGE_URL"
		"DEVHOST_IMAGE_SHA256=$IMAGE_SHA256"
		"DEVHOST_OS_VARIANT=$OS_VARIANT"
		"DEVHOST_POOL_DIR=$POOL_DIR"
	)
	if [[ -n "$SSH_PUBKEY" ]]; then
		envs+=("DEVHOST_SSH_PUBKEY=$SSH_PUBKEY")
	fi
	if [[ -n "$IP" ]]; then
		envs+=("DEVHOST_IP=$IP")
	fi
	# The image cache belongs to whichever machine fetches, so it is forwarded
	# only when someone has said where they want it.
	if [[ -n "${DEVHOST_CACHE_DIR:-}" ]]; then
		envs+=("DEVHOST_CACHE_DIR=$DEVHOST_CACHE_DIR")
	fi

	cmd="sudo -n env"
	for assignment in "${envs[@]}"; do
		cmd+=" $(printf '%q' "$assignment")"
	done
	cmd+=" bash $tmp/$(basename "$SCRIPT") $(printf '%q %q' "$VERB" "$NAME")"
	# The temp dir goes whatever the run did, and the run's status survives it.
	cmd="$cmd; rc=\$?; rm -rf $tmp; exit \$rc"

	bold "Driving $VERB of $NAME on $RTARGET"
	out="$(mktemp)"
	trap 'rm -f "$out"' EXIT
	if ssh -p "$RPORT" "${ssh_opts[@]}" "$RTARGET" "$cmd" | tee "$out"; then
		rc=0
	else
		rc=$?
	fi
	[[ $rc -eq 0 ]] || exit "$rc"

	case "$VERB" in
	create | recycle | address)
		IP="$(sed -n 's/^devhost-address: //p' "$out" | tail -n1)"
		[[ -n "$IP" ]] || die "$RTARGET never reported an address for $NAME"
		;;
	esac
	rm -f "$out"
	trap - EXIT

	case "$VERB" in
	create | recycle)
		wait_for_ssh
		print_address
		;;
	address) print_address ;;
	esac
}

destroy_host() {
	bold "Destroying $NAME"
	if command -v virsh >/dev/null 2>&1; then
		virsh destroy "$NAME" >/dev/null 2>&1 || true
		# --remove-all-storage takes the root disk and the cloud-init seed ISO
		# with the domain; the explicit rm is the belt for a domain that never
		# defined.
		virsh undefine "$NAME" --nvram --remove-all-storage >/dev/null 2>&1 || true
	fi
	sudo rm -f "$DISK"
	if [[ -n "$UPLINK" ]]; then
		echo "$NAME is gone"
	else
		# The DHCP reservation deliberately survives: it is what makes the
		# address stable across a recycle, and create re-adds it either way.
		echo "$NAME is gone (its reservation at $IP is kept)"
	fi
}

create_host() {
	ensure_prereqs
	need virsh
	need virt-install
	need qemu-img
	[[ -e /dev/kvm ]] || die "/dev/kvm is missing — nested guests need hardware virtualization"
	if [[ -z "$SSH_PUBKEY" ]]; then
		[[ -f "$SSH_KEY" ]] || die "no public key at $SSH_KEY (set DEVHOST_SSH_KEY)"
		SSH_PUBKEY="$(cat "$SSH_KEY")"
	fi
	if [[ -n "$UPLINK" ]]; then
		ip link show "$UPLINK" >/dev/null 2>&1 ||
			die "no interface '$UPLINK' on this machine (set DEVHOST_UPLINK)"
	else
		virsh net-info "$NETWORK" >/dev/null 2>&1 || die "libvirt network '$NETWORK' does not exist"
		# grep consumes all input on purpose: under pipefail, grep -q's early exit
		# EPIPEs the writer and fails the pipeline even when the match succeeded.
		virsh net-info "$NETWORK" | grep 'Active:.*yes' >/dev/null || die "libvirt network '$NETWORK' is not running (virsh net-start $NETWORK)"
	fi
	if virsh dominfo "$NAME" >/dev/null 2>&1; then
		die "$NAME already exists — 'recycle' rebuilds it, 'destroy' removes it"
	fi

	bold "Fetching the guest image"
	mkdir -p "$CACHE_DIR"
	cached="$CACHE_DIR/$(basename "$IMAGE_URL")"
	if [[ ! -f "$cached" ]]; then
		curl -fsSL -o "$cached.part" "$IMAGE_URL"
		mv "$cached.part" "$cached"
	fi
	echo "$IMAGE_SHA256  $cached" | sha256sum -c - >/dev/null || {
		rm -f "$cached"
		die "image sha mismatch — cache purged, re-run"
	}
	echo "image ok: $cached"

	if [[ -n "$UPLINK" ]]; then
		netarg="type=direct,source=$UPLINK,source_mode=bridge,model=virtio,mac=$MAC"
		bold "Putting $NAME on the LAN through $UPLINK ($MAC)"
	else
		netarg="network=$NETWORK,mac=$MAC,model=virtio"
		bold "Pinning $NAME to $IP on the '$NETWORK' network"
		virsh net-update "$NETWORK" delete ip-dhcp-host "<host mac='$MAC'/>" \
			--live --config >/dev/null 2>&1 || true
		virsh net-update "$NETWORK" add ip-dhcp-host \
			"<host mac='$MAC' name='$NAME' ip='$IP'/>" --live --config >/dev/null
	fi

	bold "Creating $NAME ($VCPUS vCPU, $((MEM_MB / 1024)) GB, $DISK_GB GB)"
	sudo qemu-img convert -O qcow2 "$cached" "$DISK"
	sudo qemu-img resize "$DISK" "${DISK_GB}G" >/dev/null

	seed="$(mktemp)"
	trap 'rm -f "$seed"' EXIT
	template="$(cat "$SEED_TEMPLATE")"
	printf '%s\n' "${template//'${SSH_AUTHORIZED_KEY}'/$SSH_PUBKEY}" >"$seed"

	virt-install \
		--name "$NAME" \
		--memory "$MEM_MB" \
		--vcpus "$VCPUS" \
		--cpu host-passthrough \
		--disk "path=$DISK,format=qcow2,bus=virtio" \
		--network "$netarg" \
		--os-variant "$OS_VARIANT" \
		--cloud-init "user-data=$seed" \
		--graphics none \
		--noautoconsole \
		--import
	rm -f "$seed"
	trap - EXIT

	if [[ -n "$UPLINK" ]]; then
		# Discovery waits on the guest agent, which the seed installs on first
		# boot. DEVHOST_IP is the way past it for a guest the router already has
		# a reservation for: the address is known before the machine exists.
		if [[ -z "$IP" ]]; then
			IP="$(await_address)"
		fi
		# macvtap keeps this machine off its own guest, so the ssh proof belongs
		# to whoever drives this script. Report the address and let them make it.
		printf 'devhost-address: %s\n' "$IP"
		if [[ -z "$MANAGED" ]]; then
			print_address
		fi
	else
		wait_for_ssh
		print_address
	fi
}

# The guest's address, from the first source that knows it. Under macvtap the
# host has no path to its own guest, so the ARP table never learns it and
# libvirt owns no lease to read — the guest agent's virtio channel is the one
# source that answers. The other two are kept for a host whose guest shares its
# L2, where they answer sooner.
domain_ipv4() {
	for src in agent arp; do
		found="$(virsh domifaddr "$NAME" --source "$src" 2>/dev/null |
			awk '{
				for (i = 1; i < NF; i++)
					if ($i == "ipv4") {
						split($(i + 1), a, "/")
						if (a[1] !~ /^(127\.|169\.254\.)/ && !ip) ip = a[1]
					}
			} END { if (ip) print ip }' || true)"
		if [[ -n "$found" ]]; then
			printf '%s' "$found"
			return 0
		fi
	done
	ip -4 neigh show 2>/dev/null |
		awk -v mac="$MAC" 'BEGIN { mac = tolower(mac) }
			tolower($0) ~ mac && !ip { ip = $1 } END { if (ip) print ip }' || true
}

await_address() {
	bold "Waiting for $NAME to report the address the LAN leased it" >&2
	for _ in $(seq 1 90); do
		found="$(domain_ipv4)"
		if [[ -n "$found" ]]; then
			printf '%s' "$found"
			return 0
		fi
		sleep 5
	done
	die "$NAME never reported an address ($CONSOLE_HINT)"
}

wait_for_ssh() {
	# A rebuilt host presents a new host key at an address ssh already knows,
	# which otherwise fails the first deploy with a warning that looks like an
	# attack rather than a recycle.
	ssh-keygen -R "$IP" >/dev/null 2>&1 || true

	bold "Waiting for ssh on $IP"
	for _ in $(seq 1 60); do
		if ssh -p "$SSH_PORT" -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
			-o ConnectTimeout=5 "ubuntu@$IP" true 2>/dev/null; then
			return 0
		fi
		sleep 5
	done
	die "$NAME did not answer ssh at $IP within 5 minutes ($CONSOLE_HINT)"
}

print_address() {
	cat <<-EOF

		$NAME is up. Point the branch gate at it in ~/eitri-deploy/deploy.env:

		  AGENT_HOSTS="ubuntu@$IP"
		  AGENT_EXTRA_FLAGS="--max-vcpus $((VCPUS - 2)) --max-mem-mb $((MEM_MB - 4096)) --max-disk-gb $((DISK_GB - 30))"

		then run: make deploy
	EOF
}

if [[ -n "$REMOTE" && -z "$MANAGED" ]]; then
	run_remote
	exit 0
fi

case "$VERB" in
create) create_host ;;
destroy) destroy_host ;;
recycle)
	destroy_host
	create_host
	;;
address)
	if [[ -n "$UPLINK" ]]; then
		if [[ -z "$IP" ]]; then
			IP="$(domain_ipv4)"
		fi
		[[ -n "$IP" ]] || die "$NAME has no address to report ($CONSOLE_HINT)"
		printf 'devhost-address: %s\n' "$IP"
		if [[ -z "$MANAGED" ]]; then
			print_address
		fi
	else
		print_address
	fi
	;;
esac