#!/usr/bin/env bash set -euo pipefail COMMON_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" VM_TOOLS_DIR="$(cd "$COMMON_DIR/.." && pwd)" PROJECT_ROOT="$(cd "$VM_TOOLS_DIR/../.." && pwd)" SC_OWNER_USER="${SC_OWNER_USER:-${SUDO_USER:-$USER}}" SC_OWNER_HOME="${SC_OWNER_HOME:-$(getent passwd "$SC_OWNER_USER" | cut -d: -f6)}" SC_OWNER_HOME="${SC_OWNER_HOME:-$HOME}" export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}" if [ "${LIBVIRT_DEFAULT_URI}" = "qemu:///system" ]; then SC_HOME="${SC_HOME:-/var/lib/libvirt/sysadmin-chronicles}" SC_IMAGE_ROOT="${SC_IMAGE_ROOT:-/var/lib/libvirt/images/sysadmin-chronicles}" else SC_HOME="${SC_HOME:-$SC_OWNER_HOME/.local/share/sysadmin-chronicles}" SC_IMAGE_ROOT="${SC_IMAGE_ROOT:-$SC_HOME/images}" fi SC_BASE_DIR="$SC_IMAGE_ROOT/base" SC_SEED_DIR="$SC_IMAGE_ROOT/seed" SC_POOL_NAME="${SC_POOL_NAME:-sc-images}" SC_NETWORK_NAME="${SC_NETWORK_NAME:-sc-internal}" SC_SSH_KEY="${SC_SSH_KEY:-$SC_OWNER_HOME/.ssh/sc_host_key}" DRY_RUN="${DRY_RUN:-false}" _virsh() { if [ "${SC_VIRSH_SUDO:-false}" = true ]; then sudo virsh "$@" else virsh "$@" fi } _virt_install() { if [ "${SC_VIRSH_SUDO:-false}" = true ]; then sudo virt-install "$@" else virt-install "$@" fi } step() { echo ""; echo "── $* ───────────────────────────────────────"; } ok() { echo " ✓ $*"; } info() { echo " → $*"; } fail() { echo " ✗ $*"; exit 1; } ssh_login_user() { local domain="${1:-}" case "$domain" in sc-workstation) printf '%s\n' "opsbridge" ;; *) printf '%s\n' "player" ;; esac } run() { if [ "$DRY_RUN" = "true" ]; then echo " [DRY-RUN] $*" else "$@" fi } require_cmd() { local cmd="$1" command -v "$cmd" >/dev/null 2>&1 || fail "Required command not found: $cmd" } ensure_vm_tooling() { require_cmd virsh require_cmd qemu-img require_cmd curl require_cmd virt-install if ! command -v cloud-localds >/dev/null 2>&1 \ && ! command -v genisoimage >/dev/null 2>&1 \ && ! command -v mkisofs >/dev/null 2>&1 \ && ! command -v xorriso >/dev/null 2>&1; then fail "Need cloud-localds, genisoimage, mkisofs, or xorriso to build NoCloud seed images" fi [ -f "$SC_SSH_KEY" ] || fail "Missing SSH private key: $SC_SSH_KEY" [ -f "${SC_SSH_KEY}.pub" ] || fail "Missing SSH public key: ${SC_SSH_KEY}.pub" run mkdir -p "$SC_BASE_DIR" "$SC_SEED_DIR" _virsh pool-info "$SC_POOL_NAME" >/dev/null 2>&1 || fail "Missing libvirt pool: $SC_POOL_NAME" _virsh net-info "$SC_NETWORK_NAME" >/dev/null 2>&1 || fail "Missing libvirt network: $SC_NETWORK_NAME" } pool_path() { local path path="$(_virsh pool-dumpxml "$SC_POOL_NAME" | sed -n 's:.*\(.*\).*:\1:p' | head -n1)" [ -n "$path" ] || fail "Could not determine pool path for $SC_POOL_NAME" printf '%s\n' "$path" } domain_exists() { local domain="$1" _virsh dominfo "$domain" >/dev/null 2>&1 } download_if_missing() { local url="$1" local dest="$2" if [ -f "$dest" ]; then ok "Using cached base image: $(basename "$dest")" return fi info "Downloading $(basename "$dest")" run curl -L --fail --output "$dest" "$url" } create_backing_disk() { local base_image="$1" local target_disk="$2" local disk_size="${3:-}" run mkdir -p "$(dirname "$target_disk")" run rm -f "$target_disk" if [ -n "$disk_size" ]; then run qemu-img create -f qcow2 -F qcow2 -b "$base_image" "$target_disk" "$disk_size" else run qemu-img create -f qcow2 -F qcow2 -b "$base_image" "$target_disk" fi } create_seed_iso() { local user_data="$1" local meta_data="$2" local output_iso="$3" local seed_dir seed_dir="$(mktemp -d)" cp "$user_data" "$seed_dir/user-data" cp "$meta_data" "$seed_dir/meta-data" run rm -f "$output_iso" if command -v cloud-localds >/dev/null 2>&1; then run cloud-localds "$output_iso" "$seed_dir/user-data" "$seed_dir/meta-data" elif command -v genisoimage >/dev/null 2>&1; then run genisoimage -quiet -output "$output_iso" -volid cidata -joliet -rock "$seed_dir/user-data" "$seed_dir/meta-data" elif command -v mkisofs >/dev/null 2>&1; then run mkisofs -quiet -output "$output_iso" -volid cidata -joliet -rock "$seed_dir/user-data" "$seed_dir/meta-data" else run xorriso -as mkisofs -quiet -output "$output_iso" -volid cidata -joliet -rock "$seed_dir/user-data" "$seed_dir/meta-data" fi rm -rf "$seed_dir" } destroy_domain() { local domain="$1" if ! domain_exists "$domain"; then return fi info "Removing existing domain definition: $domain" run _virsh destroy "$domain" >/dev/null 2>&1 || true run _virsh undefine "$domain" --nvram --snapshots-metadata >/dev/null 2>&1 \ || run _virsh undefine "$domain" --snapshots-metadata >/dev/null 2>&1 \ || run _virsh undefine "$domain" --nvram >/dev/null 2>&1 \ || run _virsh undefine "$domain" >/dev/null 2>&1 \ || true } build_import_domain() { local domain="$1" local disk_path="$2" local seed_iso="$3" local ram_mb="$4" local vcpus="$5" local graphics_mode="$6" local args=( --name "$domain" --memory "$ram_mb" --vcpus "$vcpus" --import --disk "path=$disk_path,format=qcow2,bus=virtio" --disk "path=$seed_iso,device=cdrom" --network "network=$SC_NETWORK_NAME,model=virtio" --channel "unix,target_type=virtio,name=org.qemu.guest_agent.0" --rng /dev/urandom --osinfo detect=on,require=off --noautoconsole ) case "$graphics_mode" in none) args+=(--graphics none --console pty,target_type=serial) ;; vnc) args+=(--graphics vnc,listen=127.0.0.1) ;; spice) args+=( --graphics spice,listen=127.0.0.1 --video virtio --channel "spicevmc,target_type=virtio,name=com.redhat.spice.0" ) ;; spice-qxl) args+=( --graphics spice,listen=127.0.0.1 --video qxl --channel "spicevmc,target_type=virtio,name=com.redhat.spice.0" ) ;; *) fail "Unknown graphics mode: $graphics_mode" ;; esac run _virt_install "${args[@]}" run _virsh autostart "$domain" } seed_cdrom_target() { local domain="$1" local seed_iso="$2" _virsh dumpxml "$domain" 2>/dev/null \ | awk -v seed="$seed_iso" ' // { if (matched && target != "") { print target exit } in_disk=0 matched=0 target="" } ' } detach_seed_iso() { local domain="$1" local seed_iso="$2" local target target="$(seed_cdrom_target "$domain" "$seed_iso" || true)" if [ -z "$target" ]; then info "No cloud-init seed ISO attached to $domain" return 0 fi info "Detaching cloud-init seed ISO from $domain ($target)" if _virsh domstate "$domain" 2>/dev/null | grep -qi running; then run _virsh detach-disk "$domain" "$target" --config >/dev/null 2>&1 || true run _virsh detach-disk "$domain" "$target" --live >/dev/null 2>&1 || true if seed_cdrom_target "$domain" "$seed_iso" >/dev/null 2>&1; then info "Restarting $domain to apply cloud-init seed ISO detach" run _virsh shutdown "$domain" >/dev/null 2>&1 || true local waited=0 while [ "$waited" -lt 60 ] && _virsh domstate "$domain" 2>/dev/null | grep -qi running; do sleep 2 waited=$((waited + 2)) done if _virsh domstate "$domain" 2>/dev/null | grep -qi running; then run _virsh destroy "$domain" >/dev/null 2>&1 || true fi run _virsh start "$domain" >/dev/null 2>&1 wait_for_agent_ip "$domain" 180 >/dev/null || true fi else run _virsh detach-disk "$domain" "$target" --config >/dev/null 2>&1 || true fi } domain_mac() { local domain="$1" _virsh dumpxml "$domain" 2>/dev/null | sed -n "s/.*/dev/null \ | awk -v mac="$mac" '$0 ~ mac {print $5}' \ | cut -d/ -f1 \ | head -n1 } valid_guest_ip() { local addr="${1:-}" [[ -n "$addr" ]] || return 1 [[ "$addr" != 127.* ]] || return 1 [[ "$addr" != "0.0.0.0" ]] || return 1 return 0 } wait_for_agent_ip() { local domain="$1" local timeout_sec="${2:-300}" local waited=0 while [ "$waited" -lt "$timeout_sec" ]; do local addr addr="$(_virsh domifaddr "$domain" --source agent 2>/dev/null | awk '/ipv4/ {print $4}' | cut -d/ -f1 | head -n1 || true)" if ! valid_guest_ip "$addr"; then addr="" fi if [ -z "$addr" ]; then addr="$(dhcp_lease_ip "$domain" || true)" fi if valid_guest_ip "$addr"; then printf '%s\n' "$addr" return 0 fi sleep 5 waited=$((waited + 5)) done return 1 } wait_for_ssh() { local domain="$1" local timeout_sec="${2:-180}" local waited=0 local login_user login_user="$(ssh_login_user "$domain")" while [ "$waited" -lt "$timeout_sec" ]; do local addr addr="$(wait_for_agent_ip "$domain" 10 || true)" if [ -n "$addr" ]; then if ssh_base_args "$addr" "$login_user" true >/dev/null 2>&1; then return 0 fi fi sleep 5 waited=$((waited + 5)) done return 1 } wait_for_guest_command() { local domain="$1" local timeout_sec="$2" local command="$3" local progress_command="${4:-}" local progress_every_sec="${5:-30}" local waited=0 local last_progress=-9999 local login_user login_user="$(ssh_login_user "$domain")" while [ "$waited" -lt "$timeout_sec" ]; do local addr addr="$(wait_for_agent_ip "$domain" 10 || true)" if [ -n "$addr" ]; then if ssh_base_args "$addr" "$login_user" "$command" >/dev/null 2>&1; then return 0 fi if [ -n "$progress_command" ] && [ $((waited - last_progress)) -ge "$progress_every_sec" ]; then last_progress="$waited" info "Guest progress for $domain:" ssh_base_args "$addr" "$login_user" "$progress_command" 2>/dev/null || true fi fi sleep 5 waited=$((waited + 5)) done return 1 } ssh_base_args() { local host="$1" local login_user="${2:-player}" shift shift || true ssh \ -o StrictHostKeyChecking=no \ -o UserKnownHostsFile=/dev/null \ -o BatchMode=yes \ -o ConnectTimeout=5 \ -o LogLevel=ERROR \ -i "$SC_SSH_KEY" \ "${login_user}@${host}" \ "$@" } guest_run() { local domain="$1" shift wait_for_ssh "$domain" 180 >/dev/null || fail "SSH did not become ready for $domain" local addr addr="$(wait_for_agent_ip "$domain" 120)" || fail "Could not resolve IP for $domain" local login_user login_user="$(ssh_login_user "$domain")" if [ "$DRY_RUN" = "true" ]; then echo " [DRY-RUN][SSH $domain@$addr] $*" return 0 fi ssh_base_args "$addr" "$login_user" "$@" } guest_run_sudo_script() { local domain="$1" local script_file="$2" wait_for_ssh "$domain" 180 >/dev/null || fail "SSH did not become ready for $domain" local addr addr="$(wait_for_agent_ip "$domain" 120)" || fail "Could not resolve IP for $domain" local login_user login_user="$(ssh_login_user "$domain")" if [ "$DRY_RUN" = "true" ]; then echo " [DRY-RUN][SSH $domain@$addr] sudo bash -s < $script_file" return 0 fi ssh \ -o StrictHostKeyChecking=no \ -o UserKnownHostsFile=/dev/null \ -o BatchMode=yes \ -o ConnectTimeout=5 \ -o LogLevel=ERROR \ -i "$SC_SSH_KEY" \ "${login_user}@${addr}" "sudo bash -s" < "$script_file" }