fix(installer): bound native Go build and preserve dirty clones

Refs #371

Add stage-specific watchdogs for module graph validation and Go builds, and make native source branch recovery non-destructive. Add regression coverage for timeout propagation, dirty clones, runtime data preservation, and clone failure recovery.

Tests: Git Bash bash -n; scripts/test-native-installer.sh; installer-focused npm tests (4 suites, 33 tests); IDE lints.

Thanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow)
This commit is contained in:
UNITRONIX
2026-09-26 02:27:32 +02:00
parent 5176f3a531
commit ebcebdf41a
4 changed files with 365 additions and 24 deletions
+4
View File
@@ -63,6 +63,7 @@ jobs:
install.sh \
betterdesk.sh \
betterdesk-docker.sh \
scripts/test-native-installer.sh \
scripts/test-betterdesk-node-install.sh \
scripts/test-postgresql-hba.sh)
- name: Run ShellCheck
@@ -71,10 +72,13 @@ jobs:
install.sh \
betterdesk.sh \
betterdesk-docker.sh \
scripts/test-native-installer.sh \
scripts/test-betterdesk-node-install.sh \
scripts/test-postgresql-hba.sh
- name: Test native Node.js installer recovery
run: bash scripts/test-betterdesk-node-install.sh
- name: Test native installer watchdog and clone recovery
run: bash scripts/test-native-installer.sh
- name: Test PostgreSQL pg_hba.conf setup
run: bash scripts/test-postgresql-hba.sh
+137 -14
View File
@@ -191,6 +191,13 @@ GO_MIN_VERSION="1.26.6"
GO_DOWNLOAD_VERSION="1.26.6"
# Maximum time allowed for the first module download on a native install.
GO_MODULE_DOWNLOAD_TIMEOUT="${GO_MODULE_DOWNLOAD_TIMEOUT:-600}"
# Maximum time allowed to validate the downloaded module graph.
GO_MODULE_GRAPH_TIMEOUT="${GO_MODULE_GRAPH_TIMEOUT:-120}"
# Maximum time allowed to compile the Go server.
GO_BUILD_TIMEOUT="${GO_BUILD_TIMEOUT:-1800}"
# Heartbeat/deadline settings for bounded Go commands.
GO_COMMAND_HEARTBEAT_INTERVAL="${GO_COMMAND_HEARTBEAT_INTERVAL:-15}"
GO_COMMAND_KILL_AFTER="${GO_COMMAND_KILL_AFTER:-15}"
# Short HTTPS probe before go mod download (fail fast on blocked proxy/DNS).
GO_MODULE_PREFLIGHT_TIMEOUT="${GO_MODULE_PREFLIGHT_TIMEOUT:-20}"
# Set when preflight detects IPv4 OK but IPv6 broken (common on GCP VMs).
@@ -3106,7 +3113,94 @@ _go_kill_process_group() {
fi
}
# Run go mod download with a hard bash process-group deadline (primary guard).
# Run a Go command with a hard deadline and visible progress.
_run_go_command_bounded() {
local label="$1"
local deadline="$2"
shift 2
local command_pid=""
local command_pgid=""
local heartbeat_pid=""
local elapsed=0
local status=0
local heartbeat_interval="${GO_COMMAND_HEARTBEAT_INTERVAL}"
local kill_after="${GO_COMMAND_KILL_AFTER}"
case "$heartbeat_interval" in
''|*[!0-9]*) heartbeat_interval=15 ;;
esac
case "$kill_after" in
''|*[!0-9]*) kill_after=15 ;;
esac
if command -v setsid &> /dev/null; then
setsid "$@" &
else
"$@" &
fi
command_pid=$!
if command -v ps &> /dev/null; then
command_pgid=$(ps -o pgid= -p "$command_pid" 2>/dev/null | tr -d ' ' || true)
case "$command_pgid" in
''|*[!0-9]*) command_pgid="" ;;
esac
fi
print_info "${label} started (PID ${command_pid}, deadline ${deadline}s)"
(
heartbeat_elapsed=0
while kill -0 "$command_pid" 2>/dev/null; do
sleep "$heartbeat_interval"
if ! kill -0 "$command_pid" 2>/dev/null; then
break
fi
heartbeat_elapsed=$((heartbeat_elapsed + heartbeat_interval))
printf 'ℹ still %s... %ss elapsed (watchdog active)\n' \
"$label" "$heartbeat_elapsed" >&2
done
) &
heartbeat_pid=$!
while kill -0 "$command_pid" 2>/dev/null; do
if [ "$elapsed" -ge "$deadline" ]; then
print_error "${label} exceeded ${deadline}s"
if [ -n "$command_pgid" ] && [ "$command_pgid" -gt 1 ] 2>/dev/null; then
_go_kill_process_group "$command_pgid" "$label"
else
kill -TERM "$command_pid" 2>/dev/null || true
sleep "$kill_after"
if kill -0 "$command_pid" 2>/dev/null; then
kill -KILL "$command_pid" 2>/dev/null || true
fi
fi
status=124
break
fi
sleep 1
elapsed=$((elapsed + 1))
done
if [ "$status" -ne 124 ]; then
if wait "$command_pid"; then
status=0
else
status=$?
fi
else
wait "$command_pid" 2>/dev/null || true
fi
if [ -n "$heartbeat_pid" ]; then
kill "$heartbeat_pid" 2>/dev/null || true
wait "$heartbeat_pid" 2>/dev/null || true
fi
return "$status"
}
# Run go mod download with a hard bash deadline (primary guard).
# GNU timeout alone was insufficient on some cloud VMs (#371).
_run_go_mod_download_bounded() {
local deadline="${GO_MODULE_DOWNLOAD_TIMEOUT}"
@@ -3175,14 +3269,6 @@ _run_go_mod_download_bounded() {
wait "$heartbeat_pid" 2>/dev/null || true
fi
# Keep IPv6 disabled until we confirm the module graph is readable.
if [ "$status" -eq 0 ]; then
if ! go list -m all >/dev/null 2>&1; then
print_error "go mod download reported success but 'go list -m all' failed"
status=1
fi
fi
_go_ipv4_force_end
return "$status"
}
@@ -3230,8 +3316,8 @@ compile_go_server() {
return 1
fi
# Download dependencies. Keep this visible and hard-bounded (bash PGID watchdog).
print_info "Downloading Go modules (timeout: ${GO_MODULE_DOWNLOAD_TIMEOUT}s, kill-after: 15s, setsid watchdog)..."
# Download dependencies. Keep this visible and hard-bounded (bash watchdog).
print_info "Downloading Go modules (timeout: ${GO_MODULE_DOWNLOAD_TIMEOUT}s, kill-after: 15s, process watchdog)..."
local module_download_status=0
# Keep the command in the if condition so `set -e` does not abort before
# we can capture the actual watchdog/download exit status. Do not use `!`
@@ -3252,9 +3338,43 @@ compile_go_server() {
print_error "On GCP/cloud VMs with broken IPv6, confirm IPv4 works: curl -4 -I https://proxy.golang.org/"
return 1
fi
print_info "Go module download completed; validating module graph (timeout: ${GO_MODULE_GRAPH_TIMEOUT}s)..."
local module_graph_status=0
if _run_go_command_bounded "validating Go module graph" "$GO_MODULE_GRAPH_TIMEOUT" go list -m all; then
module_graph_status=0
else
module_graph_status=$?
fi
if [ "$module_graph_status" -ne 0 ]; then
if [ "$module_graph_status" -eq 124 ] || [ "$module_graph_status" -eq 137 ]; then
print_error "Go module graph validation timed out after ${GO_MODULE_GRAPH_TIMEOUT}s"
else
print_error "Go module graph validation failed (exit code ${module_graph_status})"
fi
print_error "The module cache may be incomplete; retry the native installation after checking GOPROXY and disk space"
return 1
fi
print_success "Go module graph validated"
# Build with optimizations
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=${VERSION}" -o "$output_name" .
print_info "Building BetterDesk server (timeout: ${GO_BUILD_TIMEOUT}s)..."
local build_status=0
if _run_go_command_bounded "building Go server" "$GO_BUILD_TIMEOUT" \
env CGO_ENABLED=0 "$go_bin" build \
-ldflags="-s -w -X main.Version=${VERSION}" -o "$output_name" .; then
build_status=0
else
build_status=$?
fi
if [ "$build_status" -ne 0 ]; then
if [ "$build_status" -eq 124 ] || [ "$build_status" -eq 137 ]; then
print_error "Go server build timed out after ${GO_BUILD_TIMEOUT}s"
else
print_error "Go server build failed (exit code ${build_status})"
fi
return 1
fi
if [ -f "$output_name" ]; then
chmod +x "$output_name"
@@ -8369,5 +8489,8 @@ main() {
done
}
# Run
main "$@"
# Run only when executed directly; this also lets installer tests source the
# bounded command helpers without starting the interactive manager.
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi
+96 -10
View File
@@ -550,10 +550,70 @@ uninstall_docker_mode() {
# ── Native install ────────────────────────────────────────────────────────────
native_backup_source() {
local source_dir="$1"
local backup_dir="${source_dir}.backup.$(date +%Y%m%d%H%M%S).$$"
while [ -e "$backup_dir" ]; do
backup_dir="${source_dir}.backup.$(date +%Y%m%d%H%M%S).${RANDOM}"
done
if ! mv "$source_dir" "$backup_dir"; then
print_error "Could not preserve existing native source at ${source_dir}"
return 1
fi
printf '%s\n' "$backup_dir"
}
native_clone_source() {
local repo_dir="$1"
local backup_dir="${2:-}"
local staging_parent
local staging_repo
mkdir -p "$INSTALL_DIR"
staging_parent=$(mktemp -d "${INSTALL_DIR}/.source-clone.XXXXXX")
staging_repo="${staging_parent}/source"
if ! git clone --depth 1 --branch "$BETTERDESK_BRANCH" \
"https://github.com/${BETTERDESK_REPO}.git" "$staging_repo"; then
rm -rf "$staging_parent"
if [ -n "$backup_dir" ] && [ ! -e "$repo_dir" ]; then
mv "$backup_dir" "$repo_dir" 2>/dev/null || true
fi
return 1
fi
# The native update path keeps local Go runtime data beside the source.
# Carry it forward without replacing anything created by the fresh clone.
if [ -n "$backup_dir" ] \
&& [ -d "$backup_dir/betterdesk-server/data" ] \
&& [ ! -e "$staging_repo/betterdesk-server/data" ]; then
if ! cp -a "$backup_dir/betterdesk-server/data" "$staging_repo/betterdesk-server/"; then
rm -rf "$staging_parent"
if [ -n "$backup_dir" ]; then
mv "$backup_dir" "$repo_dir" 2>/dev/null || true
fi
return 1
fi
fi
if ! mv "$staging_repo" "$repo_dir"; then
rm -rf "$staging_parent"
if [ -n "$backup_dir" ]; then
mv "$backup_dir" "$repo_dir" 2>/dev/null || true
fi
return 1
fi
rm -rf "$staging_parent"
return 0
}
install_native_mode() {
local repo_dir="${INSTALL_DIR}/source"
local relay
local commit_sha
local source_backup=""
local checkout_error=""
log "BetterDesk native installer v${VERSION}"
log "Native install branch: ${BETTERDESK_BRANCH} (override with --branch)"
@@ -565,18 +625,42 @@ install_native_mode() {
log "Updating existing clone in ${repo_dir} to ${BETTERDESK_BRANCH}..."
git -C "$repo_dir" remote set-url origin "https://github.com/${BETTERDESK_REPO}.git" || true
git -C "$repo_dir" fetch --depth 1 origin "$BETTERDESK_BRANCH"
# Shallow clones previously pinned to another branch need a hard reset.
if ! git -C "$repo_dir" checkout -B "$BETTERDESK_BRANCH" "FETCH_HEAD"; then
warn "Could not switch existing clone to ${BETTERDESK_BRANCH}; recloning..."
rm -rf "$repo_dir"
git clone --depth 1 --branch "$BETTERDESK_BRANCH" \
"https://github.com/${BETTERDESK_REPO}.git" "$repo_dir"
# Shallow clones previously pinned to another branch need a branch switch.
checkout_error=$(mktemp)
if git -C "$repo_dir" checkout -B "$BETTERDESK_BRANCH" "FETCH_HEAD" 2>"$checkout_error"; then
rm -f "$checkout_error"
else
warn "Could not switch existing clone to ${BETTERDESK_BRANCH}."
if [ -n "$(git -C "$repo_dir" status --short 2>/dev/null || true)" ]; then
warn "The existing clone contains local changes; preserving it before recloning."
cat "$checkout_error" >&2 || true
else
warn "Git checkout failed; preserving the existing clone before recloning."
cat "$checkout_error" >&2 || true
fi
rm -f "$checkout_error"
if ! source_backup=$(native_backup_source "$repo_dir"); then
die "Cannot preserve ${repo_dir}; refusing to remove the existing native source"
fi
if ! native_clone_source "$repo_dir" "$source_backup"; then
die "Could not clone ${BETTERDESK_REPO}@${BETTERDESK_BRANCH}; original source preserved at ${source_backup}"
fi
warn "Previous native source preserved at ${source_backup}"
fi
else
log "Cloning ${BETTERDESK_REPO} (${BETTERDESK_BRANCH})..."
rm -rf "$repo_dir"
git clone --depth 1 --branch "$BETTERDESK_BRANCH" \
"https://github.com/${BETTERDESK_REPO}.git" "$repo_dir"
if [ -e "$repo_dir" ]; then
if ! source_backup=$(native_backup_source "$repo_dir"); then
die "Cannot preserve ${repo_dir}; refusing to remove the existing native source"
fi
warn "Existing non-Git source preserved at ${source_backup}"
fi
if ! native_clone_source "$repo_dir" "$source_backup"; then
die "Could not clone ${BETTERDESK_REPO}@${BETTERDESK_BRANCH}; original source preserved at ${source_backup:-none}"
fi
if [ -n "$source_backup" ]; then
warn "Previous native source preserved at ${source_backup}"
fi
fi
commit_sha=$(git -C "$repo_dir" rev-parse --short HEAD 2>/dev/null || echo "unknown")
@@ -699,4 +783,6 @@ main() {
esac
}
main "$@"
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
fail() {
printf 'FAIL: %s\n' "$*" >&2
exit 1
}
test_bounded_go_commands() (
export GO_COMMAND_HEARTBEAT_INTERVAL=1
export GO_COMMAND_KILL_AFTER=1
set --
# shellcheck disable=SC1091
source "$SCRIPT_ROOT/betterdesk.sh"
if ! _run_go_command_bounded "successful test command" 5 sh -c 'exit 0'; then
fail "successful bounded command returned non-zero"
fi
if _run_go_command_bounded "failed test command" 5 sh -c 'exit 7'; then
fail "failed bounded command returned success"
else
status=$?
fi
[ "$status" -eq 7 ] || fail "expected exit code 7, got ${status}"
if _run_go_command_bounded "timed test command" 1 sh -c 'sleep 10'; then
fail "timed bounded command returned success"
else
status=$?
fi
[ "$status" -eq 124 ] || fail "expected timeout code 124, got ${status}"
)
test_dirty_native_clone() (
local temp_dir fake_bin install_dir repo_dir
temp_dir=$(mktemp -d)
trap 'rm -rf "$temp_dir"' EXIT
fake_bin="$temp_dir/bin"
install_dir="$temp_dir/install"
repo_dir="$install_dir/source"
mkdir -p "$fake_bin" "$repo_dir/.git" "$repo_dir/betterdesk-server/data"
printf 'local edit\n' > "$repo_dir/betterdesk.sh"
printf 'runtime state\n' > "$repo_dir/betterdesk-server/data/state.txt"
cat > "$fake_bin/git" <<'FAKE_GIT'
#!/usr/bin/env bash
set -euo pipefail
if [ "${1:-}" = "-C" ]; then
shift 2
case "${1:-}" in
remote|fetch)
exit 0
;;
status)
printf ' M betterdesk.sh\n'
exit 0
;;
checkout)
printf 'error: local changes would be overwritten\n' >&2
exit 1
;;
rev-parse)
printf 'abc123\n'
exit 0
;;
esac
fi
if [ "${1:-}" = "clone" ]; then
if [ "${FAKE_GIT_FAIL_CLONE:-0}" = "1" ]; then
printf 'simulated clone failure\n' >&2
exit 1
fi
destination="${!#}"
mkdir -p "$destination/.git" "$destination/betterdesk-server"
printf '#!/usr/bin/env bash\nexit 0\n' > "$destination/betterdesk.sh"
exit 0
fi
printf 'unexpected fake git invocation: %s\n' "$*" >&2
exit 1
FAKE_GIT
chmod +x "$fake_bin/git"
export PATH="$fake_bin:$PATH"
export INSTALL_DIR="$install_dir"
export RELAY_SERVERS="127.0.0.1:21117"
export BETTERDESK_REPO="example/BetterDesk"
export BETTERDESK_BRANCH="dev"
set --
# shellcheck disable=SC1091
source "$SCRIPT_ROOT/install.sh"
install_native_mode
[ -d "$repo_dir/.git" ] || fail "fresh clone was not installed"
[ -f "$repo_dir/betterdesk-server/data/state.txt" ] \
|| fail "runtime Go data was not preserved"
compgen -G "$install_dir/source.backup.*" > /dev/null \
|| fail "dirty source backup was not preserved"
backup_dir=""
for path in "$install_dir"/source.backup.*; do
if [ -e "$path" ]; then
backup_dir="$path"
break
fi
done
[ -f "$backup_dir/betterdesk.sh" ] || fail "original dirty source was not backed up"
if (
export FAKE_GIT_FAIL_CLONE=1
set --
# shellcheck disable=SC1091
source "$SCRIPT_ROOT/install.sh"
install_native_mode
); then
fail "failed clone returned success"
fi
[ -d "$repo_dir/.git" ] || fail "original source was not restored after clone failure"
)
test_bounded_go_commands
test_dirty_native_clone
printf 'PASS: native installer regression tests\n'