#!/bin/bash # # Purpose : coredesktop-dev smoketest: run CoreDesktop's smoketest scenarios # (smoketest/ in the working copy) on QEMU virtual machines source "$(dirname "${BASH_SOURCE[0]}")/vm.sh" CLOUD_IMAGE_URL="${SMOKETEST_CLOUD_IMAGE_URL:-https://geo.mirror.pkgbuild.com/images/latest/Arch-Linux-x86_64-cloudimg.qcow2}" DISK_SIZE=120G RUNS="$SMOKETEST_STATE/runs" smoketest_usage() { cat << EOF Usage: coredesktop-dev smoketest list Show the scenarios run [options] [scenario...] Run scenarios against the working copy's HEAD --tier pr|all pr (the default): the quick ones; all: everything, including a fresh install and upgrades from older commits, which take hours --ref REF Test REF instead of HEAD --keep After a failure, leave its VM running to look around in, and stop images Show the VM images images base (Re)build the base image: Arch + GNOME images installed [REF] (Re)build the installed image: base + install.sh at REF (default: where HEAD branched from upstream) shell [base|installed] Boot a throwaway copy of an image and SSH in clean [--all] Delete old runs' logs (--all: images and the package cache too) Scenarios are named by file (40-failing-migration) or number (40). Each run keeps its logs in $RUNS. Set SMOKETEST_VNC=1 to watch the VM's screen at VNC display 127.0.0.1:1. EOF } cmd_smoketest() { local sub="${1:-}" shift || true case "$sub" in -h | --help | "") smoketest_usage; [ -n "$sub" ]; return ;; list | run | images | shell | clean) ;; *) smoketest_usage >&2; return 1 ;; esac require_working_copy SCENARIOS="$WORKING_COPY/smoketest/scenarios" [ -d "$SCENARIOS" ] || die "$WORKING_COPY has no smoketest/scenarios; is it an older CoreDesktop?" "smoketest_$sub" "$@" } require_vm_tools() { local tool for tool in qemu-system-x86_64 qemu-img xorriso curl ssh scp ssh-keygen; do command -v "$tool" &> /dev/null \ || die "$tool isn't installed. Install coredesktop-dev's packages: bash $(sibling_dir)/update.sh" done [ -r "$KVM_DEVICE" ] && [ -w "$KVM_DEVICE" ] \ || die "Can't use $KVM_DEVICE. Turn on virtualization (VT-x/AMD-V) in the firmware settings." } # scenario_info FILE: print IMAGE, TIER, DESCRIPTION and the VARIANTS (one per # line after the first three) scenario_info() { ( source "$WORKING_COPY/smoketest/lib/scenario.sh" source "$1" printf '%s\n' "$IMAGE" "$TIER" "$DESCRIPTION" (( ${#VARIANTS[@]} == 0 )) || printf '%s\n' "${VARIANTS[@]}" ) } smoketest_list() { local file info for file in "$SCENARIOS"/*.sh; do mapfile -t info < <(scenario_info "$file") printf '%-28s %-8s %-10s %s' "$(basename "$file" .sh)" "${info[1]}" "${info[0]}" "${info[2]}" (( ${#info[@]} > 3 )) && printf ' (%d variants)' $(( ${#info[@]} - 3 )) echo done } # Set SELECTED to the scenario files to run: those named, or those in the tier select_scenarios() { local tier="$1" name file found info shift SELECTED=() if (( $# > 0 )); then for name in "$@"; do found="" for file in "$SCENARIOS"/*.sh; do case "$(basename "$file" .sh)" in "$name" | "$name"-*) found="$file"; break ;; esac done [ -n "$found" ] || die "No scenario called $name (see: coredesktop-dev smoketest list)" SELECTED+=("$found") done return fi for file in "$SCENARIOS"/*.sh; do mapfile -t info < <(scenario_info "$file") [[ "$tier" == all || "${info[1]}" == "$tier" ]] && SELECTED+=("$file") done return 0 } smoketest_run() { local tier=pr ref=HEAD keep=0 names=() while (( $# > 0 )); do case "$1" in --tier) tier="${2:-}"; shift 2 || die "--tier needs pr or all" ;; --ref) ref="${2:-}"; shift 2 || die "--ref needs a commit" ;; --keep) keep=1; shift ;; -*) die "Unknown option: $1" ;; *) names+=("$1"); shift ;; esac done [[ "$tier" == pr || "$tier" == all ]] || die "--tier is pr or all, not $tier" require_vm_tools local sha sha="$(git -C "$WORKING_COPY" rev-parse --verify --quiet "$ref^{commit}")" || die "No such commit: $ref" if [ "$ref" = HEAD ] && [ -n "$(git -C "$WORKING_COPY" status --porcelain --untracked-files=no)" ]; then echo "Note: the working copy has uncommitted changes. Only HEAD is installed and" echo "updated in the VM; the scenarios themselves are read from the working copy." fi select_scenarios "$tier" "${names[@]}" local files=("${SELECTED[@]}") (( ${#files[@]} > 0 )) || die "No scenarios to run." # Build the images the chosen scenarios need, if they aren't there yet local file info need_installed=0 for file in "${files[@]}"; do mapfile -t info < <(scenario_info "$file") [ "${info[0]}" = installed ] && need_installed=1 done [ -f "$IMAGES/base.qcow2" ] || build_base_image if (( need_installed )) && [ ! -f "$IMAGES/installed.qcow2" ]; then build_installed_image "$(default_installed_ref)" fi RUN="$RUNS/$(date +%Y%m%d-%H%M%S)" mkdir -p "$RUN/shared" echo "Testing $(git -C "$WORKING_COPY" log -1 --format='%h %s' "$sha"); logs in $RUN" local results=() failed=0 variants variant status for file in "${files[@]}"; do mapfile -t info < <(scenario_info "$file") variants=("${info[@]:3}") (( ${#variants[@]} > 0 )) || variants=("") for variant in "${variants[@]}"; do run_scenario "$file" "$variant" "$sha" "$keep" status=$? local label label="$(basename "$file" .sh)${variant:+ @ $variant}" case $status in 0) results+=("PASS $label") ;; 3) results+=("SKIP $label") ;; *) results+=("FAIL $label"); failed=1 ;; esac if (( failed && keep )); then break 2 fi done done echo printf '%s\n' "${results[@]}" | tee "$RUN/summary" echo "Logs: $RUN" return $failed } # run_scenario FILE VARIANT SHA KEEP: boot a throwaway copy of the scenario's # image and run it. Returns 0 for pass, 3 for skip, anything else for fail. run_scenario() { local file="$1" variant="$2" sha="$3" keep="$4" local name dir name="$(basename "$file" .sh)${variant:+@$variant}" dir="$RUN/$name" mkdir -p "$dir" echo echo "=== $name" ( export SMOKETEST_RESULTS="$dir" SMOKETEST_SHARED="$RUN/shared" SMOKETEST_REF="$sha" VARIANT="$variant" IMAGE_REF="$(cat "$IMAGES/installed.ref" 2> /dev/null)" export IMAGE_REF source "$WORKING_COPY/smoketest/lib/scenario.sh" source "$file" qemu-img create -q -f qcow2 -b "$IMAGES/$IMAGE.qcow2" -F qcow2 "$dir/disk.qcow2" \ || die "Couldn't make the VM's disk." vm_start "$dir" "$dir/disk.qcow2" trap 'finish_scenario $? '"$keep" EXIT vm_wait_up smoketest_prepare set -e scenario ) 2>&1 | tee "$dir/scenario.log" return "${PIPESTATUS[0]}" } # Runs as the scenario's subshell exits: stop the VM and delete its disk, # unless it failed and --keep was given finish_scenario() { local status="$1" keep="$2" if (( status != 0 && status != SKIP_STATUS && keep )); then echo echo "The VM is still running. To look around: $(vm_ssh_command)" echo "To stop it: kill $(vm_pid) Its disk: $VM_DIR/disk.qcow2" return fi vm_kill rm -f "$VM_DIR/disk.qcow2" } # Where the installed image is built by default: the commit HEAD branched # from upstream, so updating it to HEAD exercises the branch's changes default_installed_ref() { git -C "$WORKING_COPY" merge-base HEAD '@{upstream}' 2> /dev/null \ || git -C "$WORKING_COPY" merge-base HEAD origin/main 2> /dev/null \ || git -C "$WORKING_COPY" rev-parse HEAD } download_cloud_image() { local image="$IMAGES/cloudimg.qcow2" expected actual [ -f "$image" ] && return 0 mkdir -p "$IMAGES" echo "Downloading Arch's cloud image..." curl -fL --progress-bar -o "$image.part" "$CLOUD_IMAGE_URL" || die "Couldn't download $CLOUD_IMAGE_URL" expected="$(curl -fsSL "$CLOUD_IMAGE_URL.SHA256" | awk '{print $1}')" \ || die "Couldn't download $CLOUD_IMAGE_URL.SHA256" actual="$(sha256sum "$image.part" | awk '{print $1}')" [ -n "$expected" ] && [ "$expected" = "$actual" ] \ || die "The cloud image's checksum doesn't match; try again." mv "$image.part" "$image" } build_base_image() { require_vm_tools download_cloud_image local build="$SMOKETEST_STATE/build-base" rm -rf "$build" mkdir -p "$build/seed" echo "Building the base image (Arch + GNOME); log: $build/build.log" cp --reflink=auto "$IMAGES/cloudimg.qcow2" "$build/disk.qcow2" qemu-img resize -q "$build/disk.qcow2" "$DISK_SIZE" ensure_ssh_key sed "s|@SSH_KEY@|$(cat "$SSH_KEY.pub")|" "$(sibling_dir)/vm/user-data" > "$build/seed/user-data" printf 'instance-id: coredesktop-smoketest\nlocal-hostname: coredesktop-smoketest\n' \ > "$build/seed/meta-data" xorriso -as mkisofs -quiet -output "$build/seed.iso" -volid cidata -joliet -rock \ "$build/seed/user-data" "$build/seed/meta-data" || die "Couldn't make the cloud-init disk." ( vm_start "$build" "$build/disk.qcow2" -cdrom "$build/seed.iso" trap vm_kill EXIT wait_for_ssh vm_ssh 'cloud-init status --wait' < /dev/null > /dev/null || true vm_put "$(sibling_dir)/vm/provision.sh" /tmp/provision.sh || die "Couldn't copy provision.sh to the VM." vm_exec 'sudo bash /tmp/provision.sh' || die "Provisioning failed; see $build/build.log" vm_stop ) 2>&1 | tee "$build/build.log" (( PIPESTATUS[0] == 0 )) || die "Building the base image failed; see $build/build.log" mv "$build/disk.qcow2" "$IMAGES/base.qcow2" # Built on the old base image rm -f "$IMAGES/installed.qcow2" "$IMAGES/installed.ref" rm -rf "$build" echo "Built $IMAGES/base.qcow2" } # build_installed_image REF: the base image after install.sh at REF build_installed_image() { local ref sha build="$SMOKETEST_STATE/build-installed" ref="$1" sha="$(git -C "$WORKING_COPY" rev-parse --verify --quiet "$ref^{commit}")" || die "No such commit: $ref" require_vm_tools [ -f "$IMAGES/base.qcow2" ] || build_base_image rm -rf "$build" mkdir -p "$build" echo "Building the installed image at $(git -C "$WORKING_COPY" log -1 --format='%h %s' "$sha");" \ "this takes an hour or more. Log: $build/build.log" qemu-img create -q -f qcow2 -b "$IMAGES/base.qcow2" -F qcow2 "$build/disk.qcow2" ( export SMOKETEST_RESULTS="$build" SMOKETEST_SHARED="$build" SMOKETEST_REF="$sha" IMAGE=base source "$WORKING_COPY/smoketest/lib/scenario.sh" vm_start "$build" "$build/disk.qcow2" trap vm_kill EXIT vm_wait_up smoketest_install_image "$sha" # The README says to reboot after installing vm_reboot vm_stop ) 2>&1 | tee "$build/build.log" (( PIPESTATUS[0] == 0 )) || die "Building the installed image failed; logs in $build" mv "$build/disk.qcow2" "$IMAGES/installed.qcow2" echo "$sha" > "$IMAGES/installed.ref" echo "Built $IMAGES/installed.qcow2" } smoketest_images() { case "${1:-}" in "") local image for image in base installed; do if [ -f "$IMAGES/$image.qcow2" ]; then printf '%-10s built %s' "$image" "$(date -r "$IMAGES/$image.qcow2" '+%Y-%m-%d %H:%M')" [ "$image" = installed ] \ && printf ', at %s' "$(git -C "$WORKING_COPY" log -1 --format='%h %s' "$(cat "$IMAGES/installed.ref")" 2> /dev/null)" echo else printf '%-10s not built\n' "$image" fi done ;; base) build_base_image ;; installed) build_installed_image "${2:-$(default_installed_ref)}" ;; *) smoketest_usage >&2; return 1 ;; esac } smoketest_shell() { local image="${1:-installed}" dir [[ "$image" == base || "$image" == installed ]] || die "The image is base or installed, not $image." require_vm_tools [ -f "$IMAGES/$image.qcow2" ] || die "The $image image isn't built: coredesktop-dev smoketest images $image" dir="$SMOKETEST_STATE/shell" rm -rf "$dir" mkdir -p "$dir" qemu-img create -q -f qcow2 -b "$IMAGES/$image.qcow2" -F qcow2 "$dir/disk.qcow2" vm_start "$dir" "$dir/disk.qcow2" trap 'vm_kill; rm -rf "$dir"' EXIT echo "Starting a throwaway copy of the $image image..." vm_wait_up vm_ssh -t } smoketest_clean() { case "${1:-}" in "") rm -rf "$RUNS" && echo "Deleted $RUNS" ;; --all) rm -rf "$SMOKETEST_STATE" && echo "Deleted $SMOKETEST_STATE" ;; *) smoketest_usage >&2; return 1 ;; esac }