#!/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"
}