mirror of
https://github.com/community-scripts/ProxmoxVE.git
synced 2026-07-21 05:15:07 +02:00
6f04a9787f
Overhaul the telemetry system to enforce a single-reporter model where only the HOST sends terminal statuses (success/failed/aborted) to the API. Containers now write local artifact files (.failed flag + .errinfo) that the host picks up after lxc-attach returns, preventing the race condition where a metadata-less container payload would win over the full host payload. Key changes: - Add TELEMETRY_CONTEXT=container export in install.func/alpine-install.func to mark container context before error handling starts - Introduce _is_container_context() heuristic and _container_write_failure() artifact writer in error_handler.func - Refactor api.func: unified _tm_payload() builder (full metadata on every send), _tm_send() curl wrapper, _tm_enabled() gate, telemetry_collect_sysinfo() cached collector - Add structured .errinfo capture in silent() (core.func) using byte-offset to extract exactly the failing command's output - Pull .errinfo from container in build.func after lxc-attach for precise error traces - Add categorize_error(), telemetry_new_attempt(), detect_arm(), REPO_SLUG tracking - Signal exits (129/130/143) now report as 'aborted' instead of 'failed' - Remove post_update_to_api_extended() (superseded by unified _tm_payload) - Export REPO_SOURCE, REPO_SLUG, TELEMETRY_PLATFORM into container environment
1225 lines
45 KiB
Bash
1225 lines
45 KiB
Bash
# Copyright (c) 2021-2026 community-scripts ORG
|
|
# Author: michelroegl-brunner | MickLesk
|
|
# License: MIT | https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/LICENSE
|
|
|
|
# ==============================================================================
|
|
# API.FUNC - TELEMETRY & DIAGNOSTICS API
|
|
# ==============================================================================
|
|
#
|
|
# Provides functions for sending anonymous telemetry data via the community
|
|
# telemetry ingest service at telemetry.community-scripts.org.
|
|
#
|
|
# DESIGN PRINCIPLES:
|
|
# 1. SINGLE REPORTER: only the HOST sends terminal statuses (success/failed/
|
|
# aborted). Containers never curl the telemetry endpoint for terminal
|
|
# events - they write local artifact files (.failed flag + .errinfo) that
|
|
# the host picks up. This prevents the "first terminal event wins" race
|
|
# on the server from persisting a metadata-less container payload and
|
|
# discarding the full host payload.
|
|
# 2. FULL PAYLOAD, ALWAYS: every send includes the complete system metadata
|
|
# (os, pve version, arch, cpu, gpu, ram, repo attribution). No more
|
|
# minimal payloads that create empty records.
|
|
# 3. FOCUSED ERROR TRACE: the error field contains exactly the output of the
|
|
# command that failed (captured by silent() via byte offset), prefixed
|
|
# with "exit_code=N | <explanation> | at line L: <command>". Not a 120KB
|
|
# log dump, not the wrong phase of the log.
|
|
# 4. IDEMPOTENT FINALIZE: post_update_to_api() sends exactly one terminal
|
|
# status per execution, no matter how many traps fire.
|
|
#
|
|
# Usage:
|
|
# source <(curl -fsSL .../api.func)
|
|
# post_to_api # Report LXC container creation ("installing")
|
|
# post_to_api_vm # Report VM creation ("installing")
|
|
# post_progress_to_api # Progress ping ("validation"/"configuring")
|
|
# post_update_to_api # Final status ("done"/"failed"/"aborted")
|
|
#
|
|
# Privacy:
|
|
# - Only anonymous statistics (no personal data, IPs are anonymized)
|
|
# - User can opt-out via DIAGNOSTICS=no
|
|
# - Random UUID for session tracking only
|
|
#
|
|
# ==============================================================================
|
|
|
|
# ==============================================================================
|
|
# Telemetry Configuration
|
|
# ==============================================================================
|
|
TELEMETRY_URL="${TELEMETRY_URL:-https://telemetry.community-scripts.org/telemetry}"
|
|
|
|
# Timeout for progress pings (seconds)
|
|
TELEMETRY_TIMEOUT=5
|
|
# Timeout for final status updates (they carry the error trace)
|
|
STATUS_TIMEOUT=10
|
|
|
|
# Max size of the error trace sent to the API (bytes) and max line count.
|
|
# Keep this SMALL and FOCUSED - the goal is "exactly the messages from the
|
|
# moment of failure", not the whole installation log.
|
|
TELEMETRY_ERROR_MAX_LINES=60
|
|
TELEMETRY_ERROR_MAX_BYTES=10240
|
|
|
|
# ==============================================================================
|
|
# SECTION 0: REPOSITORY SOURCE DETECTION
|
|
# ==============================================================================
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# detect_repo_source()
|
|
#
|
|
# - Dynamically detects which GitHub/Gitea repo the scripts were loaded from
|
|
# - Inspects /proc/$$/cmdline and $0 to find the source URL
|
|
# - Maps detected repo to one of three canonical values:
|
|
# * "ProxmoxVE" — official community-scripts/ProxmoxVE (production)
|
|
# * "ProxmoxVED" — official community-scripts/ProxmoxVED (development)
|
|
# * "external" — any fork or unknown source
|
|
# - Additionally sets REPO_SLUG to the real "owner/repo" string
|
|
# - Fallback: "ProxmoxVE" (this file lives in the production repo)
|
|
# - Skips detection if REPO_SOURCE is already set (e.g., exported by the host
|
|
# into the container environment)
|
|
# ------------------------------------------------------------------------------
|
|
detect_repo_source() {
|
|
# Allow explicit override via environment (also how the container inherits
|
|
# the host's attribution instead of re-detecting)
|
|
if [[ -n "${REPO_SOURCE:-}" ]]; then
|
|
if [[ -z "${REPO_SLUG:-}" ]]; then
|
|
case "$REPO_SOURCE" in
|
|
ProxmoxVE) REPO_SLUG="community-scripts/ProxmoxVE" ;;
|
|
ProxmoxVED) REPO_SLUG="community-scripts/ProxmoxVED" ;;
|
|
esac
|
|
export REPO_SLUG
|
|
fi
|
|
return 0
|
|
fi
|
|
|
|
local content="" owner_repo=""
|
|
|
|
# Method 1: Read from /proc/$$/cmdline
|
|
# When invoked via: bash -c "$(curl -fsSL https://.../ct/app.sh)"
|
|
if [[ -r /proc/$$/cmdline ]]; then
|
|
content=$(tr '\0' ' ' </proc/$$/cmdline 2>/dev/null) || true
|
|
fi
|
|
|
|
# Method 2: Read from the original script file (bash ct/app.sh)
|
|
if [[ -z "$content" ]] || ! echo "$content" | grep -qE 'githubusercontent\.com|community-scripts\.org' 2>/dev/null; then
|
|
if [[ -f "$0" ]] && [[ "$0" != *bash* ]]; then
|
|
content=$(head -10 "$0" 2>/dev/null) || true
|
|
fi
|
|
fi
|
|
|
|
if [[ -n "$content" ]]; then
|
|
owner_repo=$(echo "$content" | grep -oE 'raw\.githubusercontent\.com/[^/]+/[^/]+' | head -1 | sed 's|raw\.githubusercontent\.com/||') || true
|
|
if [[ -z "$owner_repo" ]]; then
|
|
owner_repo=$(echo "$content" | grep -oE 'git\.community-scripts\.org/[^/]+/[^/]+' | head -1 | sed 's|git\.community-scripts\.org/||') || true
|
|
fi
|
|
fi
|
|
|
|
case "$owner_repo" in
|
|
community-scripts/ProxmoxVE)
|
|
REPO_SOURCE="ProxmoxVE"
|
|
REPO_SLUG="community-scripts/ProxmoxVE"
|
|
;;
|
|
community-scripts/ProxmoxVED)
|
|
REPO_SOURCE="ProxmoxVED"
|
|
REPO_SLUG="community-scripts/ProxmoxVED"
|
|
;;
|
|
"")
|
|
# No URL detected — hardcoded fallback (production repo)
|
|
REPO_SOURCE="ProxmoxVE"
|
|
REPO_SLUG="community-scripts/ProxmoxVE"
|
|
;;
|
|
*)
|
|
REPO_SOURCE="external"
|
|
REPO_SLUG="$owner_repo"
|
|
;;
|
|
esac
|
|
|
|
export REPO_SOURCE REPO_SLUG
|
|
}
|
|
|
|
# Run detection immediately when api.func is sourced
|
|
detect_repo_source
|
|
|
|
# ==============================================================================
|
|
# SECTION 1: ERROR CODE DESCRIPTIONS & CATEGORIES
|
|
# ==============================================================================
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# explain_exit_code()
|
|
#
|
|
# - Maps numeric exit codes to human-readable error descriptions
|
|
# - Canonical source of truth for ALL exit code mappings
|
|
# - Used by both api.func (telemetry) and error_handler.func (error display)
|
|
# ------------------------------------------------------------------------------
|
|
explain_exit_code() {
|
|
local code="$1"
|
|
case "$code" in
|
|
# --- Generic / Shell ---
|
|
1) echo "General error / Operation not permitted" ;;
|
|
2) echo "Misuse of shell builtins (e.g. syntax error)" ;;
|
|
3) echo "General syntax or argument error" ;;
|
|
10) echo "Docker / privileged mode required (unsupported environment)" ;;
|
|
|
|
# --- curl / wget errors ---
|
|
4) echo "curl: Feature not supported or protocol error" ;;
|
|
5) echo "curl: Could not resolve proxy" ;;
|
|
6) echo "curl: DNS resolution failed (could not resolve host)" ;;
|
|
7) echo "curl: Failed to connect (network unreachable / host down)" ;;
|
|
8) echo "curl: Server reply error (FTP/SFTP or apk untrusted key)" ;;
|
|
16) echo "curl: HTTP/2 framing layer error" ;;
|
|
18) echo "curl: Partial file (transfer not completed)" ;;
|
|
22) echo "curl: HTTP error returned (404, 429, 500+)" ;;
|
|
23) echo "curl: Write error (disk full or permissions)" ;;
|
|
24) echo "curl: Write to local file failed" ;;
|
|
25) echo "curl: Upload failed" ;;
|
|
26) echo "curl: Read error on local file (I/O)" ;;
|
|
27) echo "curl: Out of memory (memory allocation failed)" ;;
|
|
28) echo "curl: Operation timeout (network slow or server not responding)" ;;
|
|
30) echo "curl: FTP port command failed" ;;
|
|
32) echo "curl: FTP SIZE command failed" ;;
|
|
33) echo "curl: HTTP range error" ;;
|
|
34) echo "curl: HTTP post error" ;;
|
|
35) echo "curl: SSL/TLS handshake failed (certificate error)" ;;
|
|
36) echo "curl: FTP bad download resume" ;;
|
|
39) echo "curl: LDAP search failed" ;;
|
|
44) echo "curl: Internal error (bad function call order)" ;;
|
|
45) echo "curl: Interface error (failed to bind to specified interface)" ;;
|
|
46) echo "curl: Bad password entered" ;;
|
|
47) echo "curl: Too many redirects" ;;
|
|
48) echo "curl: Unknown command line option specified" ;;
|
|
51) echo "curl: SSL peer certificate or SSH host key verification failed" ;;
|
|
52) echo "curl: Empty reply from server (got nothing)" ;;
|
|
55) echo "curl: Failed sending network data" ;;
|
|
56) echo "curl: Receive error (connection reset by peer)" ;;
|
|
57) echo "curl: Unrecoverable poll/select error (system I/O failure)" ;;
|
|
59) echo "curl: Couldn't use specified SSL cipher" ;;
|
|
61) echo "curl: Bad/unrecognized transfer encoding" ;;
|
|
63) echo "curl: Maximum file size exceeded" ;;
|
|
75) echo "Temporary failure (retry later)" ;;
|
|
78) echo "curl: Remote file not found (404 on FTP/file)" ;;
|
|
79) echo "curl: SSH session error (key exchange/auth failed)" ;;
|
|
92) echo "curl: HTTP/2 stream error (protocol violation)" ;;
|
|
95) echo "curl: HTTP/3 layer error" ;;
|
|
|
|
# --- Package manager / APT / DPKG ---
|
|
100) echo "APT: Package manager error (broken packages / dependency problems)" ;;
|
|
101) echo "APT: Configuration error (bad sources.list, malformed config)" ;;
|
|
102) echo "APT: Lock held by another process (dpkg/apt still running)" ;;
|
|
|
|
# --- Script Validation & Setup (103-123) ---
|
|
103) echo "Validation: Shell is not Bash" ;;
|
|
104) echo "Validation: Not running as root (or invoked via sudo)" ;;
|
|
105) echo "Validation: Proxmox VE version not supported" ;;
|
|
106) echo "Validation: Unsupported architecture (requires amd64 or arm64)" ;;
|
|
107) echo "Validation: Kernel key parameters unreadable" ;;
|
|
108) echo "Validation: Kernel key limits exceeded" ;;
|
|
109) echo "Proxmox: No available container ID after max attempts" ;;
|
|
110) echo "Proxmox: Failed to apply default.vars" ;;
|
|
111) echo "Proxmox: App defaults file not available" ;;
|
|
112) echo "Proxmox: Invalid install menu option" ;;
|
|
113) echo "LXC: Under-provisioned — user aborted update" ;;
|
|
114) echo "LXC: Storage too low — user aborted update" ;;
|
|
115) echo "Download: install.func download failed or incomplete" ;;
|
|
116) echo "Proxmox: Default bridge vmbr0 not found" ;;
|
|
117) echo "LXC: Container did not reach running state" ;;
|
|
118) echo "LXC: No IP assigned to container after timeout" ;;
|
|
119) echo "Proxmox: No valid storage for rootdir content" ;;
|
|
120) echo "Proxmox: No valid storage for vztmpl content" ;;
|
|
121) echo "LXC: Container network not ready (no IP after retries)" ;;
|
|
122) echo "LXC: No internet connectivity — user declined to continue" ;;
|
|
123) echo "LXC: Local IP detection failed" ;;
|
|
|
|
# --- BSD sysexits.h (64-78) ---
|
|
64) echo "Usage error (wrong arguments)" ;;
|
|
65) echo "Data format error (bad input data)" ;;
|
|
66) echo "Input file not found (cannot open input)" ;;
|
|
67) echo "User not found (addressee unknown)" ;;
|
|
68) echo "Host not found (hostname unknown)" ;;
|
|
69) echo "Service unavailable" ;;
|
|
70) echo "Internal software error" ;;
|
|
71) echo "System error (OS-level failure)" ;;
|
|
72) echo "Critical OS file missing" ;;
|
|
73) echo "Cannot create output file" ;;
|
|
74) echo "I/O error" ;;
|
|
76) echo "Remote protocol error" ;;
|
|
77) echo "Permission denied" ;;
|
|
|
|
# --- Common shell/system errors ---
|
|
124) echo "Command timed out (timeout command)" ;;
|
|
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" ;;
|
|
129) echo "Killed by SIGHUP (terminal closed / hangup)" ;;
|
|
130) echo "Aborted by user (SIGINT)" ;;
|
|
131) echo "Killed by SIGQUIT (core dumped)" ;;
|
|
132) echo "Killed by SIGILL (illegal CPU instruction)" ;;
|
|
134) echo "Process aborted (SIGABRT - possibly Node.js heap overflow)" ;;
|
|
137) echo "Killed (SIGKILL / Out of memory?)" ;;
|
|
139) echo "Segmentation fault (core dumped)" ;;
|
|
141) echo "Broken pipe (SIGPIPE - output closed prematurely)" ;;
|
|
143) echo "Terminated (SIGTERM)" ;;
|
|
144) echo "Killed by signal 16 (SIGUSR1 / SIGSTKFLT)" ;;
|
|
146) echo "Killed by signal 18 (SIGTSTP)" ;;
|
|
|
|
# --- Systemd / Service errors (150-154) ---
|
|
150) echo "Systemd: Service failed to start" ;;
|
|
151) echo "Systemd: Service unit not found" ;;
|
|
152) echo "Permission denied (EACCES)" ;;
|
|
153) echo "Build/compile failed (make/gcc/cmake)" ;;
|
|
154) echo "Node.js: Native addon build failed (node-gyp)" ;;
|
|
|
|
# --- Python / pip / uv (160-162) ---
|
|
160) echo "Python: Virtualenv / uv environment missing or broken" ;;
|
|
161) echo "Python: Dependency resolution failed" ;;
|
|
162) echo "Python: Installation aborted (permissions or EXTERNALLY-MANAGED)" ;;
|
|
|
|
# --- PostgreSQL (170-173) ---
|
|
170) echo "PostgreSQL: Connection failed (server not running / wrong socket)" ;;
|
|
171) echo "PostgreSQL: Authentication failed (bad user/password)" ;;
|
|
172) echo "PostgreSQL: Database does not exist" ;;
|
|
173) echo "PostgreSQL: Fatal error in query / syntax" ;;
|
|
|
|
# --- MySQL / MariaDB (180-183) ---
|
|
180) echo "MySQL/MariaDB: Connection failed (server not running / wrong socket)" ;;
|
|
181) echo "MySQL/MariaDB: Authentication failed (bad user/password)" ;;
|
|
182) echo "MySQL/MariaDB: Database does not exist" ;;
|
|
183) echo "MySQL/MariaDB: Fatal error in query / syntax" ;;
|
|
|
|
# --- MongoDB (190-193) ---
|
|
190) echo "MongoDB: Connection failed (server not running)" ;;
|
|
191) echo "MongoDB: Authentication failed (bad user/password)" ;;
|
|
192) echo "MongoDB: Database not found" ;;
|
|
193) echo "MongoDB: Fatal query error" ;;
|
|
|
|
# --- Proxmox Custom Codes (200-231) ---
|
|
200) echo "Proxmox: Failed to create lock file" ;;
|
|
203) echo "Proxmox: Missing CTID variable" ;;
|
|
204) echo "Proxmox: Missing PCT_OSTYPE variable" ;;
|
|
205) echo "Proxmox: Invalid CTID (<100)" ;;
|
|
206) echo "Proxmox: CTID already in use" ;;
|
|
207) echo "Proxmox: Password contains unescaped special characters" ;;
|
|
208) echo "Proxmox: Invalid configuration (DNS/MAC/Network format)" ;;
|
|
209) echo "Proxmox: Container creation failed" ;;
|
|
210) echo "Proxmox: Cluster not quorate" ;;
|
|
211) echo "Proxmox: Timeout waiting for template lock" ;;
|
|
212) echo "Proxmox: Storage type 'iscsidirect' does not support containers (VMs only)" ;;
|
|
213) echo "Proxmox: Storage type does not support 'rootdir' content" ;;
|
|
214) echo "Proxmox: Not enough storage space" ;;
|
|
215) echo "Proxmox: Container created but not listed (ghost state)" ;;
|
|
216) echo "Proxmox: RootFS entry missing in config" ;;
|
|
217) echo "Proxmox: Storage not accessible" ;;
|
|
218) echo "Proxmox: Template file corrupted or incomplete" ;;
|
|
219) echo "Proxmox: CephFS does not support containers - use RBD" ;;
|
|
220) echo "Proxmox: Unable to resolve template path" ;;
|
|
221) echo "Proxmox: Template file not readable" ;;
|
|
222) echo "Proxmox: Template download failed" ;;
|
|
223) echo "Proxmox: Template not available after download" ;;
|
|
224) echo "Proxmox: PBS storage is for backups only" ;;
|
|
225) echo "Proxmox: No template available for OS/Version" ;;
|
|
226) echo "Proxmox: VM disk import or post-creation setup failed" ;;
|
|
231) echo "Proxmox: LXC stack upgrade failed" ;;
|
|
|
|
# --- Tools & Addon Scripts (232-238) ---
|
|
232) echo "Tools: Wrong execution environment (run on PVE host, not inside LXC)" ;;
|
|
233) echo "Tools: Application not installed (update prerequisite missing)" ;;
|
|
234) echo "Tools: No LXC containers found or available" ;;
|
|
235) echo "Tools: Backup or restore operation failed" ;;
|
|
236) echo "Tools: Required hardware not detected" ;;
|
|
237) echo "Tools: Dependency package installation failed" ;;
|
|
238) echo "Tools: OS or distribution not supported for this addon" ;;
|
|
|
|
# --- Node.js / npm / pnpm / yarn (239-249) ---
|
|
239) echo "npm/Node.js: Unexpected runtime error or dependency failure" ;;
|
|
243) echo "Node.js: Out of memory (JavaScript heap out of memory)" ;;
|
|
245) echo "Node.js: Invalid command-line option" ;;
|
|
246) echo "Node.js: Internal JavaScript Parse Error" ;;
|
|
247) echo "Node.js: Fatal internal error" ;;
|
|
248) echo "Node.js: Invalid C++ addon / N-API failure" ;;
|
|
249) echo "npm/pnpm/yarn: Unknown fatal error" ;;
|
|
|
|
# --- Application Install/Update Errors (250-254) ---
|
|
250) echo "App: Download failed or version not determined" ;;
|
|
251) echo "App: File extraction failed (corrupt or incomplete archive)" ;;
|
|
252) echo "App: Required file or resource not found" ;;
|
|
253) echo "App: Data migration required — update aborted" ;;
|
|
254) echo "App: User declined prompt or input timed out" ;;
|
|
|
|
# --- DPKG ---
|
|
255) echo "DPKG: Fatal internal error" ;;
|
|
|
|
# --- Default ---
|
|
*) echo "Unknown error" ;;
|
|
esac
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# categorize_error()
|
|
#
|
|
# - Maps exit codes to error categories for dashboard grouping
|
|
# - Can be overridden via ERROR_CATEGORY_OVERRIDE (log-based subclassification)
|
|
# ------------------------------------------------------------------------------
|
|
categorize_error() {
|
|
if [[ -n "${ERROR_CATEGORY_OVERRIDE:-}" ]]; then
|
|
echo "$ERROR_CATEGORY_OVERRIDE"
|
|
return
|
|
fi
|
|
|
|
local code="$1"
|
|
case "$code" in
|
|
6 | 7 | 22 | 35) echo "network" ;;
|
|
10) echo "config" ;;
|
|
28 | 124 | 211) echo "timeout" ;;
|
|
214 | 217 | 219 | 224) echo "storage" ;;
|
|
100 | 101 | 102 | 127 | 160 | 161 | 162 | 255) echo "dependency" ;;
|
|
126 | 152) echo "permission" ;;
|
|
128 | 203 | 204 | 205 | 206 | 207 | 208) echo "config" ;;
|
|
200 | 209 | 210 | 212 | 213 | 215 | 216 | 218 | 220 | 221 | 222 | 223 | 225 | 231) echo "proxmox" ;;
|
|
150 | 151 | 153 | 154) echo "service" ;;
|
|
170 | 171 | 172 | 173 | 180 | 181 | 182 | 183 | 190 | 191 | 192 | 193) echo "database" ;;
|
|
243 | 245 | 246 | 247 | 248 | 249) echo "runtime" ;;
|
|
129 | 130 | 143) echo "user_aborted" ;;
|
|
134 | 137) echo "resource" ;;
|
|
139 | 141) echo "signal" ;;
|
|
1 | 2) echo "shell" ;;
|
|
*) echo "unknown" ;;
|
|
esac
|
|
}
|
|
|
|
# ==============================================================================
|
|
# SECTION 2: JSON & LOG HELPERS
|
|
# ==============================================================================
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# json_escape()
|
|
#
|
|
# - Escapes a string for safe JSON embedding
|
|
# - Strips ANSI escape sequences and non-printable control characters
|
|
# - Uses jq when available (guaranteed correct), falls back to awk
|
|
# ------------------------------------------------------------------------------
|
|
json_escape() {
|
|
local input
|
|
input=$(printf '%s' "$1" |
|
|
sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' |
|
|
tr -d '\000-\010\013\014\016-\037\177\r')
|
|
|
|
if command -v jq &>/dev/null; then
|
|
printf '%s' "$input" | jq -Rs '.' | sed 's/^"//;s/"$//'
|
|
return
|
|
fi
|
|
|
|
# Fallback: character-by-character processing with awk
|
|
printf '%s' "$input" |
|
|
awk '
|
|
BEGIN { ORS="" }
|
|
{
|
|
if (NR > 1) printf "%s", "\\n"
|
|
for (i = 1; i <= length($0); i++) {
|
|
c = substr($0, i, 1)
|
|
if (c == "\\") printf "%s", "\\\\"
|
|
else if (c == "\"") printf "%s", "\\\""
|
|
else if (c == "\t") printf "%s", "\\t"
|
|
else printf "%s", c
|
|
}
|
|
}'
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# _tm_clean_log()
|
|
#
|
|
# - stdin filter: strips ANSI/CR, anonymizes IPs (GDPR), drops pure
|
|
# progress-noise lines (apt/dpkg download and unpack chatter)
|
|
# ------------------------------------------------------------------------------
|
|
_tm_clean_log() {
|
|
sed 's/\r$//' |
|
|
sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' |
|
|
sed -E 's/([0-9]{1,3}\.)[0-9]{1,3}\.[0-9]{1,3}/\1x.x/g' |
|
|
grep -avE '^(Get:|Hit:|Ign:|Fetched |Reading package lists|Reading state information|Building dependency tree|Selecting previously|Preparing to unpack|Unpacking |Processing triggers for|\(Reading database|[0-9]+%[[:space:]]*\[)' |
|
|
grep -avE '^[[:space:]]*$' || true
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# _tm_pick_logfile()
|
|
#
|
|
# - Returns the most specific available log file (combined → INSTALL_LOG →
|
|
# BUILD_LOG → SILENT_LOGFILE). Never downgrades a present log.
|
|
# ------------------------------------------------------------------------------
|
|
_tm_pick_logfile() {
|
|
local candidate
|
|
for candidate in \
|
|
"${combined_log:-}" \
|
|
"/tmp/install-${SESSION_ID:-}-combined.log" \
|
|
"/tmp/${NSAPP:-lxc}-${CTID:-}-${SESSION_ID:-}.log" \
|
|
"${INSTALL_LOG:-}" \
|
|
"${BUILD_LOG:-}" \
|
|
"${SILENT_LOGFILE:-}"; do
|
|
if [[ -n "$candidate" && -s "$candidate" ]]; then
|
|
echo "$candidate"
|
|
return 0
|
|
fi
|
|
done
|
|
return 0
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# get_error_log()
|
|
#
|
|
# - Returns the last N (default 50) RELEVANT lines of the active log
|
|
# - Fallback error source when no .errinfo capture exists
|
|
# ------------------------------------------------------------------------------
|
|
get_error_log() {
|
|
local max_lines="${1:-50}"
|
|
local logfile
|
|
|
|
if declare -f ensure_log_on_host >/dev/null 2>&1; then
|
|
ensure_log_on_host 2>/dev/null || true
|
|
fi
|
|
|
|
logfile=$(_tm_pick_logfile)
|
|
[[ -z "$logfile" || ! -s "$logfile" ]] && return 0
|
|
|
|
_tm_clean_log <"$logfile" | tail -n "$max_lines"
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# get_error_text() (legacy compatibility)
|
|
#
|
|
# - Returns last 20 relevant lines of the active log
|
|
# ------------------------------------------------------------------------------
|
|
get_error_text() {
|
|
get_error_log 20
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# get_full_log() (legacy compatibility - dev/debug use only)
|
|
#
|
|
# - Returns the full log, ANSI-stripped and IP-anonymized, capped at max_bytes
|
|
# ------------------------------------------------------------------------------
|
|
get_full_log() {
|
|
local max_bytes="${1:-122880}"
|
|
local logfile
|
|
if declare -f ensure_log_on_host >/dev/null 2>&1; then
|
|
ensure_log_on_host 2>/dev/null || true
|
|
fi
|
|
logfile=$(_tm_pick_logfile)
|
|
[[ -z "$logfile" || ! -s "$logfile" ]] && return 0
|
|
sed 's/\r$//' "$logfile" 2>/dev/null |
|
|
sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' |
|
|
sed -E 's/([0-9]{1,3}\.)[0-9]{1,3}\.[0-9]{1,3}/\1x.x/g' |
|
|
head -c "$max_bytes"
|
|
}
|
|
|
|
# ==============================================================================
|
|
# SECTION 3: STRUCTURED ERROR CAPTURE (.errinfo protocol)
|
|
# ==============================================================================
|
|
#
|
|
# When a command run via silent() fails, silent() writes "<logfile>.errinfo":
|
|
#
|
|
# EXIT_CODE=<n>
|
|
# LINE=<n>
|
|
# COMMAND=<the exact command, single line>
|
|
# --- OUTPUT ---
|
|
# <the failing command's OWN output (last 60 lines)>
|
|
#
|
|
# error_handler() writes the same file for non-silent failures (best effort:
|
|
# tail of the active log). The HOST reads this file (pulling it from the
|
|
# container if needed) and builds the telemetry error field from it.
|
|
# ==============================================================================
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# _tm_find_errinfo()
|
|
#
|
|
# - Locates the newest .errinfo file for this session
|
|
# - Honors TELEMETRY_ERRINFO override (set by the host after pulling the
|
|
# container's copy)
|
|
# ------------------------------------------------------------------------------
|
|
_tm_find_errinfo() {
|
|
local candidate
|
|
for candidate in \
|
|
"${TELEMETRY_ERRINFO:-}" \
|
|
"/tmp/.errinfo-${SESSION_ID:-none}" \
|
|
"${INSTALL_LOG:-/nonexistent}.errinfo" \
|
|
"${BUILD_LOG:-/nonexistent}.errinfo"; do
|
|
if [[ -n "$candidate" && -s "$candidate" ]]; then
|
|
echo "$candidate"
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# write_errinfo()
|
|
#
|
|
# - Writes a structured .errinfo file next to the given logfile
|
|
# - Arguments:
|
|
# * $1: target file path
|
|
# * $2: exit_code
|
|
# * $3: line number
|
|
# * $4: command (flattened to one line, truncated)
|
|
# * stdin: output segment of the failing command
|
|
# ------------------------------------------------------------------------------
|
|
write_errinfo() {
|
|
local target="$1" exit_code="$2" line="$3" command="$4"
|
|
command=$(printf '%s' "$command" | tr '\n' ' ' | head -c 300)
|
|
{
|
|
echo "EXIT_CODE=${exit_code}"
|
|
echo "LINE=${line}"
|
|
echo "COMMAND=${command}"
|
|
echo "--- OUTPUT ---"
|
|
_tm_clean_log | tail -n "${TELEMETRY_ERROR_MAX_LINES}" | head -c "${TELEMETRY_ERROR_MAX_BYTES}"
|
|
} >"$target" 2>/dev/null || true
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# build_error_string()
|
|
#
|
|
# - Builds the final structured error string for telemetry:
|
|
# "exit_code=N | <explanation> | at line L: <cmd>\n---\n<focused output>"
|
|
# - Sources, in priority order:
|
|
# 1. .errinfo file (exact output of the failing command)
|
|
# 2. FAILED_COMMAND/FAILED_LINE globals + filtered log tail
|
|
# 3. Explanation only
|
|
# - Arguments:
|
|
# * $1: exit_code
|
|
# * $2: log_text (optional override for the output section)
|
|
# ------------------------------------------------------------------------------
|
|
build_error_string() {
|
|
local exit_code="${1:-1}"
|
|
local log_text="${2:-}"
|
|
local explanation location="" cmd="" line="" errinfo=""
|
|
|
|
# Prefer the structured capture from the failure moment
|
|
if errinfo=$(_tm_find_errinfo); then
|
|
local captured_code
|
|
captured_code=$(sed -n 's/^EXIT_CODE=//p' "$errinfo" | head -1)
|
|
line=$(sed -n 's/^LINE=//p' "$errinfo" | head -1)
|
|
cmd=$(sed -n 's/^COMMAND=//p' "$errinfo" | head -1)
|
|
# Only trust the capture when it matches the reported failure (or the
|
|
# reported code is the generic 1 that bash propagates upward)
|
|
if [[ -n "$captured_code" && ("$captured_code" == "$exit_code" || "$exit_code" == "1" || "$exit_code" == "255") ]]; then
|
|
exit_code="$captured_code"
|
|
if [[ -z "$log_text" ]]; then
|
|
log_text=$(awk 'found{print} /^--- OUTPUT ---$/{found=1}' "$errinfo")
|
|
fi
|
|
else
|
|
cmd="" line=""
|
|
fi
|
|
fi
|
|
|
|
# Fall back to globals exported by silent()/error_handler
|
|
[[ -z "$cmd" && -n "${FAILED_COMMAND:-}" ]] && cmd="${FAILED_COMMAND}"
|
|
[[ -z "$line" && -n "${FAILED_LINE:-}" ]] && line="${FAILED_LINE}"
|
|
|
|
explanation=$(explain_exit_code "$exit_code")
|
|
|
|
if [[ -n "$cmd" ]]; then
|
|
if [[ -n "$line" ]]; then
|
|
location=$(printf ' | at line %s: %s' "$line" "$cmd")
|
|
else
|
|
location=$(printf ' | command: %s' "$cmd")
|
|
fi
|
|
fi
|
|
|
|
# Last-resort output: filtered tail of the active log
|
|
if [[ -z "$log_text" ]]; then
|
|
log_text=$(get_error_log 40) || true
|
|
fi
|
|
|
|
if [[ -n "$log_text" ]]; then
|
|
printf 'exit_code=%s | %s%s\n---\n%s' "$exit_code" "$explanation" "$location" "$log_text"
|
|
else
|
|
printf 'exit_code=%s | %s%s' "$exit_code" "$explanation" "$location"
|
|
fi
|
|
}
|
|
|
|
# ==============================================================================
|
|
# SECTION 4: SYSTEM INFO COLLECTION (collected once, sent with EVERY payload)
|
|
# ==============================================================================
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# detect_gpu() - GPU vendor, model, passthrough type
|
|
# ------------------------------------------------------------------------------
|
|
detect_gpu() {
|
|
GPU_VENDOR="unknown"
|
|
GPU_MODEL=""
|
|
GPU_PASSTHROUGH="unknown"
|
|
|
|
local gpu_line
|
|
gpu_line=$(lspci 2>/dev/null | grep -iE "VGA|3D|Display" | head -1 || true)
|
|
|
|
if [[ -n "$gpu_line" ]]; then
|
|
GPU_MODEL=$(echo "$gpu_line" | sed 's/.*: //' | sed 's/ (rev .*)$//' | cut -c1-64)
|
|
|
|
if echo "$gpu_line" | grep -qi "Intel"; then
|
|
GPU_VENDOR="intel"
|
|
GPU_PASSTHROUGH="igpu"
|
|
elif echo "$gpu_line" | grep -qi "AMD\|ATI"; then
|
|
GPU_VENDOR="amd"
|
|
if echo "$gpu_line" | grep -qi "Radeon RX\|Radeon Pro"; then
|
|
GPU_PASSTHROUGH="dgpu"
|
|
else
|
|
GPU_PASSTHROUGH="igpu"
|
|
fi
|
|
elif echo "$gpu_line" | grep -qi "NVIDIA"; then
|
|
GPU_VENDOR="nvidia"
|
|
GPU_PASSTHROUGH="dgpu"
|
|
fi
|
|
fi
|
|
|
|
export GPU_VENDOR GPU_MODEL GPU_PASSTHROUGH
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# detect_cpu() - CPU vendor and model
|
|
# ------------------------------------------------------------------------------
|
|
detect_cpu() {
|
|
CPU_VENDOR="unknown"
|
|
CPU_MODEL=""
|
|
|
|
if [[ -f /proc/cpuinfo ]]; then
|
|
local vendor_id
|
|
vendor_id=$(grep -m1 "vendor_id" /proc/cpuinfo 2>/dev/null | cut -d: -f2 | tr -d ' ' || true)
|
|
|
|
case "$vendor_id" in
|
|
GenuineIntel) CPU_VENDOR="intel" ;;
|
|
AuthenticAMD) CPU_VENDOR="amd" ;;
|
|
*)
|
|
if grep -qi "CPU implementer" /proc/cpuinfo 2>/dev/null; then
|
|
CPU_VENDOR="arm"
|
|
fi
|
|
;;
|
|
esac
|
|
|
|
CPU_MODEL=$(grep -m1 "model name" /proc/cpuinfo 2>/dev/null | cut -d: -f2 | sed 's/^ *//' | sed 's/(R)//g' | sed 's/(TM)//g' | sed 's/ */ /g' | cut -c1-64 || true)
|
|
fi
|
|
|
|
export CPU_VENDOR CPU_MODEL
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# detect_ram() - RAM speed via dmidecode (empty in nested VMs)
|
|
# ------------------------------------------------------------------------------
|
|
detect_ram() {
|
|
RAM_SPEED=""
|
|
|
|
if command -v dmidecode &>/dev/null; then
|
|
RAM_SPEED=$(dmidecode -t memory 2>/dev/null | grep -m1 "Configured Memory Speed:" | grep -oE "[0-9]+" | head -1) || true
|
|
if [[ -z "$RAM_SPEED" ]]; then
|
|
RAM_SPEED=$(dmidecode -t memory 2>/dev/null | grep -m1 "Speed:" | grep -oE "[0-9]+" | head -1) || true
|
|
fi
|
|
fi
|
|
|
|
export RAM_SPEED
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# detect_arm() - true when running on arm64 hardware
|
|
# ------------------------------------------------------------------------------
|
|
detect_arm() {
|
|
HAS_ARM="false"
|
|
case "$(dpkg --print-architecture 2>/dev/null || uname -m)" in
|
|
arm64 | aarch64) HAS_ARM="true" ;;
|
|
esac
|
|
export HAS_ARM
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# telemetry_collect_sysinfo()
|
|
#
|
|
# - Collects ALL system metadata exactly once (cached via _TM_SYSINFO_DONE)
|
|
# - Sets: CPU_VENDOR, CPU_MODEL, GPU_VENDOR, GPU_MODEL, GPU_PASSTHROUGH,
|
|
# RAM_SPEED, HAS_ARM, TM_ARCH, TM_PVE_VERSION, TM_PLATFORM
|
|
# ------------------------------------------------------------------------------
|
|
telemetry_collect_sysinfo() {
|
|
[[ "${_TM_SYSINFO_DONE:-}" == "true" ]] && return 0
|
|
|
|
[[ -z "${GPU_VENDOR:-}" ]] && detect_gpu
|
|
[[ -z "${CPU_VENDOR:-}" ]] && detect_cpu
|
|
[[ -z "${RAM_SPEED+x}" ]] && detect_ram
|
|
[[ -z "${HAS_ARM:-}" ]] && detect_arm
|
|
|
|
TM_ARCH="$(dpkg --print-architecture 2>/dev/null || uname -m || echo unknown)"
|
|
|
|
# Virtualization platform: pve (Proxmox VE) or incus.
|
|
# Containers inherit TELEMETRY_PLATFORM from the host environment.
|
|
TM_PLATFORM="${TELEMETRY_PLATFORM:-}"
|
|
TM_PVE_VERSION=""
|
|
if command -v pveversion &>/dev/null; then
|
|
TM_PVE_VERSION=$(pveversion 2>/dev/null | awk -F'[/ ]' '{print $2}') || true
|
|
TM_PLATFORM="${TM_PLATFORM:-pve}"
|
|
elif command -v incus &>/dev/null; then
|
|
TM_PVE_VERSION="incus-$(incus version 2>/dev/null | head -1 | awk '{print $NF}')" || true
|
|
TM_PLATFORM="${TM_PLATFORM:-incus}"
|
|
fi
|
|
|
|
_TM_SYSINFO_DONE=true
|
|
export TM_ARCH TM_PVE_VERSION TM_PLATFORM _TM_SYSINFO_DONE
|
|
}
|
|
|
|
# ==============================================================================
|
|
# SECTION 5: PAYLOAD BUILDER & SENDER
|
|
# ==============================================================================
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# _tm_enabled()
|
|
#
|
|
# - Central gate: curl present, DIAGNOSTICS=yes, RANDOM_UUID set
|
|
# ------------------------------------------------------------------------------
|
|
_tm_enabled() {
|
|
command -v curl &>/dev/null || return 1
|
|
[[ "${DIAGNOSTICS:-no}" == "no" ]] && return 1
|
|
[[ -z "${RANDOM_UUID:-}" ]] && return 1
|
|
return 0
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# _tm_payload()
|
|
#
|
|
# - Builds the COMPLETE JSON payload. Every send (installing, progress ping,
|
|
# final status) carries the full metadata so no record is ever empty,
|
|
# regardless of which event the server persists.
|
|
# - Arguments:
|
|
# * $1: status
|
|
# * $2: exit_code (optional, default 0)
|
|
# * $3: error text (optional, raw - will be JSON-escaped here)
|
|
# ------------------------------------------------------------------------------
|
|
_tm_payload() {
|
|
local status="$1"
|
|
local exit_code="${2:-0}"
|
|
local error_raw="${3:-}"
|
|
|
|
telemetry_collect_sysinfo
|
|
|
|
# Numeric sanitation (server rejects out-of-range values)
|
|
[[ ! "$exit_code" =~ ^[0-9]+$ ]] && exit_code=1
|
|
((exit_code > 255)) && exit_code=255
|
|
|
|
local disk_size="${DISK_SIZE:-0}"
|
|
disk_size="${disk_size%G}"
|
|
[[ ! "$disk_size" =~ ^[0-9]+$ ]] && disk_size=0
|
|
|
|
local duration=0
|
|
if [[ -n "${INSTALL_START_TIME:-}" ]]; then
|
|
duration=$(($(date +%s) - INSTALL_START_TIME))
|
|
((duration < 0)) && duration=0
|
|
((duration > 86400)) && duration=86400
|
|
fi
|
|
|
|
local error_json="" error_category=""
|
|
if [[ -n "$error_raw" ]]; then
|
|
error_json=$(json_escape "$error_raw")
|
|
error_category=$(categorize_error "$exit_code")
|
|
fi
|
|
|
|
local gpu_model cpu_model
|
|
gpu_model=$(json_escape "${GPU_MODEL:-}")
|
|
cpu_model=$(json_escape "${CPU_MODEL:-}")
|
|
|
|
cat <<EOF
|
|
{
|
|
"random_id": "${RANDOM_UUID}",
|
|
"execution_id": "${EXECUTION_ID:-${RANDOM_UUID}}",
|
|
"type": "${TELEMETRY_TYPE:-lxc}",
|
|
"nsapp": "${NSAPP:-${app:-unknown}}",
|
|
"status": "${status}",
|
|
"ct_type": ${CT_TYPE:-1},
|
|
"disk_size": ${disk_size},
|
|
"core_count": ${CORE_COUNT:-0},
|
|
"ram_size": ${RAM_SIZE:-0},
|
|
"os_type": "${var_os:-}",
|
|
"os_version": "${var_version:-}",
|
|
"pve_version": "${TM_PVE_VERSION:-}",
|
|
"method": "${METHOD:-default}",
|
|
"exit_code": ${exit_code},
|
|
"error": "${error_json}",
|
|
"error_category": "${error_category}",
|
|
"install_duration": ${duration},
|
|
"cpu_vendor": "${CPU_VENDOR:-unknown}",
|
|
"cpu_model": "${cpu_model}",
|
|
"gpu_vendor": "${GPU_VENDOR:-unknown}",
|
|
"gpu_model": "${gpu_model}",
|
|
"gpu_passthrough": "${GPU_PASSTHROUGH:-unknown}",
|
|
"ram_speed": "${RAM_SPEED:-}",
|
|
"arch": "${TM_ARCH:-}",
|
|
"platform": "${TM_PLATFORM:-}",
|
|
"has_arm": ${HAS_ARM:-false},
|
|
"repo_source": "${REPO_SOURCE}",
|
|
"repo_slug": "${REPO_SLUG:-}"
|
|
}
|
|
EOF
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# _tm_send()
|
|
#
|
|
# - Single curl wrapper for all telemetry sends
|
|
# - Arguments:
|
|
# * $1: JSON payload
|
|
# * $2: timeout seconds (default TELEMETRY_TIMEOUT)
|
|
# * $3: attempts (default 1)
|
|
# - Returns 0 on HTTP 2xx, 1 otherwise. Never raises errors.
|
|
# ------------------------------------------------------------------------------
|
|
_tm_send() {
|
|
local payload="$1"
|
|
local timeout="${2:-$TELEMETRY_TIMEOUT}"
|
|
local attempts="${3:-1}"
|
|
local http_code attempt
|
|
|
|
for ((attempt = 1; attempt <= attempts; attempt++)); do
|
|
if [[ "${DEV_MODE:-}" == "true" ]]; then
|
|
echo "[DEBUG] telemetry POST (attempt ${attempt}/${attempts}): $payload" >&2
|
|
fi
|
|
http_code=$(curl -sS -w "%{http_code}" -m "$timeout" -X POST "${TELEMETRY_URL}" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$payload" -o /dev/null 2>/dev/null) || http_code="000"
|
|
[[ "${DEV_MODE:-}" == "true" ]] && echo "[DEBUG] telemetry HTTP=${http_code}" >&2
|
|
if [[ "$http_code" =~ ^2[0-9]{2}$ ]]; then
|
|
return 0
|
|
fi
|
|
((attempt < attempts)) && sleep 1
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# ==============================================================================
|
|
# SECTION 6: PUBLIC TELEMETRY API
|
|
# ==============================================================================
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# post_to_api()
|
|
#
|
|
# - Sends the initial "installing" record for LXC container creation
|
|
# - Full metadata payload; retried up to 3x
|
|
# - Idempotent per execution (POST_TO_API_DONE)
|
|
# ------------------------------------------------------------------------------
|
|
post_to_api() {
|
|
[[ "${POST_TO_API_DONE:-}" == "true" ]] && return 0
|
|
_tm_enabled || return 0
|
|
|
|
TELEMETRY_TYPE="${TELEMETRY_TYPE:-lxc}"
|
|
|
|
local payload
|
|
payload=$(_tm_payload "installing")
|
|
|
|
if _tm_send "$payload" "$TELEMETRY_TIMEOUT" 3; then
|
|
POST_TO_API_DONE=true
|
|
else
|
|
POST_TO_API_DONE=false
|
|
fi
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# post_to_api_vm()
|
|
#
|
|
# - Sends the initial "installing" record for VM creation
|
|
# - Reads DIAGNOSTICS from /usr/local/community-scripts/diagnostics
|
|
# ------------------------------------------------------------------------------
|
|
post_to_api_vm() {
|
|
[[ "${POST_TO_API_DONE:-}" == "true" ]] && return 0
|
|
|
|
if [[ -f /usr/local/community-scripts/diagnostics ]]; then
|
|
DIAGNOSTICS=$(grep -i "^DIAGNOSTICS=" /usr/local/community-scripts/diagnostics 2>/dev/null | awk -F'=' '{print $2}') || true
|
|
fi
|
|
|
|
_tm_enabled || return 0
|
|
|
|
TELEMETRY_TYPE="vm"
|
|
CT_TYPE=2
|
|
|
|
local payload
|
|
payload=$(_tm_payload "installing")
|
|
|
|
if _tm_send "$payload" "$TELEMETRY_TIMEOUT" 3; then
|
|
POST_TO_API_DONE=true
|
|
else
|
|
POST_TO_API_DONE=false
|
|
fi
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# post_progress_to_api()
|
|
#
|
|
# - Progress ping ("validation" / "configuring")
|
|
# - Sends the FULL payload (not a minimal one) so that even if the server
|
|
# ever has to fall back to a progress row, it carries all metadata
|
|
# - Fire-and-forget, single attempt
|
|
# ------------------------------------------------------------------------------
|
|
post_progress_to_api() {
|
|
_tm_enabled || return 0
|
|
|
|
local progress_status="${1:-configuring}"
|
|
local payload
|
|
payload=$(_tm_payload "$progress_status")
|
|
_tm_send "$payload" "$TELEMETRY_TIMEOUT" 1 || true
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# post_update_to_api()
|
|
#
|
|
# - Reports the FINAL installation status. This is the single most important
|
|
# telemetry event - it must carry full metadata AND the focused error trace.
|
|
# - Arguments:
|
|
# * $1: status ("done"/"success" | "failed" | "aborted")
|
|
# * $2: exit_code (numeric; "none" → 0)
|
|
# * $3: "force" to bypass the duplicate guard (new information available)
|
|
# - Signal exit codes (129/130/143) are reported as "aborted", not "failed"
|
|
# - Idempotent via POST_UPDATE_DONE
|
|
# ------------------------------------------------------------------------------
|
|
post_update_to_api() {
|
|
command -v curl &>/dev/null || return 0
|
|
|
|
local status="${1:-failed}"
|
|
local raw_exit_code="${2:-1}"
|
|
local force="${3:-}"
|
|
|
|
POST_UPDATE_DONE=${POST_UPDATE_DONE:-false}
|
|
if [[ "$POST_UPDATE_DONE" == "true" && "$force" != "force" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
[[ "${DIAGNOSTICS:-no}" == "no" ]] && return 0
|
|
[[ -z "${RANDOM_UUID:-}" ]] && return 0
|
|
|
|
local exit_code=0
|
|
if [[ "$raw_exit_code" =~ ^[0-9]+$ ]]; then
|
|
exit_code="$raw_exit_code"
|
|
elif [[ "$raw_exit_code" == "none" ]]; then
|
|
exit_code=0
|
|
else
|
|
exit_code=1
|
|
fi
|
|
|
|
local pb_status error_raw=""
|
|
case "$status" in
|
|
done | success)
|
|
pb_status="success"
|
|
exit_code=0
|
|
;;
|
|
aborted)
|
|
pb_status="aborted"
|
|
;;
|
|
failed)
|
|
# Signal-based exits are user aborts, not installation failures
|
|
case "$exit_code" in
|
|
129 | 130 | 143) pb_status="aborted" ;;
|
|
*) pb_status="failed" ;;
|
|
esac
|
|
;;
|
|
*)
|
|
pb_status="unknown"
|
|
;;
|
|
esac
|
|
|
|
if [[ "$pb_status" == "failed" || "$pb_status" == "unknown" ]]; then
|
|
error_raw=$(build_error_string "$exit_code")
|
|
elif [[ "$pb_status" == "aborted" ]]; then
|
|
# Short context line only - no log dump for user aborts
|
|
error_raw="exit_code=${exit_code} | $(explain_exit_code "$exit_code")"
|
|
ERROR_CATEGORY_OVERRIDE="${ERROR_CATEGORY_OVERRIDE:-user_aborted}"
|
|
fi
|
|
|
|
local payload
|
|
payload=$(_tm_payload "$pb_status" "$exit_code" "$error_raw")
|
|
|
|
if _tm_send "$payload" "$STATUS_TIMEOUT" 2; then
|
|
POST_UPDATE_DONE=true
|
|
return 0
|
|
fi
|
|
|
|
# Retry with a smaller error trace (in case the payload was the problem)
|
|
if [[ -n "$error_raw" ]]; then
|
|
local short_error
|
|
short_error="exit_code=${exit_code} | $(explain_exit_code "$exit_code")"
|
|
[[ -n "${FAILED_COMMAND:-}" ]] && short_error+=" | at line ${FAILED_LINE:-?}: $(printf '%s' "$FAILED_COMMAND" | head -c 200)"
|
|
payload=$(_tm_payload "$pb_status" "$exit_code" "$short_error")
|
|
if _tm_send "$payload" "$STATUS_TIMEOUT" 2; then
|
|
POST_UPDATE_DONE=true
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
# All attempts failed - leave POST_UPDATE_DONE=false so the EXIT trap can retry
|
|
return 0
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# telemetry_new_attempt()
|
|
#
|
|
# - Resets telemetry state for a RETRY of the same session (recovery menu:
|
|
# rebuild, OOM retry, DNS retry, APT repair). Each attempt becomes its own
|
|
# execution on the server (the previous one keeps its terminal status).
|
|
# ------------------------------------------------------------------------------
|
|
telemetry_new_attempt() {
|
|
EXECUTION_ID="$(cat /proc/sys/kernel/random/uuid 2>/dev/null || echo "${RANDOM_UUID}-r$RANDOM")"
|
|
POST_TO_API_DONE=false
|
|
POST_UPDATE_DONE=false
|
|
unset FAILED_COMMAND FAILED_LINE ERROR_CATEGORY_OVERRIDE TELEMETRY_ERRINFO
|
|
export EXECUTION_ID
|
|
}
|
|
|
|
# ==============================================================================
|
|
# SECTION 7: TIMERS
|
|
# ==============================================================================
|
|
|
|
start_install_timer() {
|
|
INSTALL_START_TIME=$(date +%s)
|
|
export INSTALL_START_TIME
|
|
}
|
|
|
|
get_install_duration() {
|
|
if [[ -z "${INSTALL_START_TIME:-}" ]]; then
|
|
echo "0"
|
|
return
|
|
fi
|
|
local now
|
|
now=$(date +%s)
|
|
echo $((now - INSTALL_START_TIME))
|
|
}
|
|
|
|
# ==============================================================================
|
|
# SECTION 8: TOOLS & ADDON TELEMETRY
|
|
# ==============================================================================
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# _telemetry_report_exit() - EXIT trap handler set by init_tool_telemetry()
|
|
# ------------------------------------------------------------------------------
|
|
_telemetry_report_exit() {
|
|
local ec="${1:-0}"
|
|
local status="success"
|
|
[[ "$ec" -ne 0 ]] && status="failed"
|
|
|
|
local name="${TELEMETRY_TOOL_NAME:-${APP:-unknown}}"
|
|
|
|
if [[ "${TELEMETRY_TOOL_TYPE:-pve}" == "addon" ]]; then
|
|
post_addon_to_api "$name" "$status" "$ec"
|
|
else
|
|
post_tool_to_api "$name" "$status" "$ec"
|
|
fi
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# init_tool_telemetry()
|
|
#
|
|
# - One-line telemetry setup for tools/addon scripts
|
|
# - Arguments:
|
|
# * $1: tool_name (optional, falls back to $APP at exit time)
|
|
# * $2: type ("pve" for PVE host scripts, "addon" for container addons)
|
|
# ------------------------------------------------------------------------------
|
|
init_tool_telemetry() {
|
|
local name="${1:-}"
|
|
local type="${2:-pve}"
|
|
|
|
[[ -n "$name" ]] && TELEMETRY_TOOL_NAME="$name"
|
|
TELEMETRY_TOOL_TYPE="$type"
|
|
|
|
if [[ -f /usr/local/community-scripts/diagnostics ]]; then
|
|
DIAGNOSTICS=$(grep -i "^DIAGNOSTICS=" /usr/local/community-scripts/diagnostics 2>/dev/null | awk -F'=' '{print $2}') || true
|
|
fi
|
|
|
|
start_install_timer
|
|
|
|
trap '_telemetry_report_exit "$?"' EXIT
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# post_tool_to_api() - PVE host tool usage report
|
|
# ------------------------------------------------------------------------------
|
|
post_tool_to_api() {
|
|
command -v curl &>/dev/null || return 0
|
|
[[ "${DIAGNOSTICS:-no}" == "no" ]] && return 0
|
|
|
|
local tool_name="${1:-unknown}"
|
|
local status="${2:-success}"
|
|
local exit_code="${3:-0}"
|
|
local error="" error_category=""
|
|
local uuid duration
|
|
|
|
uuid=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen 2>/dev/null || echo "tool-$(date +%s)")
|
|
duration=$(get_install_duration)
|
|
|
|
[[ "$status" == "done" ]] && status="success"
|
|
|
|
if [[ "$status" == "failed" ]]; then
|
|
[[ ! "$exit_code" =~ ^[0-9]+$ ]] && exit_code=1
|
|
error=$(json_escape "$(build_error_string "$exit_code")")
|
|
error_category=$(categorize_error "$exit_code")
|
|
fi
|
|
|
|
telemetry_collect_sysinfo
|
|
|
|
local JSON_PAYLOAD
|
|
JSON_PAYLOAD=$(
|
|
cat <<EOF
|
|
{
|
|
"random_id": "${uuid}",
|
|
"execution_id": "${uuid}",
|
|
"type": "pve",
|
|
"nsapp": "${tool_name}",
|
|
"status": "${status}",
|
|
"exit_code": ${exit_code},
|
|
"error": "${error}",
|
|
"error_category": "${error_category}",
|
|
"install_duration": ${duration:-0},
|
|
"pve_version": "${TM_PVE_VERSION:-}",
|
|
"platform": "${TM_PLATFORM:-}",
|
|
"repo_source": "${REPO_SOURCE}",
|
|
"repo_slug": "${REPO_SLUG:-}"
|
|
}
|
|
EOF
|
|
)
|
|
|
|
_tm_send "$JSON_PAYLOAD" "$TELEMETRY_TIMEOUT" 1 || true
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# post_addon_to_api() - Addon installation report (runs inside containers)
|
|
# ------------------------------------------------------------------------------
|
|
post_addon_to_api() {
|
|
command -v curl &>/dev/null || return 0
|
|
[[ "${DIAGNOSTICS:-no}" == "no" ]] && return 0
|
|
|
|
local addon_name="${1:-unknown}"
|
|
local status="${2:-success}"
|
|
local exit_code="${3:-0}"
|
|
local error="" error_category=""
|
|
local uuid duration
|
|
|
|
uuid=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen 2>/dev/null || echo "addon-$(date +%s)")
|
|
duration=$(get_install_duration)
|
|
|
|
[[ "$status" == "done" ]] && status="success"
|
|
|
|
if [[ "$status" == "failed" ]]; then
|
|
[[ ! "$exit_code" =~ ^[0-9]+$ ]] && exit_code=1
|
|
error=$(json_escape "$(build_error_string "$exit_code")")
|
|
error_category=$(categorize_error "$exit_code")
|
|
fi
|
|
|
|
local os_type="" os_version=""
|
|
if [[ -f /etc/os-release ]]; then
|
|
os_type=$(grep "^ID=" /etc/os-release | cut -d= -f2 | tr -d '"' || true)
|
|
os_version=$(grep "^VERSION_ID=" /etc/os-release | cut -d= -f2 | tr -d '"' || true)
|
|
fi
|
|
|
|
local JSON_PAYLOAD
|
|
JSON_PAYLOAD=$(
|
|
cat <<EOF
|
|
{
|
|
"random_id": "${uuid}",
|
|
"execution_id": "${uuid}",
|
|
"type": "addon",
|
|
"nsapp": "${addon_name}",
|
|
"status": "${status}",
|
|
"exit_code": ${exit_code},
|
|
"error": "${error}",
|
|
"error_category": "${error_category}",
|
|
"install_duration": ${duration:-0},
|
|
"os_type": "${os_type}",
|
|
"os_version": "${os_version}",
|
|
"platform": "${TELEMETRY_PLATFORM:-}",
|
|
"repo_source": "${REPO_SOURCE}",
|
|
"repo_slug": "${REPO_SLUG:-}"
|
|
}
|
|
EOF
|
|
)
|
|
|
|
_tm_send "$JSON_PAYLOAD" "$TELEMETRY_TIMEOUT" 1 || true
|
|
}
|