diff --git a/.github/workflows/verify-split-files.yml b/.github/workflows/verify-split-files.yml index 9f4cb45..d5f18bb 100644 --- a/.github/workflows/verify-split-files.yml +++ b/.github/workflows/verify-split-files.yml @@ -33,6 +33,7 @@ on: - "tests/nodejs-key-refresh.sh" - "tests/forge-connection.sh" - "tests/failed-command-output.sh" + - "tests/exit-code-hints.sh" - "tests/forge-release-json.sh" - ".github/workflows/verify-split-files.yml" push: @@ -58,6 +59,7 @@ on: - "tests/nodejs-key-refresh.sh" - "tests/forge-connection.sh" - "tests/failed-command-output.sh" + - "tests/exit-code-hints.sh" - "tests/forge-release-json.sh" - ".github/workflows/verify-split-files.yml" workflow_dispatch: @@ -113,6 +115,9 @@ jobs: - name: Failed command output tests run: bash tests/failed-command-output.sh + - name: Exit code hint tests + run: bash tests/exit-code-hints.sh + - name: Forge release lookup tests run: bash tests/forge-release-json.sh diff --git a/api/exitcodes.func b/api/exitcodes.func index 8277532..f0b24bc 100644 --- a/api/exitcodes.func +++ b/api/exitcodes.func @@ -39,9 +39,9 @@ explain_exit_code() { # never reported success is someone backing out of a dialog. Every one of # those records read "Unknown error". 0) echo "Cancelled before completion (no error)" ;; - 1) echo "General error / Operation not permitted" ;; - 2) echo "Misuse of shell builtins (e.g. syntax error)" ;; - 3) echo "General syntax or argument error" ;; + 1) echo "The command failed; its output says why" ;; + 2) echo "The command failed with status 2 (a usage error for most tools); its output says why" ;; + 3) echo "The command failed with status 3; its output says why" ;; 10) echo "Docker / privileged mode required (unsupported environment)" ;; # --- curl / wget errors --- @@ -133,7 +133,7 @@ explain_exit_code() { 125) echo "Command failed to start (Docker daemon or execution error)" ;; 126) echo "Command invoked cannot execute (permission problem?)" ;; 127) echo "Command not found" ;; - 128) echo "Invalid argument to exit" ;; + 128) echo "git: Fatal error (repository, network or permission problem)" ;; 129) echo "Killed by SIGHUP (terminal closed / hangup)" ;; 130) echo "Aborted by user (SIGINT)" ;; 131) echo "Killed by SIGQUIT (core dumped)" ;; diff --git a/core/core.func b/core/core.func index 3b979c8..4e933d5 100644 --- a/core/core.func +++ b/core/core.func @@ -1871,13 +1871,18 @@ confirm_external_installer() { msg_warn "If you have any doubts or concerns, please review the installer code before proceeding:" msg_custom "${TAB3}${GATEWAY}${BGN}${CL}" "\e[1;34m" "→ ${installer}" echo + # 254 is the user declining; 10 would read as "needs privileged mode". if [[ -z "$answer" ]] && ! read -r -p "${TAB3}Do you want to continue? [y/N]: " answer; then msg_error "No terminal to confirm on. Set var_external_installer=yes to run it unattended." - exit 10 + exit 254 fi if [[ ! "${answer,,}" =~ ^(y|yes|true)$ ]]; then - msg_error "Aborted by user. No changes have been made." - exit 10 + if [[ -n "${var_external_installer:-}" ]]; then + msg_error "var_external_installer=${var_external_installer} declines the upstream installer, and this script has no other way to install. Set it to yes, or leave it unset to be asked." + else + msg_error "Aborted by user. No changes have been made." + fi + exit 254 fi } diff --git a/core/error_handler.func b/core/error_handler.func index 4eb7f4d..62363e8 100644 --- a/core/error_handler.func +++ b/core/error_handler.func @@ -39,9 +39,9 @@ if ! declare -f explain_exit_code &>/dev/null; then explain_exit_code() { local code="$1" case "$code" in - 1) echo "General error / Operation not permitted" ;; - 2) echo "Misuse of shell builtins (e.g. syntax error)" ;; - 3) echo "General syntax or argument error" ;; + 1) echo "The command failed; its output says why" ;; + 2) echo "The command failed with status 2 (a usage error for most tools); its output says why" ;; + 3) echo "The command failed with status 3; its output says why" ;; 10) echo "Docker / privileged mode required (unsupported environment)" ;; 4) echo "curl: Feature not supported or protocol error" ;; 5) echo "curl: Could not resolve proxy" ;; @@ -123,7 +123,7 @@ if ! declare -f explain_exit_code &>/dev/null; then 125) echo "Command failed to start (Docker daemon or execution error)" ;; 126) echo "Command invoked cannot execute (permission problem?)" ;; 127) echo "Command not found" ;; - 128) echo "Invalid argument to exit" ;; + 128) echo "git: Fatal error (repository, network or permission problem)" ;; 129) echo "Killed by SIGHUP (terminal closed / hangup)" ;; 130) echo "Aborted by user (SIGINT)" ;; 131) echo "Killed by SIGQUIT (core dumped)" ;; @@ -334,15 +334,39 @@ _eh_ok() { # readings are only right if curl is what actually ran, so they are dropped when # the failing command was something else. # ------------------------------------------------------------------------------ +# A tool-specific explanation only holds when the failing command is that tool: +# cargo's 101 is a panic, not an APT configuration error. _eh_explanation() { - local exit_code="$1" command="$2" explanation + local exit_code="$1" command="$2" explanation tools explanation="$(explain_exit_code "$exit_code")" - if [[ "$explanation" == curl:* && ! "$command" =~ (^|[[:space:]])([^[:space:]]*/)?curl([[:space:]]|$) ]]; then + tools="$(_eh_tools_for "${explanation%%:*}")" + if [[ -n "$tools" ]] && ! _eh_command_is "$command" "$tools"; then explanation="Command failed with exit status ${exit_code}" fi printf '%s' "$explanation" } +# The prefix of an explanation names the tool family it belongs to. +_eh_tools_for() { + case "$1" in + curl) echo "curl" ;; + APT | DPKG) echo "apt|apt-get|apt-cache|aptitude|dpkg|dpkg-query|dpkg-reconfigure" ;; + Node.js | npm/Node.js | npm/pnpm/yarn) echo "node|npm|npx|pnpm|yarn|corepack|bun" ;; + Python) echo "python|python3|pip|pip3|pipx|uv|uvx" ;; + PostgreSQL) echo "psql|pg_[a-z_]+|postgres" ;; + MySQL/MariaDB) echo "mysql|mariadb|mysqladmin|mariadb-admin" ;; + MongoDB) echo "mongosh|mongo|mongod" ;; + Systemd) echo "systemctl" ;; + git) echo "git" ;; + esac +} + +# Does the command line run one of the tools (first word or any word, with or without a path)? +_eh_command_is() { + local command="$1" tools="$2" + [[ "$command" =~ (^|[[:space:]]|/)(${tools})([[:space:]]|$) ]] +} + # ------------------------------------------------------------------------------ # _eh_report() - the one terminal event for this execution # @@ -540,6 +564,10 @@ _eh_hint_from_log() { _eh_warn "Hint: Run 'apt-get install -f' inside the container or check that all required repositories are configured for your distribution." fi + elif grep -qE "ERR_PNPM_OUTDATED_LOCKFILE|ERR_PNPM_LOCKFILE_CONFIG_MISMATCH|npm ERR! code ELOCKVERIFY" <<<"$tail_txt"; then + _eh_warn "The release's lockfile does not match its package.json: an upstream packaging slip, not this container." + _eh_warn "Hint: the install script can fall back to 'pnpm install --no-frozen-lockfile' for this release." + elif grep -qE "sqv|KEYEXPIRED|NO_PUBKEY|key is not certified|signature verification failed|is not signed|Sub-process.*sqv" <<<"$tail_txt"; then _eh_warn "APT repository signature error detected." _eh_warn "Hint: A repository GPG key may be missing, expired, or the keyring file is not yet present (/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc etc.)." @@ -637,10 +665,12 @@ error_handler() { command="${command//\$STD/}" # silent() knows the real failing command; the trap only sees the wrapper. + local from_silent=0 if [[ -n "${_SILENT_FAILED_RC:-}" ]]; then exit_code="$_SILENT_FAILED_RC" command="$_SILENT_FAILED_CMD" line_number="$_SILENT_FAILED_LINE" + from_silent=1 unset _SILENT_FAILED_RC _SILENT_FAILED_CMD _SILENT_FAILED_LINE fi @@ -669,7 +699,12 @@ error_handler() { _eh_write_debug_log "$exit_code" "$command" "$line_number" "$explanation" _eh_resolve_log - _eh_print_log_tail "$_EH_ACTIVE_LOG" + # silent() captured exactly this command's output; the raw log tail is the fallback. + if ((from_silent)) && [[ -s "${_EH_ACTIVE_LOG}.errinfo" ]]; then + _eh_print_failed_output "${_EH_ACTIVE_LOG}.errinfo" + else + _eh_print_log_tail "$_EH_ACTIVE_LOG" + fi _eh_hint_missing_command "$exit_code" "$command" _eh_hint_node_oom "$exit_code" "$command" "$_EH_ACTIVE_LOG" _eh_hint_from_log "$_EH_ACTIVE_LOG" diff --git a/incus/backend.func b/incus/backend.func index c483c32..896344d 100644 --- a/incus/backend.func +++ b/incus/backend.func @@ -1220,12 +1220,21 @@ incus_run_install_script_with_recovery() { echo "" local is_oom=false is_network_issue=false is_apt_issue=false is_cmd_not_found=false - local error_explanation="" - declare -f explain_exit_code >/dev/null 2>&1 && error_explanation="$(explain_exit_code "$install_exit_code")" + local error_explanation="" failed_cmd="" + [[ -s "${TELEMETRY_ERRINFO:-}" ]] && failed_cmd="$(sed -n 's/^COMMAND=//p' "$TELEMETRY_ERRINFO" | head -n1)" + if declare -f _eh_explanation >/dev/null 2>&1; then + error_explanation="$(_eh_explanation "$install_exit_code" "$failed_cmd")" + elif declare -f explain_exit_code >/dev/null 2>&1; then + error_explanation="$(explain_exit_code "$install_exit_code")" + fi [[ $install_exit_code -eq 134 || $install_exit_code -eq 137 || $install_exit_code -eq 243 ]] && is_oom=true case "$install_exit_code" in - 100 | 101 | 102) is_apt_issue=true ;; + 100 | 101 | 102) + if [[ -z "$failed_cmd" ]] || ! declare -f _eh_command_is >/dev/null 2>&1 || _eh_command_is "$failed_cmd" "$(_eh_tools_for APT)"; then + is_apt_issue=true + fi + ;; 255) [[ -f "$combined_log" ]] && grep -qiE 'dpkg|apt-get|broken packages|unmet dependencies' "$combined_log" && is_apt_issue=true ;; @@ -1240,12 +1249,11 @@ incus_run_install_script_with_recovery() { if [[ $install_exit_code -eq 1 && -f "$combined_log" ]]; then grep -qiE 'E: Unable to|broken packages|unmet dependencies|dpkg --configure' "$combined_log" && is_apt_issue=true grep -qiE 'Cannot allocate memory|Out of memory|oom-killer|JavaScript heap' "$combined_log" && is_oom=true - grep -qiE 'Could not resolve|DNS|Network is unreachable|Failed to fetch' "$combined_log" && is_network_issue=true + grep -qiE 'Could not resolve|DNS resolution failed|Temporary failure resolving|Network is unreachable|Failed to fetch' "$combined_log" && is_network_issue=true grep -qiE ': command not found' "$combined_log" && is_cmd_not_found=true fi [[ -n "$error_explanation" ]] && echo -e "${TAB}${RD}Error: ${error_explanation}${CL}" && echo "" - declare -f _eh_print_failed_output >/dev/null 2>&1 && _eh_print_failed_output "${TELEMETRY_ERRINFO:-}" if [[ $install_exit_code -eq 10 ]]; then echo -e "${TAB}${INFO} Privileged / nesting / device hint:" echo -e "${TAB}${INFO} • Privileged: ${GN}security.privileged=true${CL} (USB, Docker-in-LXC)" diff --git a/pve/backend.func b/pve/backend.func index ee523c9..c6bd081 100644 --- a/pve/backend.func +++ b/pve/backend.func @@ -1497,7 +1497,7 @@ PROFILE if grep -qiE 'Cannot allocate memory|Out of memory|oom-killer|Killed process|JavaScript heap' "$combined_log"; then is_oom=true fi - if grep -qiE 'Could not resolve|DNS|Connection refused|Network is unreachable|No route to host|Temporary failure resolving|Failed to fetch' "$combined_log"; then + if grep -qiE 'Could not resolve|DNS resolution failed|Connection refused|Network is unreachable|No route to host|Temporary failure resolving|Failed to fetch' "$combined_log"; then is_network_issue=true fi if grep -qiE ': command not found|No such file or directory.*/s?bin/' "$combined_log"; then @@ -1550,8 +1550,11 @@ PROFILE echo "" - local error_explanation="" - if declare -f explain_exit_code >/dev/null 2>&1; then + local error_explanation="" failed_cmd="" + [[ -s "${TELEMETRY_ERRINFO:-}" ]] && failed_cmd="$(sed -n 's/^COMMAND=//p' "$TELEMETRY_ERRINFO" | head -n1)" + if declare -f _eh_explanation >/dev/null 2>&1; then + error_explanation="$(_eh_explanation "$install_exit_code" "$failed_cmd")" + elif declare -f explain_exit_code >/dev/null 2>&1; then error_explanation="$(explain_exit_code "$install_exit_code")" fi @@ -1560,7 +1563,12 @@ PROFILE fi case "$install_exit_code" in - 100 | 101 | 102) is_apt_issue=true ;; + 100 | 101 | 102) + # cargo exits 101 on a panic; only an apt/dpkg command makes this an APT problem + if [[ -z "$failed_cmd" ]] || ! declare -f _eh_command_is >/dev/null 2>&1 || _eh_command_is "$failed_cmd" "$(_eh_tools_for APT)"; then + is_apt_issue=true + fi + ;; 255) if [[ -f "$combined_log" ]] && grep -qiE 'dpkg|apt-get|apt\.conf|broken packages|unmet dependencies|E: Sub-process|E: Failed' "$combined_log"; then is_apt_issue=true @@ -1597,7 +1605,6 @@ PROFILE echo -e "${TAB}${RD}Error: ${error_explanation}${CL}" echo "" fi - declare -f _eh_print_failed_output >/dev/null 2>&1 && _eh_print_failed_output "${TELEMETRY_ERRINFO:-}" if [[ $install_exit_code -eq 10 ]]; then echo -e "${TAB}${INFO} This error usually means the container needs ${GN}privileged${CL} mode or Docker/nesting support." diff --git a/tests/exit-code-hints.sh b/tests/exit-code-hints.sh new file mode 100644 index 0000000..7f5b80b --- /dev/null +++ b/tests/exit-code-hints.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -uo pipefail + +CORE_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +eval "$(awk '/^explain_exit_code[(][)] [{]/,/^}/' "$CORE_DIR/api/exitcodes.func")" +eval "$(awk '/^_eh_(hint_from_log|explanation|tools_for|command_is)[(][)] [{]/,/^}/' "$CORE_DIR/core/error_handler.func")" + +TEST_DIR="$(mktemp -d)" +trap 'rm -rf -- "$TEST_DIR"' EXIT +LOG="$TEST_DIR/log" +WARN="$TEST_DIR/warn" +passed=0 failed=0 +_eh_warn() { echo "$1" >>"$WARN"; } + +expect() { + if [[ "$2" == "$3" ]]; then + echo "PASS $1" + passed=$((passed + 1)) + else + echo "FAIL $1: expected <$2>, got <$3>" >&2 + failed=$((failed + 1)) + fi +} + +# Exit status 1 is the command's own verdict; errno 1 (EPERM) is something else. +expect "exit 1 is not explained as EPERM" 0 "$(explain_exit_code 1 | grep -c 'Operation not permitted')" +expect "the container fallback table agrees" 0 "$(grep -E '^ *1[)] echo' "$CORE_DIR/core/error_handler.func" | grep -c 'Operation not permitted')" + +# A tool-specific meaning holds only for that tool; everything else gets the plain status. +expect "cargo 101 is not an APT error" "Command failed with exit status 101" "$(_eh_explanation 101 'cargo build --release')" +expect "apt-get 101 keeps the APT text" APT "$(_eh_explanation 101 'apt-get install -y jq' | cut -d: -f1)" +expect "a core wrapper returning 7 is not curl" "Command failed with exit status 7" "$(_eh_explanation 7 'fetch_and_deploy_gh_release app owner/repo')" +expect "curl 7 with a path keeps the curl text" curl "$(_eh_explanation 7 '/usr/bin/curl -fsSL https://x' | cut -d: -f1)" +expect "git 128 is a git fatal error" git "$(_eh_explanation 128 'git clone https://x' | cut -d: -f1)" +expect "pnpm 128 is not" "Command failed with exit status 128" "$(_eh_explanation 128 'pnpm build')" +expect "node 243 keeps the Node text" Node.js "$(_eh_explanation 243 'node server.js' | cut -d: -f1)" +expect "python 243 does not" "Command failed with exit status 243" "$(_eh_explanation 243 'python3 app.py')" +expect "core codes stay as they are" Proxmox "$(_eh_explanation 209 '' | cut -d: -f1)" +expect "unprefixed codes stay as they are" "Command not found" "$(_eh_explanation 127 'foo')" + +: >"$WARN" +printf '%s\n' 'Scope: all 36 workspace projects' ' ERR_PNPM_OUTDATED_LOCKFILE Cannot install with "frozen-lockfile" because pnpm-lock.yaml is not up to date with /server/package.json' >"$LOG" +_eh_hint_from_log "$LOG" +expect "a stale pnpm lockfile is named" 1 "$(grep -c 'lockfile does not match' "$WARN")" +expect "the fallback is suggested" 1 "$(grep -c 'no-frozen-lockfile' "$WARN")" + +: >"$WARN" +printf '%s\n' '[OK] Git DNS: github.com:(ok)' '[ERROR] in line 34: exit code 1' >"$LOG" +_eh_hint_from_log "$LOG" +expect "the network check line raises no hint" 0 "$(wc -l <"$WARN" | tr -d ' ')" + +# The host classifies the combined log itself; a bare DNS used to match the network check line. +expect "pve host grep no longer matches the word DNS alone" 0 "$(grep -c 'resolve|DNS|' "$CORE_DIR/pve/backend.func")" +expect "incus host grep no longer matches the word DNS alone" 0 "$(grep -c 'resolve|DNS|' "$CORE_DIR/incus/backend.func")" + +printf '%s passed, %s failed\n' "$passed" "$failed" +((failed == 0)) diff --git a/tests/external-installer.sh b/tests/external-installer.sh index 8a4d12e..ca74952 100644 --- a/tests/external-installer.sh +++ b/tests/external-installer.sh @@ -36,13 +36,20 @@ run_eof() { check "var yes" 0 "$(run yes "")" check "var YES" 0 "$(run YES "")" check "var true" 0 "$(run true "")" -check "var no" 10 "$(run no "y")" -check "var wins over input" 10 "$(run no "yes")" +check "var no" 254 "$(run no "y")" +check "var wins over input" 254 "$(run no "yes")" check "typed y" 0 "$(run "" y)" check "typed Yes" 0 "$(run "" Yes)" -check "typed n" 10 "$(run "" n)" -check "just enter" 10 "$(run "" "")" -check "no stdin" 10 "$(run_eof)" +check "typed n" 254 "$(run "" n)" +check "just enter" 254 "$(run "" "")" +check "no stdin" 254 "$(run_eof)" + +# what the message says; $1 is var_external_installer, $2 what the user types +said() { + (var_external_installer="$1" confirm_external_installer src url) <<<"$2" 2>&1 >/dev/null || true +} +check "declining via the variable names the variable" 1 "$(said no "" | grep -c 'var_external_installer=no')" +check "declining at the prompt is a user abort" 1 "$(said "" n | grep -c 'Aborted by user')" printf '%s passed, %s failed\n' "$passed" "$failed" ((failed == 0)) diff --git a/tests/failed-command-output.sh b/tests/failed-command-output.sh index 55cca1b..adbb722 100644 --- a/tests/failed-command-output.sh +++ b/tests/failed-command-output.sh @@ -86,5 +86,32 @@ expect "an empty output section prints nothing" "" "$(_eh_print_failed_output "$ expect "a missing errinfo prints nothing" "" "$(_eh_print_failed_output "$TEST_DIR/nope" 2>&1)" expect "no errinfo path prints nothing" "" "$(_eh_print_failed_output "" 2>&1)" +# --- the container handler prefers the captured output over the raw log tail --- +eval "$(awk '/^(error_handler|_eh_resolve_log|_eh_print_log_tail)[(][)] [{]/,/^}/' "$CORE_DIR/core/error_handler.func")" +_eh_explanation() { echo "explained"; } +_eh_report() { :; } +_eh_write_debug_log() { :; } +_eh_hint_missing_command() { :; } +_eh_hint_node_oom() { :; } +_eh_hint_from_log() { :; } +_is_container_context() { return 0; } +msg_error() { echo "error: $*"; } +handler_output() { + # error_handler exits, so it runs in a subshell with the state silent() leaves behind. + ( + export _SILENT_FAILED_RC=1 _SILENT_FAILED_CMD="pnpm install" _SILENT_FAILED_LINE=34 _SILENT_FAILED_LOG="$LOGFILE" + error_handler 1 "silent pnpm install" 2>&1 + ) +} +reset +printf '%s\n' "[INFO] Building" "cargo noise" "ERR_PNPM_OUTDATED_LOCKFILE" >"$LOGFILE" +printf 'EXIT_CODE=1\nLINE=34\nCOMMAND=pnpm install\n--- OUTPUT ---\nERR_PNPM_OUTDATED_LOCKFILE\n' >"$LOGFILE.errinfo" +out="$(handler_output)" +expect "with errinfo the handler shows the command output" 1 "$(printf '%s\n' "$out" | grep -c 'Output of the failing command')" +expect "and leaves out the raw log tail" 0 "$(printf '%s\n' "$out" | grep -c 'Last 20 lines of log')" +rm -f "$LOGFILE.errinfo" +out="$(handler_output)" +expect "without errinfo the raw tail remains" 1 "$(printf '%s\n' "$out" | grep -c 'Last 20 lines of log')" + printf '%s passed, %s failed\n' "$passed" "$failed" ((failed == 0)) diff --git a/ui/API.txt b/ui/API.txt index 4df57b1..795bb83 100644 --- a/ui/API.txt +++ b/ui/API.txt @@ -17,6 +17,7 @@ _dev_step_end _dev_step_key _dev_step_start _dev_timing_dir +_eh_command_is _eh_destroy_container _eh_explanation _eh_hint_from_log @@ -31,6 +32,7 @@ _eh_quiet_spinner _eh_report _eh_resolve_log _eh_signal_exit +_eh_tools_for _eh_warn _eh_write_debug_log _get_current_ip