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.
This commit is contained in:
2026-05-02 11:49:07 -04:00
commit 0265afa054
252 changed files with 37574 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Install config management for Sysadmin Chronicles.
# Config lives at ~/.config/sysadmin-chronicles/config (survives game dir moves).
# Source this file; do not execute directly.
SC_CONFIG_DIR="${SC_CONFIG_DIR:-$HOME/.config/sysadmin-chronicles}"
SC_CONFIG_FILE="$SC_CONFIG_DIR/config"
config_read() {
[ -f "$SC_CONFIG_FILE" ] && source "$SC_CONFIG_FILE" || true
}
config_write() {
local key="$1"
local value="$2"
mkdir -p "$SC_CONFIG_DIR"
local tmp
tmp="$(mktemp "$SC_CONFIG_DIR/config.XXXXXX")"
if [ -f "$SC_CONFIG_FILE" ]; then
awk -v key="$key" -v value="$value" '
BEGIN { found = 0 }
index($0, key "=") == 1 {
print key "=" value
found = 1
next
}
{ print }
END {
if (!found) {
print key "=" value
}
}
' "$SC_CONFIG_FILE" > "$tmp"
else
printf '%s=%s\n' "$key" "$value" > "$tmp"
fi
mv "$tmp" "$SC_CONFIG_FILE"
}
config_show() {
if [ ! -f "$SC_CONFIG_FILE" ]; then
echo " (no config file at $SC_CONFIG_FILE)"
return
fi
echo " Config: $SC_CONFIG_FILE"
local line key value
while IFS= read -r line; do
[[ "$line" =~ ^# ]] && continue
[[ -z "$line" ]] && continue
key="${line%%=*}"
value="${line#*=}"
printf ' %-28s %s\n' "$key" "$value"
done < "$SC_CONFIG_FILE"
}
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env bash
# Dependency detection and installation for Sysadmin Chronicles.
# Source this file; do not execute directly.
SC_DISTRO=""
detect_distro() {
if [ -f /etc/arch-release ]; then
SC_DISTRO=arch
elif grep -qi ubuntu /etc/os-release 2>/dev/null; then
SC_DISTRO=ubuntu
elif [ -f /etc/debian_version ]; then
SC_DISTRO=debian
elif [ -f /etc/fedora-release ]; then
SC_DISTRO=fedora
elif grep -qi opensuse /etc/os-release 2>/dev/null; then
SC_DISTRO=opensuse
else
SC_DISTRO=unknown
fi
}
# Per-distro package names for canonical dep names.
# Format: canonical:arch:debian:ubuntu:fedora:opensuse
# Empty field = not applicable / same as above / handled specially.
_SC_DEP_MAP=(
"libvirt:libvirt:libvirt-daemon-system:libvirt-daemon-system:libvirt:libvirt"
"qemu-system:qemu-system-x86:qemu-system-x86:qemu-kvm:qemu-kvm:qemu-kvm"
"qemu-img:qemu-img:qemu-utils:qemu-utils:qemu-img:qemu-tools"
"virt-install:virt-install:virtinst:virtinst:virt-install:virt-install"
"virt-viewer:virt-viewer:virt-viewer:virt-viewer:virt-viewer:virt-viewer"
"cloud-localds:cloud-image-utils:cloud-image-utils:cloud-image-utils:cloud-utils:cloud-utils"
"genisoimage:cdrtools:genisoimage:genisoimage:genisoimage:genisoimage"
"xorriso:libisoburn:xorriso:xorriso:xorriso:xorriso"
"nodejs:nodejs:nodejs:nodejs:nodejs:nodejs"
"openssh:openssh:openssh-client:openssh-client:openssh-clients:openssh-clients"
)
# Arch-only QEMU SPICE/display extras installed alongside qemu-system
_SC_ARCH_QEMU_EXTRAS=(
qemu-hw-display-qxl
qemu-hw-display-virtio-gpu
qemu-ui-spice-core
qemu-chardev-spice
qemu-audio-spice
)
map_package() {
local dep="$1"
local distro="${2:-$SC_DISTRO}"
local entry
for entry in "${_SC_DEP_MAP[@]}"; do
IFS=':' read -r name arch debian ubuntu fedora opensuse <<< "$entry"
if [ "$name" = "$dep" ]; then
case "$distro" in
arch) printf '%s' "$arch" ;;
debian) printf '%s' "$debian" ;;
ubuntu) printf '%s' "$ubuntu" ;;
fedora) printf '%s' "$fedora" ;;
opensuse) printf '%s' "$opensuse" ;;
esac
return
fi
done
}
# Outputs canonical dep names that are not yet installed (one per line)
check_deps() {
local missing=()
command -v virsh >/dev/null 2>&1 || missing+=(libvirt)
command -v qemu-system-x86_64 >/dev/null 2>&1 || missing+=(qemu-system)
command -v qemu-img >/dev/null 2>&1 || missing+=(qemu-img)
command -v virt-install >/dev/null 2>&1 || missing+=(virt-install)
command -v remote-viewer >/dev/null 2>&1 || missing+=(virt-viewer)
command -v node >/dev/null 2>&1 || missing+=(nodejs)
command -v ssh >/dev/null 2>&1 || missing+=(openssh)
# Need at least one cloud-init ISO tool
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
missing+=(cloud-localds genisoimage xorriso)
fi
[ "${#missing[@]}" -gt 0 ] && printf '%s\n' "${missing[@]}" || true
}
# Install a list of canonical dep names. Logs to SC_INSTALL_LOG if set.
install_deps() {
local deps=("$@")
local pkgs=()
for dep in "${deps[@]}"; do
local pkg
pkg="$(map_package "$dep")"
[ -n "$pkg" ] && pkgs+=("$pkg")
done
if [ "$SC_DISTRO" = "arch" ]; then
pkgs+=("${_SC_ARCH_QEMU_EXTRAS[@]}")
fi
[ "${#pkgs[@]}" -eq 0 ] && return 0
local -a pm_cmd
case "$SC_DISTRO" in
arch) pm_cmd=(pacman -S --noconfirm --needed) ;;
debian|ubuntu) pm_cmd=(apt-get install -y) ;;
fedora) pm_cmd=(dnf install -y) ;;
opensuse) pm_cmd=(zypper install -y) ;;
*)
echo " Unsupported distro '$SC_DISTRO' — install these manually:"
printf ' %s\n' "${pkgs[@]}"
return 1
;;
esac
sudo "${pm_cmd[@]}" "${pkgs[@]}"
if [ -n "${SC_INSTALL_LOG:-}" ] && [ "$SC_INSTALL_LOG" != "/dev/null" ]; then
mkdir -p "$(dirname "$SC_INSTALL_LOG")"
local distro_label="$SC_DISTRO"
for pkg in "${pkgs[@]}"; do
local ver=""
case "$SC_DISTRO" in
arch) ver="$(pacman -Q "$pkg" 2>/dev/null | awk '{print $2}' || true)" ;;
debian|ubuntu) ver="$(dpkg -l "$pkg" 2>/dev/null | awk '/^ii/{print $3}' | head -1 || true)" ;;
fedora) ver="$(rpm -q --queryformat '%{VERSION}' "$pkg" 2>/dev/null || true)" ;;
esac
printf '[INSTALLED] %-36s %-14s via %s\n' "$pkg" "${ver:-}" "$distro_label" \
>> "$SC_INSTALL_LOG"
done
fi
}
# Outputs already-installed canonical deps (for log completeness)
log_present_deps() {
local log_file="${SC_INSTALL_LOG:-}"
[ -z "$log_file" ] || [ "$log_file" = "/dev/null" ] && return
local distro_label="$SC_DISTRO"
for dep in libvirt qemu-system qemu-img virt-install virt-viewer nodejs openssh; do
local bin
case "$dep" in
libvirt) bin=virsh ;;
qemu-system) bin=qemu-system-x86_64 ;;
qemu-img) bin=qemu-img ;;
virt-install) bin=virt-install ;;
virt-viewer) bin=remote-viewer ;;
nodejs) bin=node ;;
openssh) bin=ssh ;;
esac
if command -v "$bin" >/dev/null 2>&1; then
printf '[SKIPPED] %-36s already installed\n' "$dep" >> "$log_file"
fi
done
}
dep_label() {
case "$1" in
libvirt) echo "Virtual machine manager (libvirt)" ;;
qemu-system) echo "KVM virtualization support (QEMU)" ;;
qemu-img) echo "VM disk image tools (qemu-img)" ;;
virt-install) echo "VM installer (virt-install)" ;;
virt-viewer) echo "SPICE display viewer (virt-viewer)" ;;
cloud-localds|genisoimage|xorriso) echo "Cloud image tools" ;;
nodejs) echo "Node.js runtime" ;;
openssh) echo "SSH client" ;;
*) echo "$1" ;;
esac
}
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# Shared internal HTTPS/URL helpers for Sysadmin Chronicles launch and VM build scripts.
# Source this file; do not execute directly.
sc_internal_port() {
printf '%s\n' "${PORT:-3000}"
}
sc_cert_dir() {
printf '%s\n' "${SC_CERT_DIR:-$HOME/.local/share/sysadmin-chronicles/certs}"
}
sc_tls_cert() {
printf '%s/server.crt\n' "$(sc_cert_dir)"
}
sc_tls_key() {
printf '%s/server.key\n' "$(sc_cert_dir)"
}
sc_ca_cert() {
printf '%s/ca.crt\n' "$(sc_cert_dir)"
}
sc_hud_url() {
printf '%s\n' "${SC_HUD_URL:-https://portal.axiomworks.internal:$(sc_internal_port)}"
}
sc_sage_url() {
printf '%s\n' "${SC_SAGE_URL:-https://sage.axiomworks.internal:$(sc_internal_port)/sage/}"
}
sc_company_url() {
printf '%s\n' "${SC_COMPANY_URL:-https://www.axiomworks.corp/}"
}
sc_have_internal_certs() {
[[ -f "$(sc_tls_cert)" && -f "$(sc_tls_key)" && -f "$(sc_ca_cert)" ]]
}
sc_ensure_internal_certs() {
local project_root="$1"
if sc_have_internal_certs; then
return 0
fi
bash "$project_root/tools/setup/generate-certs.sh"
}
sc_export_internal_https_env() {
export SC_CERT_DIR="$(sc_cert_dir)"
export SC_TLS_CERT="$(sc_tls_cert)"
export SC_TLS_KEY="$(sc_tls_key)"
export SC_HUD_URL="$(sc_hud_url)"
export SC_SAGE_URL="$(sc_sage_url)"
export SC_COMPANY_URL="$(sc_company_url)"
}
sc_listen_pids() {
local port="$1"
if command -v lsof >/dev/null 2>&1; then
lsof -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null | sort -u
return 0
fi
ss -H -ltnp "sport = :$port" 2>/dev/null \
| sed -n 's/.*pid=\([0-9][0-9]*\).*/\1/p' \
| sort -u
}
sc_pid_is_repo_server() {
local pid="$1"
local project_root="$2"
local server_dir="$project_root/server"
local cwd=""
local cmdline=""
[[ -r "/proc/$pid/cmdline" ]] || return 1
cwd="$(readlink -f "/proc/$pid/cwd" 2>/dev/null || true)"
cmdline="$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null || true)"
[[ "$cwd" == "$server_dir" ]] || return 1
[[ "$cmdline" == *"node"* && "$cmdline" == *"src/index.js"* ]]
}
sc_pid_has_internal_tls() {
local pid="$1"
[[ -r "/proc/$pid/environ" ]] || return 1
tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null \
| grep -q '^SC_TLS_CERT=.*server\.crt$' \
&& tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null \
| grep -q '^SC_TLS_KEY=.*server\.key$'
}
sc_stop_pid() {
local pid="$1"
kill "$pid" 2>/dev/null || true
for _ in 1 2 3 4 5 6 7 8 9 10; do
kill -0 "$pid" 2>/dev/null || return 0
sleep 0.2
done
kill -TERM "$pid" 2>/dev/null || true
}
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# libvirt wrappers for Sysadmin Chronicles.
# Source this file; do not execute directly.
# Expects LIBVIRT_DEFAULT_URI to be set by the caller.
#
# SC_VIRSH_SUDO=true — prefix all virsh calls with sudo.
# Set this in install.sh when the current session doesn't yet have
# the libvirt group active (e.g., right after usermod -aG libvirt).
_virsh() {
if [ "${SC_VIRSH_SUDO:-false}" = true ]; then
sudo virsh "$@"
else
virsh "$@"
fi
}
ensure_network() {
local name="$1"
local xml_path="$2"
if _virsh net-list --all 2>/dev/null | grep -q "\\b${name}\\b"; then
if ! _virsh net-info "$name" 2>/dev/null | grep -q "Active:.*yes"; then
_virsh net-start "$name" >/dev/null 2>&1
fi
return 0
fi
_virsh net-define "$xml_path" >/dev/null 2>&1
_virsh net-autostart "$name" >/dev/null 2>&1
_virsh net-start "$name" >/dev/null 2>&1
}
ensure_pool() {
local name="$1"
local path="$2"
if _virsh pool-list --all 2>/dev/null | grep -q "\\b${name}\\b"; then
if ! _virsh pool-info "$name" 2>/dev/null | grep -q "State:.*running"; then
_virsh pool-start "$name" >/dev/null 2>&1
fi
return 0
fi
mkdir -p "$path"
_virsh pool-define-as "$name" dir --target "$path" >/dev/null 2>&1
_virsh pool-autostart "$name" >/dev/null 2>&1
_virsh pool-start "$name" >/dev/null 2>&1
}
pool_path() {
local name="$1"
_virsh pool-dumpxml "$name" 2>/dev/null \
| sed -n 's:.*<path>\(.*\)</path>.*:\1:p' \
| head -n1
}
domain_exists() {
_virsh dominfo "$1" >/dev/null 2>&1
}
domain_state() {
_virsh domstate "$1" 2>/dev/null | tr -d ' \n'
}
network_active() {
_virsh net-info "$1" 2>/dev/null | grep -q "Active:.*yes"
}
ensure_network_active() {
local name="$1"
_virsh net-list --all 2>/dev/null | grep -q "\\b${name}\\b" || return 1
network_active "$name" || _virsh net-start "$name" >/dev/null 2>&1
}
snapshot_exists() {
_virsh snapshot-info "$1" "$2" >/dev/null 2>&1
}
snapshot_create() {
local domain="$1"
local name="$2"
local desc="${3:-}"
_virsh snapshot-delete "$domain" "$name" >/dev/null 2>&1 || true
_virsh snapshot-create-as "$domain" "$name" --description "$desc" --atomic
}
snapshot_revert() {
_virsh snapshot-revert "$1" "$2" --running
}
snapshot_delete() {
_virsh snapshot-delete "$1" "$2"
}
snapshot_list_names() {
_virsh snapshot-list "$1" --name 2>/dev/null || true
}
# Returns approximate qcow2 disk usage for a domain in human-readable form
domain_disk_usage() {
local domain="$1"
local total=0
local disk
for disk in $(_virsh domblklist "$domain" --details 2>/dev/null | awk '/disk/ && $4 != "-" {print $4}' || true); do
[ -f "$disk" ] || continue
local sz
sz="$(du -sb "$disk" 2>/dev/null | awk '{print $1}' || echo 0)"
total=$(( total + sz ))
done
if [ "$total" -gt 0 ]; then
numfmt --to=iec-i --suffix=B "$total" 2>/dev/null || echo "${total}B"
else
echo "0B"
fi
}
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Save slot management for Sysadmin Chronicles.
# Source this file; do not execute directly.
SC_SAVE_DIR="${SC_SAVE_DIR:-$HOME/.local/share/sysadmin-chronicles/saves}"
_save_path() { printf '%s/%s.json' "$SC_SAVE_DIR" "$1"; }
_save_active_slot() {
local slot=""
if [ -f "$SC_SAVE_DIR/.active" ]; then
slot="$(cat "$SC_SAVE_DIR/.active")"
fi
printf '%s' "${slot:-autosave}"
}
_save_valid_slot() {
case "$1" in
autosave|slot-1|slot-2|slot-3) return 0 ;;
*) echo " ✗ Invalid slot name: $1 (use autosave, slot-1, slot-2, or slot-3)"; return 1 ;;
esac
}
_new_game_json() {
local slot="$1"
printf '{"slot":"%s","day":1,"trust":50,"questsCompleted":0,"quests":{},"flags":{},"inbox":[],"clock":{"shift":1,"day":1}}\n' \
"$slot"
}
save_list() {
mkdir -p "$SC_SAVE_DIR"
local active
active="$(_save_active_slot)"
printf ' %-14s %-10s %-10s %-10s\n' "Slot" "Day" "Trust" "Quests"
printf ' %-14s %-10s %-10s %-10s\n' "──────────────" "──────────" "──────────" "──────────"
local slot
for slot in autosave slot-1 slot-2 slot-3; do
local path
path="$(_save_path "$slot")"
if [ -f "$path" ]; then
local day trust quests marker=""
day="$( grep -o '"day":[0-9]*' "$path" 2>/dev/null | head -1 | cut -d: -f2 || echo '?')"
trust="$( grep -o '"trust":[0-9]*' "$path" 2>/dev/null | head -1 | cut -d: -f2 || echo '?')"
quests="$( grep -o '"questsCompleted":[0-9]*' "$path" 2>/dev/null | head -1 | cut -d: -f2 || echo '?')"
[ "$slot" = "$active" ] && marker=" [active]"
printf ' %-14s %-10s %-10s %-10s%s\n' "$slot" "Day $day" "T:$trust" "Q:$quests" "$marker"
else
printf ' %-14s %s\n' "$slot" "—empty—"
fi
done
}
save_switch() {
local slot="$1"
_save_valid_slot "$slot" || return 1
[ -f "$(_save_path "$slot")" ] || { echo " ✗ No save in slot: $slot"; return 1; }
printf '%s' "$slot" > "$SC_SAVE_DIR/.active"
echo " ✓ Switched to $slot"
}
save_new() {
local slot="$1"
_save_valid_slot "$slot" || return 1
mkdir -p "$SC_SAVE_DIR"
_new_game_json "$slot" > "$(_save_path "$slot")"
echo " ✓ Created new save: $slot"
}
save_reset() {
local slot="${1:-$(_save_active_slot)}"
_save_valid_slot "$slot" || return 1
mkdir -p "$SC_SAVE_DIR"
_new_game_json "$slot" > "$(_save_path "$slot")"
echo " ✓ Reset $slot to new game state"
}
save_export() {
local slot="$1"
local dest="$2"
_save_valid_slot "$slot" || return 1
[ -f "$(_save_path "$slot")" ] || { echo " ✗ No save in slot: $slot"; return 1; }
[ -n "$dest" ] || { echo " ✗ No destination path given"; return 1; }
cp "$(_save_path "$slot")" "$dest"
echo " ✓ Exported $slot$dest"
}
save_import() {
local src="$1"
local slot="$2"
_save_valid_slot "$slot" || return 1
[ -f "$src" ] || { echo " ✗ File not found: $src"; return 1; }
# Basic JSON sanity check
grep -q '^{' "$src" 2>/dev/null || { echo " ✗ File does not look like a save: $src"; return 1; }
mkdir -p "$SC_SAVE_DIR"
cp "$src" "$(_save_path "$slot")"
echo " ✓ Imported $src$slot"
}
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Shared UI helpers for Sysadmin Chronicles scripts.
# Source this file; do not execute directly.
_SC_STEP_N=0
# Colors — disabled if stdout is not a terminal or NO_COLOR is set
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
_C_RESET='\033[0m'
_C_BOLD='\033[1m'
_C_GREEN='\033[0;32m'
_C_YELLOW='\033[0;33m'
_C_RED='\033[0;31m'
_C_CYAN='\033[0;36m'
_C_DIM='\033[2m'
else
_C_RESET='' _C_BOLD='' _C_GREEN='' _C_YELLOW='' _C_RED='' _C_CYAN='' _C_DIM=''
fi
sc_header() {
local title="${1:-Sysadmin Chronicles}"
echo ""
printf "${_C_CYAN}${_C_BOLD}"
echo "╔══════════════════════════════════════════╗"
printf "║ %-40s║\n" "$title"
echo "╚══════════════════════════════════════════╝"
printf "${_C_RESET}"
echo ""
}
sc_done_banner() {
echo ""
printf "${_C_GREEN}${_C_BOLD}"
echo "╔══════════════════════════════════════════╗"
printf "║ %-40s║\n" "SETUP COMPLETE!"
echo "╚══════════════════════════════════════════╝"
printf "${_C_RESET}"
echo ""
}
sc_section() {
echo ""
printf "${_C_BOLD}── %s ${_C_DIM}─────────────────────────────────${_C_RESET}\n" "$*"
}
sc_step() {
(( _SC_STEP_N++ )) || true
echo ""
printf "${_C_BOLD}── Step %d: %s${_C_RESET}\n" "$_SC_STEP_N" "$*"
}
sc_ok() { printf " ${_C_GREEN}${_C_RESET} %s\n" "$*"; }
sc_warn() { printf " ${_C_YELLOW}${_C_RESET} %s\n" "$*"; }
sc_err() { printf " ${_C_RED}${_C_RESET} %s\n" "$*" >&2; }
sc_fail() { sc_err "$*"; exit 1; }
sc_info() { printf " ${_C_DIM}${_C_RESET} %s\n" "$*"; }
# Prompt — writes question to /dev/tty, returns answer on stdout
sc_prompt() {
local question="$1"
local default="${2:-}"
if [ -n "$default" ]; then
printf " %s [%s] > " "$question" "$default" >/dev/tty
else
printf " %s > " "$question" >/dev/tty
fi
local answer
read -r answer </dev/tty
printf '%s' "${answer:-$default}"
}
# Confirm — returns 0 for yes, 1 for no
sc_confirm() {
local question="$1"
local default="${2:-Y}"
local prompt
if [ "${default^^}" = "Y" ]; then
prompt="[Y/n]"
else
prompt="[y/N]"
fi
printf " %s %s " "$question" "$prompt" >/dev/tty
local answer
read -r answer </dev/tty
answer="${answer:-$default}"
case "${answer,,}" in
y|yes) return 0 ;;
*) return 1 ;;
esac
}
sc_progress() {
local label="$1"
local current="$2"
local total="$3"
printf " %-38s %d/%d\n" "$label" "$current" "$total"
}
_SC_SPINNER_PID=""
sc_spinner() {
local label="${1:-Working...}"
printf " %s " "$label" >/dev/tty
(
local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
local i=0
while true; do
printf "\r %s %s " "$label" "${frames[$((i % ${#frames[@]}))]}" >/dev/tty
sleep 0.12
(( i++ )) || true
done
) &
_SC_SPINNER_PID=$!
disown "$_SC_SPINNER_PID" 2>/dev/null || true
}
sc_spinner_stop() {
if [ -n "${_SC_SPINNER_PID:-}" ]; then
kill "$_SC_SPINNER_PID" 2>/dev/null || true
wait "$_SC_SPINNER_PID" 2>/dev/null || true
_SC_SPINNER_PID=""
printf "\r%-60s\r" "" >/dev/tty
fi
}
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# VM operations for Sysadmin Chronicles.
# Source this file; requires lib/libvirt.sh and PROJECT_ROOT set.
SC_VM_TOOLS="${SC_VM_TOOLS:-${PROJECT_ROOT:-}/tools/vm}"
_sc_validate_snapshot_name() {
[[ "$1" =~ ^[a-zA-Z0-9][a-zA-Z0-9-]*$ ]]
}
_sc_protected_snapshot() {
[[ "$1" == baseline.* ]] || [[ "$1" == checkpoint.* ]]
}
vm_build() {
local profile="$1"
shift
local dry_run=false force=false
for arg in "$@"; do
case "$arg" in
--dry-run) dry_run=true ;;
--force) force=true ;;
esac
done
local script="$SC_VM_TOOLS/build-${profile}.sh"
[ -f "$script" ] || { echo " ✗ No build script for profile: $profile"; return 1; }
local args=()
[ "$dry_run" = true ] && args+=(--dry-run)
[ "$force" = true ] && args+=(--force)
bash "$script" "${args[@]}"
}
vm_rebuild() {
local profile="$1"
shift
local dry_run=false
for arg in "$@"; do [ "$arg" = "--dry-run" ] && dry_run=true; done
local domain="sc-${profile}"
if domain_exists "$domain" && [ "$dry_run" = false ]; then
virsh destroy "$domain" >/dev/null 2>&1 || true
virsh undefine "$domain" --nvram --snapshots-metadata >/dev/null 2>&1 \
|| virsh undefine "$domain" --snapshots-metadata >/dev/null 2>&1 \
|| virsh undefine "$domain" >/dev/null 2>&1 || true
fi
local extra_args=()
[ "$dry_run" = true ] && extra_args+=(--dry-run)
vm_build "$profile" "${extra_args[@]}"
}
vm_revert() {
snapshot_revert "$1" "$2"
}
vm_status() {
local vm_id="$1"
domain_exists "$vm_id" && domain_state "$vm_id" || printf 'missing'
}
vm_start() {
virsh start "$1" >/dev/null 2>&1
}
vm_stop() {
virsh shutdown "$1" >/dev/null 2>&1 || virsh destroy "$1" >/dev/null 2>&1 || true
}
vm_snapshot_create() {
local vm_id="$1"
local name="$2"
_sc_validate_snapshot_name "$name" \
|| { echo " ✗ Invalid name (letters, numbers, hyphens only): $name"; return 1; }
snapshot_create "$vm_id" "$name" "User snapshot — $(date '+%Y-%m-%d %H:%M')"
}
vm_snapshot_list() {
local vm_id="$1"
virsh snapshot-list "$vm_id" 2>/dev/null || true
}
vm_snapshot_revert() {
local vm_id="$1"
local name="$2"
snapshot_exists "$vm_id" "$name" \
|| { echo " ✗ Snapshot not found: $name"; return 1; }
snapshot_revert "$vm_id" "$name"
}
vm_snapshot_delete() {
local vm_id="$1"
local name="$2"
if _sc_protected_snapshot "$name"; then
echo " ✗ Cannot delete protected snapshot: $name"
return 1
fi
snapshot_delete "$vm_id" "$name"
}