first commit
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Purpose : coredesktop-dev check: lint and test the working copy, including
|
||||
# scripts not committed yet, which CoreDesktop's make lint skips
|
||||
|
||||
check_usage() {
|
||||
cat << EOF
|
||||
Usage: coredesktop-dev check [--fast]
|
||||
|
||||
Runs make lint and make test in the working copy, then shellcheck on the
|
||||
scripts git doesn't track yet. --fast runs only the shellcheck steps.
|
||||
EOF
|
||||
}
|
||||
|
||||
# Print each file named on stdin (NUL-separated) that starts with a bash or sh
|
||||
# shebang, as the Makefile picks them, or is a .sh file
|
||||
shell_scripts() {
|
||||
local file
|
||||
while IFS= read -r -d '' file; do
|
||||
[ -f "$file" ] && [ ! -L "$file" ] || continue
|
||||
if [[ "$file" == *.sh ]] || head -n 1 "$file" | grep -qE '^#!.*[/ ](ba)?sh'; then
|
||||
echo "$file"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
cmd_check() {
|
||||
local fast=0
|
||||
case "${1:-}" in
|
||||
-h | --help) check_usage; return 0 ;;
|
||||
--fast) fast=1 ;;
|
||||
"") ;;
|
||||
*) check_usage >&2; return 1 ;;
|
||||
esac
|
||||
require_working_copy
|
||||
cd "$WORKING_COPY" || return 1
|
||||
|
||||
local status=0 tracked untracked lint="ok" bats="skipped (--fast)"
|
||||
mapfile -t tracked < <(git ls-files -z | shell_scripts)
|
||||
mapfile -t untracked < <(git ls-files -z --others --exclude-standard | shell_scripts)
|
||||
|
||||
echo "== make lint"
|
||||
make --no-print-directory lint || { lint="FAILED"; status=1; }
|
||||
|
||||
if (( ${#untracked[@]} > 0 )); then
|
||||
echo "== shellcheck on untracked scripts: ${untracked[*]}"
|
||||
shellcheck --severity=warning "${untracked[@]}" || { lint="FAILED"; status=1; }
|
||||
fi
|
||||
|
||||
if (( fast == 0 )); then
|
||||
echo "== make test"
|
||||
if make --no-print-directory test; then
|
||||
bats="ok ($(bats --count tests 2> /dev/null) tests)"
|
||||
else
|
||||
bats="FAILED"
|
||||
status=1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "shellcheck: $lint (${#tracked[@]} files, ${#untracked[@]} untracked) bats: $bats"
|
||||
return $status
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Purpose : Paths, settings and helpers shared by coredesktop-dev's scripts
|
||||
|
||||
# The deployed copy: the machine's running CoreDesktop, which update pulls
|
||||
COREDESKTOP="$HOME/.local/share/coredesktop"
|
||||
|
||||
CONFIG_DIR="$HOME/.config/coredesktop-dev"
|
||||
CONFIG="$CONFIG_DIR/config"
|
||||
|
||||
# First line after the shebang of the pre-push hook this project installs, so it
|
||||
# never overwrites or removes a hook someone else wrote
|
||||
HOOK_MARKER="# Installed by coredesktop-dev; remove with: coredesktop-dev hooks remove"
|
||||
|
||||
die() {
|
||||
echo "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
confirm() {
|
||||
local answer
|
||||
read -rp "$1 [y/N] " answer
|
||||
[[ "$answer" == [yY]* ]]
|
||||
}
|
||||
|
||||
# Sets WORKING_COPY from the settings file (empty if there is none)
|
||||
load_config() {
|
||||
WORKING_COPY=""
|
||||
# shellcheck source=/dev/null
|
||||
[ -f "$CONFIG" ] && source "$CONFIG"
|
||||
return 0
|
||||
}
|
||||
|
||||
save_config() {
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
printf 'WORKING_COPY=%q\n' "$1" > "$CONFIG"
|
||||
}
|
||||
|
||||
# True if DIR is the deployed copy, however it's spelled
|
||||
is_deployed_copy() {
|
||||
[ -e "$1" ] && [ "$(realpath "$1")" = "$(realpath "$COREDESKTOP" 2> /dev/null)" ]
|
||||
}
|
||||
|
||||
# True if DIR is the top of a git clone of CoreDesktop
|
||||
is_coredesktop_clone() {
|
||||
[ -d "$1" ] \
|
||||
&& [ "$(git -C "$1" rev-parse --show-toplevel 2> /dev/null)" = "$(realpath "$1")" ] \
|
||||
&& [ -f "$1/update" ] && [ -d "$1/updates.d" ]
|
||||
}
|
||||
|
||||
# Load the settings and stop unless they point at a CoreDesktop clone
|
||||
require_working_copy() {
|
||||
load_config
|
||||
[ -n "$WORKING_COPY" ] \
|
||||
|| die "No working copy is set. Run $(sibling_dir)/install.sh, or: sibling add <coredesktop-dev url>"
|
||||
is_coredesktop_clone "$WORKING_COPY" \
|
||||
|| die "The working copy, $WORKING_COPY, isn't a clone of CoreDesktop (set in $CONFIG)."
|
||||
}
|
||||
|
||||
in_flatpak() {
|
||||
[ -f /.flatpak-info ] || [ -n "${FLATPAK_ID:-}" ]
|
||||
}
|
||||
|
||||
# This project's folder
|
||||
sibling_dir() {
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Purpose : coredesktop-dev deployed: show the deployed copy of CoreDesktop
|
||||
# (~/.local/share/coredesktop), or put back its tracked files so
|
||||
# update can pull again
|
||||
|
||||
deployed_usage() {
|
||||
cat << EOF
|
||||
Usage: coredesktop-dev deployed [status|restore]
|
||||
|
||||
status Branch, commit, how far behind upstream, and local changes (default)
|
||||
restore Undo local changes to tracked files, after asking. Untracked files
|
||||
are listed and left alone.
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd_deployed() {
|
||||
case "${1:-status}" in
|
||||
-h | --help) deployed_usage; return 0 ;;
|
||||
status | restore) ;;
|
||||
*) deployed_usage >&2; return 1 ;;
|
||||
esac
|
||||
git -C "$COREDESKTOP" rev-parse --git-dir &> /dev/null \
|
||||
|| die "CoreDesktop isn't installed in $COREDESKTOP."
|
||||
|
||||
"deployed_${1:-status}"
|
||||
}
|
||||
|
||||
deployed_status() {
|
||||
local behind changes
|
||||
echo "Deployed copy: $COREDESKTOP"
|
||||
echo "Branch: $(git -C "$COREDESKTOP" rev-parse --abbrev-ref HEAD)"
|
||||
echo "Commit: $(git -C "$COREDESKTOP" log -1 --format='%cd (%h) %s' --date=format:%Y.%m.%d)"
|
||||
|
||||
if GIT_TERMINAL_PROMPT=0 timeout 30 git -C "$COREDESKTOP" fetch --quiet &> /dev/null \
|
||||
&& behind="$(git -C "$COREDESKTOP" rev-list --count 'HEAD..@{upstream}' 2> /dev/null)"; then
|
||||
echo "Behind: $behind commits (update pulls them)"
|
||||
else
|
||||
echo "Behind: unknown (couldn't reach upstream)"
|
||||
fi
|
||||
|
||||
changes="$(git -C "$COREDESKTOP" status --porcelain --untracked-files=no)"
|
||||
if [ -z "$changes" ]; then
|
||||
echo "Changes: none"
|
||||
else
|
||||
echo "Changes: these stop update from pulling (coredesktop-dev deployed restore):"
|
||||
echo "$changes" | sed 's/^/ /'
|
||||
fi
|
||||
}
|
||||
|
||||
deployed_restore() {
|
||||
local untracked
|
||||
if [ -z "$(git -C "$COREDESKTOP" status --porcelain --untracked-files=no)" ]; then
|
||||
echo "The deployed copy has no local changes."
|
||||
return 0
|
||||
fi
|
||||
|
||||
git -C "$COREDESKTOP" diff HEAD --stat
|
||||
untracked="$(git -C "$COREDESKTOP" ls-files --others --exclude-standard)"
|
||||
if [ -n "$untracked" ]; then
|
||||
echo
|
||||
echo "Untracked files, which are left alone:"
|
||||
echo "$untracked" | sed 's/^/ /'
|
||||
fi
|
||||
echo
|
||||
|
||||
if ! confirm "Undo the changes above in $COREDESKTOP?"; then
|
||||
echo "Nothing changed."
|
||||
return 1
|
||||
fi
|
||||
git -C "$COREDESKTOP" checkout HEAD -- . || return 1
|
||||
|
||||
if [ -n "$(git -C "$COREDESKTOP" status --porcelain --untracked-files=no)" ]; then
|
||||
echo "Restored, but some changes remain (new files added to git's index?):"
|
||||
git -C "$COREDESKTOP" status --short --untracked-files=no
|
||||
return 1
|
||||
fi
|
||||
echo "Restored. update can pull again."
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Purpose : coredesktop-dev doctor: check this machine is set up to work on
|
||||
# CoreDesktop. Read-only.
|
||||
|
||||
doctor_usage() {
|
||||
cat << EOF
|
||||
Usage: coredesktop-dev doctor
|
||||
|
||||
Checks the tools are installed, the working copy is set up, and the deployed
|
||||
copy (~/.local/share/coredesktop) can still update. Exits 1 if anything needs
|
||||
fixing.
|
||||
EOF
|
||||
}
|
||||
|
||||
PROBLEMS=0
|
||||
|
||||
ok() {
|
||||
echo "ok $1"
|
||||
}
|
||||
|
||||
problem() {
|
||||
echo "PROBLEM $1"
|
||||
shift
|
||||
local line
|
||||
for line in "$@"; do
|
||||
echo " $line"
|
||||
done
|
||||
PROBLEMS=1
|
||||
}
|
||||
|
||||
note() {
|
||||
echo "note $1"
|
||||
}
|
||||
|
||||
cmd_doctor() {
|
||||
case "${1:-}" in
|
||||
-h | --help) doctor_usage; return 0 ;;
|
||||
"") ;;
|
||||
*) doctor_usage >&2; return 1 ;;
|
||||
esac
|
||||
|
||||
local tool missing=()
|
||||
for tool in shellcheck bats tea qemu-system-x86_64 qemu-img xorriso; do
|
||||
command -v "$tool" &> /dev/null || missing+=("$tool")
|
||||
done
|
||||
if (( ${#missing[@]} == 0 )); then
|
||||
ok "shellcheck, bats, tea, QEMU and xorriso are installed"
|
||||
else
|
||||
problem "not installed: ${missing[*]}" "fix: bash $(sibling_dir)/update.sh"
|
||||
fi
|
||||
|
||||
if [ -r /dev/kvm ] && [ -w /dev/kvm ]; then
|
||||
ok "KVM is available for the smoketest's virtual machines"
|
||||
else
|
||||
problem "can't use /dev/kvm, so the smoketest can't run VMs" \
|
||||
"fix: turn on virtualization (VT-x/AMD-V) in the firmware settings"
|
||||
fi
|
||||
|
||||
load_config
|
||||
if [ -z "$WORKING_COPY" ]; then
|
||||
problem "no working copy is set" "fix: bash $(sibling_dir)/install.sh"
|
||||
elif is_coredesktop_clone "$WORKING_COPY"; then
|
||||
ok "working copy: $WORKING_COPY"
|
||||
else
|
||||
problem "the working copy, $WORKING_COPY, isn't a clone of CoreDesktop" \
|
||||
"fix: bash $(sibling_dir)/install.sh"
|
||||
fi
|
||||
|
||||
doctor_deployed
|
||||
|
||||
if [ -n "${COREDESKTOP_DEV_VIA_FLATPAK:-}" ] || in_flatpak; then
|
||||
note "you ran this in a Flatpak, which has no pacman, systemctl or host /tmp;
|
||||
coredesktop-dev runs itself on the host for you (flatpak-spawn --host)"
|
||||
fi
|
||||
|
||||
if [ -n "$WORKING_COPY" ] && hook_is_ours "$(hook_path)"; then
|
||||
ok "the pre-push hook is installed"
|
||||
else
|
||||
note "the pre-push hook isn't installed (coredesktop-dev hooks install)"
|
||||
fi
|
||||
|
||||
return $PROBLEMS
|
||||
}
|
||||
|
||||
doctor_deployed() {
|
||||
if ! git -C "$COREDESKTOP" rev-parse --git-dir &> /dev/null; then
|
||||
problem "CoreDesktop isn't installed in $COREDESKTOP"
|
||||
return
|
||||
fi
|
||||
|
||||
local changes
|
||||
changes="$(git -C "$COREDESKTOP" status --porcelain --untracked-files=no)"
|
||||
if [ -z "$changes" ]; then
|
||||
ok "the deployed copy has no local changes"
|
||||
else
|
||||
problem "the deployed copy has local changes, so update can't pull:" \
|
||||
"${changes//$'\n'/$'\n' }" \
|
||||
"fix: coredesktop-dev deployed restore"
|
||||
fi
|
||||
|
||||
if ! GIT_TERMINAL_PROMPT=0 timeout 30 git -C "$COREDESKTOP" fetch --quiet &> /dev/null; then
|
||||
problem "couldn't reach the deployed copy's upstream to check it can update"
|
||||
elif git -C "$COREDESKTOP" merge-base --is-ancestor HEAD '@{upstream}' 2> /dev/null; then
|
||||
ok "the deployed copy can fast-forward to its upstream"
|
||||
else
|
||||
problem "the deployed copy has commits its upstream doesn't, so update's pull will fail" \
|
||||
"see: git -C $COREDESKTOP log @{upstream}..HEAD"
|
||||
fi
|
||||
}
|
||||
|
||||
# hooks.sh has hook_path and hook_is_ours
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/hooks.sh"
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Purpose : coredesktop-dev hooks: install or remove the working copy's pre-push
|
||||
# hook, which runs coredesktop-dev check before each push
|
||||
|
||||
hooks_usage() {
|
||||
cat << EOF
|
||||
Usage: coredesktop-dev hooks [status|install|remove]
|
||||
|
||||
status Show whether the pre-push hook is installed (the default)
|
||||
install Run coredesktop-dev check before each git push from the working copy
|
||||
remove Remove that hook
|
||||
|
||||
git push --no-verify skips the hook for one push. A pre-push hook that
|
||||
coredesktop-dev didn't install is never replaced or removed.
|
||||
EOF
|
||||
}
|
||||
|
||||
# Where git looks for the working copy's pre-push hook (core.hooksPath aware)
|
||||
hook_path() {
|
||||
local path
|
||||
path="$(git -C "$WORKING_COPY" rev-parse --git-path hooks/pre-push)" || return 1
|
||||
[[ "$path" == /* ]] || path="$WORKING_COPY/$path"
|
||||
echo "$path"
|
||||
}
|
||||
|
||||
hook_is_ours() {
|
||||
[ -f "$1" ] && grep -qxF "$HOOK_MARKER" "$1"
|
||||
}
|
||||
|
||||
cmd_hooks() {
|
||||
local hook
|
||||
case "${1:-status}" in
|
||||
-h | --help) hooks_usage; return 0 ;;
|
||||
status | install | remove) ;;
|
||||
*) hooks_usage >&2; return 1 ;;
|
||||
esac
|
||||
require_working_copy
|
||||
hook="$(hook_path)" || die "Couldn't find the working copy's git hooks folder."
|
||||
|
||||
case "${1:-status}" in
|
||||
status)
|
||||
if hook_is_ours "$hook"; then
|
||||
echo "The pre-push hook is installed: $hook"
|
||||
elif [ -e "$hook" ]; then
|
||||
echo "$hook is someone else's hook; coredesktop-dev leaves it alone."
|
||||
else
|
||||
echo "The pre-push hook isn't installed. To install it: coredesktop-dev hooks install"
|
||||
fi
|
||||
;;
|
||||
install)
|
||||
if [ -e "$hook" ] && ! hook_is_ours "$hook"; then
|
||||
die "$hook already exists and coredesktop-dev didn't write it, so it's left alone."
|
||||
fi
|
||||
mkdir -p "$(dirname "$hook")"
|
||||
install -m 755 "$(sibling_dir)/hooks/pre-push" "$hook"
|
||||
echo "Installed $hook: each git push now runs coredesktop-dev check first."
|
||||
;;
|
||||
remove)
|
||||
if hook_is_ours "$hook"; then
|
||||
rm "$hook"
|
||||
echo "Removed $hook."
|
||||
elif [ -e "$hook" ]; then
|
||||
die "$hook isn't coredesktop-dev's, so it's left alone."
|
||||
else
|
||||
echo "The pre-push hook isn't installed."
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
#!/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 <command>
|
||||
|
||||
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
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Purpose : coredesktop-dev try: run the working copy's code as if it were the
|
||||
# deployed CoreDesktop, in a throwaway home with its own session
|
||||
|
||||
try_usage() {
|
||||
cat << EOF
|
||||
Usage: coredesktop-dev try [--keep] [command...]
|
||||
|
||||
Runs command (or a shell) with a throwaway HOME where
|
||||
~/.local/share/coredesktop is the working copy, and update, configure, version
|
||||
etc. in ~/.local/bin are the working copy's. It gets its own XDG_RUNTIME_DIR
|
||||
and D-Bus session, and no display, so it can't touch your desktop session.
|
||||
|
||||
sudo, yay, pacman and systemctl still change this machine.
|
||||
|
||||
--keep Keep the throwaway folder afterwards, and print where it is
|
||||
|
||||
Example: coredesktop-dev try configure --list
|
||||
EOF
|
||||
}
|
||||
|
||||
TRY_COMMANDS=(update refresh sibling version configure audit)
|
||||
|
||||
cmd_try() {
|
||||
local keep=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h | --help) try_usage; return 0 ;;
|
||||
--keep) keep=1; shift ;;
|
||||
--) shift; break ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
require_working_copy
|
||||
|
||||
local tmp home runtime cmd status
|
||||
tmp="$(mktemp -d "${TMPDIR:-/tmp}/coredesktop-dev-try.XXXXXX")" || return 1
|
||||
home="$tmp/home"
|
||||
runtime="$tmp/runtime"
|
||||
mkdir -p "$home/.local/share" "$home/.local/bin" "$home/.local/state" \
|
||||
"$home/.config" "$home/.cache"
|
||||
mkdir -m 700 "$runtime"
|
||||
|
||||
ln -s "$WORKING_COPY" "$home/.local/share/coredesktop"
|
||||
for cmd in "${TRY_COMMANDS[@]}"; do
|
||||
ln -s "$WORKING_COPY/$cmd" "$home/.local/bin/$cmd"
|
||||
done
|
||||
|
||||
echo "coredesktop-dev try: HOME is $home, with the working copy as CoreDesktop." >&2
|
||||
echo "Careful: sudo, yay, pacman and systemctl still change this machine." >&2
|
||||
|
||||
(( $# > 0 )) || set -- bash
|
||||
env -u DISPLAY -u WAYLAND_DISPLAY -u DBUS_SESSION_BUS_ADDRESS -u SSH_AUTH_SOCK \
|
||||
HOME="$home" \
|
||||
XDG_CONFIG_HOME="$home/.config" \
|
||||
XDG_DATA_HOME="$home/.local/share" \
|
||||
XDG_STATE_HOME="$home/.local/state" \
|
||||
XDG_CACHE_HOME="$home/.cache" \
|
||||
XDG_RUNTIME_DIR="$runtime" \
|
||||
PATH="$home/.local/bin:$PATH" \
|
||||
dbus-run-session -- "$@"
|
||||
status=$?
|
||||
|
||||
if (( keep )); then
|
||||
echo "Kept $tmp" >&2
|
||||
else
|
||||
rm -rf "$tmp"
|
||||
fi
|
||||
return $status
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Purpose : Start, reach and stop the smoketest's QEMU virtual machines. Defines
|
||||
# what CoreDesktop's smoketest/lib/scenario.sh expects of a driver:
|
||||
# vm_exec, vm_put, vm_reboot, VM_GIT_SSH and VM_GIT_URL.
|
||||
|
||||
SMOKETEST_STATE="${XDG_CACHE_HOME:-$HOME/.cache}/coredesktop-dev/smoketest"
|
||||
IMAGES="$SMOKETEST_STATE/images"
|
||||
# Shared with every VM as its /var/cache/pacman/pkg, so packages download once
|
||||
PKG_CACHE="$SMOKETEST_STATE/pkg"
|
||||
SSH_KEY="$SMOKETEST_STATE/id_ed25519"
|
||||
KVM_DEVICE="${KVM_DEVICE:-/dev/kvm}"
|
||||
|
||||
VM_USER=tester
|
||||
VM_MEMORY="${SMOKETEST_MEMORY:-8G}"
|
||||
VM_CPUS="${SMOKETEST_CPUS:-4}"
|
||||
# Longest a single command in the VM may run (an install takes an hour or more)
|
||||
VM_EXEC_TIMEOUT="${SMOKETEST_EXEC_TIMEOUT:-4h}"
|
||||
# Seconds to wait for the VM to answer on SSH, and for GNOME to start
|
||||
VM_SSH_TIMEOUT="${SMOKETEST_SSH_TIMEOUT:-900}"
|
||||
VM_DESKTOP_TIMEOUT="${SMOKETEST_DESKTOP_TIMEOUT:-300}"
|
||||
|
||||
SSH_OPTS=(
|
||||
-i "$SSH_KEY"
|
||||
-o IdentitiesOnly=yes
|
||||
-o StrictHostKeyChecking=no
|
||||
-o UserKnownHostsFile=/dev/null
|
||||
-o LogLevel=ERROR
|
||||
-o BatchMode=yes
|
||||
-o ConnectTimeout=10
|
||||
-o ServerAliveInterval=15
|
||||
-o ServerAliveCountMax=8
|
||||
)
|
||||
|
||||
# Commands in the VM run as they would in a GNOME Terminal: with the GNOME
|
||||
# session's environment, in an interactive bash that has read ~/.bashrc
|
||||
VM_SESSION_ENV='eval "$(systemctl --user show-environment 2> /dev/null | sed "s/^/export /")"
|
||||
export TERM="${TERM:-dumb}"'
|
||||
|
||||
ensure_ssh_key() {
|
||||
mkdir -p "$SMOKETEST_STATE"
|
||||
[ -f "$SSH_KEY" ] || ssh-keygen -q -t ed25519 -N '' -C coredesktop-smoketest -f "$SSH_KEY"
|
||||
}
|
||||
|
||||
free_port() {
|
||||
local port i
|
||||
for i in $(seq 50); do
|
||||
port=$((20000 + RANDOM % 20000))
|
||||
[ -z "$(ss -Hltn "( sport = :$port )" 2> /dev/null)" ] && { echo "$port"; return 0; }
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# vm_start DIR DISK [QEMU ARGS...]: boot DISK in the background. DIR gets the
|
||||
# pid file and the serial console's log.
|
||||
vm_start() {
|
||||
VM_DIR="$1"
|
||||
local disk="$2"
|
||||
shift 2
|
||||
local display=(-display none)
|
||||
[ -n "${SMOKETEST_VNC:-}" ] && display+=(-vnc "127.0.0.1:$SMOKETEST_VNC")
|
||||
|
||||
ensure_ssh_key
|
||||
mkdir -p "$VM_DIR" "$PKG_CACHE"
|
||||
VM_PORT="$(free_port)" || die "Couldn't find a free port for the VM's SSH."
|
||||
|
||||
qemu-system-x86_64 \
|
||||
-name coredesktop-smoketest \
|
||||
-machine q35,accel=kvm \
|
||||
-cpu host \
|
||||
-smp "$VM_CPUS" \
|
||||
-m "$VM_MEMORY" \
|
||||
-drive "file=$disk,if=virtio,format=qcow2,discard=unmap" \
|
||||
-nic "user,model=virtio-net-pci,hostfwd=tcp:127.0.0.1:$VM_PORT-:22" \
|
||||
-virtfs "local,path=$PKG_CACHE,mount_tag=pkgcache,security_model=mapped-xattr" \
|
||||
-vga virtio \
|
||||
"${display[@]}" \
|
||||
-serial "file:$VM_DIR/serial.log" \
|
||||
-pidfile "$VM_DIR/qemu.pid" \
|
||||
-daemonize \
|
||||
"$@" || die "QEMU didn't start."
|
||||
|
||||
VM_GIT_SSH="ssh$(printf ' %q' "${SSH_OPTS[@]}")"
|
||||
VM_GIT_URL="ssh://$VM_USER@127.0.0.1:$VM_PORT"
|
||||
export VM_GIT_SSH VM_GIT_URL
|
||||
}
|
||||
|
||||
vm_pid() {
|
||||
cat "$VM_DIR/qemu.pid" 2> /dev/null
|
||||
}
|
||||
|
||||
vm_running() {
|
||||
local pid
|
||||
pid="$(vm_pid)" && [ -n "$pid" ] && kill -0 "$pid" 2> /dev/null
|
||||
}
|
||||
|
||||
vm_ssh() {
|
||||
ssh "${SSH_OPTS[@]}" -p "$VM_PORT" "$VM_USER@127.0.0.1" "$@"
|
||||
}
|
||||
|
||||
# The command to SSH in by hand
|
||||
vm_ssh_command() {
|
||||
echo "ssh -i $SSH_KEY -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $VM_PORT $VM_USER@127.0.0.1"
|
||||
}
|
||||
|
||||
wait_for_ssh() {
|
||||
local deadline=$((SECONDS + VM_SSH_TIMEOUT))
|
||||
until vm_ssh true < /dev/null 2> /dev/null; do
|
||||
vm_running || die "The VM stopped. Its console log: $VM_DIR/serial.log"
|
||||
(( SECONDS < deadline )) || die "The VM didn't answer on SSH. Its console log: $VM_DIR/serial.log"
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
|
||||
# Wait for the automatic GNOME login. Not fatal: the health checks catch a
|
||||
# desktop that didn't start.
|
||||
wait_for_desktop() {
|
||||
local deadline=$((SECONDS + VM_DESKTOP_TIMEOUT))
|
||||
until vm_ssh "pgrep -u $VM_USER -x gnome-shell" < /dev/null &> /dev/null; do
|
||||
if (( SECONDS >= deadline )); then
|
||||
echo "Warning: GNOME didn't start in the VM within ${VM_DESKTOP_TIMEOUT}s."
|
||||
return 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
|
||||
vm_wait_up() {
|
||||
wait_for_ssh
|
||||
wait_for_desktop
|
||||
}
|
||||
|
||||
# vm_exec SCRIPT: run SCRIPT with bash in the VM, as tester, in the GNOME
|
||||
# session's environment. Interactive bash's complaints about having no
|
||||
# terminal are dropped.
|
||||
vm_exec() {
|
||||
timeout "$VM_EXEC_TIMEOUT" ssh "${SSH_OPTS[@]}" -p "$VM_PORT" -T "$VM_USER@127.0.0.1" \
|
||||
"bash -ic $(printf '%q' "$VM_SESSION_ENV
|
||||
$1")" < /dev/null \
|
||||
2> >(grep -v -e '^bash: cannot set terminal process group' -e '^bash: no job control in this shell' >&2)
|
||||
}
|
||||
|
||||
# vm_put LOCAL REMOTE
|
||||
vm_put() {
|
||||
scp -q "${SSH_OPTS[@]}" -P "$VM_PORT" "$1" "$VM_USER@127.0.0.1:$2"
|
||||
}
|
||||
|
||||
vm_boot_id() {
|
||||
vm_ssh 'cat /proc/sys/kernel/random/boot_id' < /dev/null 2> /dev/null
|
||||
}
|
||||
|
||||
vm_reboot() {
|
||||
local before deadline=$((SECONDS + VM_SSH_TIMEOUT))
|
||||
echo "== reboot"
|
||||
before="$(vm_boot_id)"
|
||||
vm_ssh 'sudo systemctl reboot' < /dev/null &> /dev/null || true
|
||||
until [ "$(vm_boot_id)" != "$before" ] && [ -n "$(vm_boot_id)" ]; do
|
||||
vm_running || die "The VM stopped while rebooting. Its console log: $VM_DIR/serial.log"
|
||||
(( SECONDS < deadline )) || die "The VM didn't come back after rebooting. Its console log: $VM_DIR/serial.log"
|
||||
sleep 5
|
||||
done
|
||||
wait_for_desktop
|
||||
}
|
||||
|
||||
# Shut the VM down cleanly (so its disk can be kept), killing it if it hangs
|
||||
vm_stop() {
|
||||
vm_running || return 0
|
||||
vm_ssh 'sudo systemctl poweroff' < /dev/null &> /dev/null || true
|
||||
local deadline=$((SECONDS + 120))
|
||||
while vm_running && (( SECONDS < deadline )); do
|
||||
sleep 2
|
||||
done
|
||||
vm_kill
|
||||
}
|
||||
|
||||
vm_kill() {
|
||||
local pid
|
||||
pid="$(vm_pid)" || return 0
|
||||
[ -n "$pid" ] && kill "$pid" 2> /dev/null
|
||||
rm -f "$VM_DIR/qemu.pid"
|
||||
return 0
|
||||
}
|
||||
Reference in New Issue
Block a user