Files
44r0n7 0265afa054 chore: bootstrap lean sysadmin-chronicles repo
Import the runnable game code, content, docs, scripts, and repo guidance while leaving local agent state, dependency installs, build output, and backup copies out of the published tree.
2026-05-02 11:49:07 -04:00

428 lines
13 KiB
Bash

#!/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:.*<path>\(.*\)</path>.*:\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" '
/<disk / { in_disk=1; target="" }
in_disk && /<source file=/ && index($0, seed) { matched=1 }
in_disk && /<target dev=/ {
line=$0
sub(/.*<target dev=.?/, "", line)
sub(/[ '\'"'"'"].*/, "", line)
target=line
}
in_disk && /<\/disk>/ {
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/.*<mac address='\([^']*\)'.*/\1/p" | head -n1
}
dhcp_lease_ip() {
local domain="$1"
local mac
mac="$(domain_mac "$domain")"
[ -n "$mac" ] || return 1
_virsh net-dhcp-leases "$SC_NETWORK_NAME" 2>/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"
}