From a1067fd0495f741c47ee0c0d2403dea1d6998c1e Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 28 Nov 2025 16:25:45 +0100 Subject: [PATCH 1/8] bin: add system support data collection Signed-off-by: Joachim Wiberg --- src/bin/Makefile.am | 1 + src/bin/support | 450 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 451 insertions(+) create mode 100755 src/bin/support diff --git a/src/bin/Makefile.am b/src/bin/Makefile.am index f33dd3e9e..e2c4ebf83 100644 --- a/src/bin/Makefile.am +++ b/src/bin/Makefile.am @@ -2,6 +2,7 @@ DISTCLEANFILES = *~ *.d ACLOCAL_AMFLAGS = -I m4 bin_PROGRAMS = copy erase files +sbin_SCRIPTS = support # Bash completion bashcompdir = $(datadir)/bash-completion/completions diff --git a/src/bin/support b/src/bin/support new file mode 100755 index 000000000..d1de72eeb --- /dev/null +++ b/src/bin/support @@ -0,0 +1,450 @@ +#!/bin/sh +# Support utilities for troubleshooting Infix systems +# +# Usage: support [options] +# +# Commands: +# collect [--log-sec N] Collect system information for support/troubleshooting +# clean [--dry-run] [--days N] Remove old support collection directories +# +# Options for collect: +# --log-sec N Tail /var/log/messages for N seconds (default: 30) +# +# Examples: +# support collect > support-data.tar.gz +# support collect --log-sec 5 > support-data.tar.gz +# ssh user@device support collect > support-data.tar.gz +# support clean --dry-run +# support clean --days 30 +# +# Installation (for older Infix versions without this script): +# 1. Copy this script to the target device's home directory: +# scp support user@device:~/ +# +# 2. SSH to the device and make it executable: +# ssh user@device +# chmod +x ~/support +# +# 3. Run the script from your home directory: +# ~/support collect > support-data.tar.gz +# # Or directly via SSH from your workstation: +# ssh user@device '~/support collect' > support-data.tar.gz +# +# TODO: +# Add more commands, e.g., verify (run health checks), upload , +# test , backup, watch (dashboard view), diff +# + +cmd_collect() +{ + # Default values + LOG_TAIL_SEC=30 + + # Parse options + while [ $# -gt 0 ]; do + case "$1" in + --log-sec) + if [ -z "$2" ] || [ "$2" -le 0 ] 2>/dev/null; then + echo "Error: --log-sec requires a positive number" >&2 + exit 1 + fi + LOG_TAIL_SEC="$2" + shift 2 + ;; + *) + echo "Error: Unknown option '$1'" >&2 + echo "Usage: support collect [--log-sec N]" >&2 + exit 1 + ;; + esac + done + + # Create unique collection directory in user's home directory + # (avoids /tmp which may be full) + COLLECT_DIR="${HOME}/support-$(hostname -s)-$(date -Iseconds)" + EXEC_LOG="${COLLECT_DIR}/collection.log" + + # Cleanup on exit + cleanup() + { + if [ -d "${COLLECT_DIR}" ]; then + rm -rf "${COLLECT_DIR}" + fi + } + trap cleanup EXIT INT TERM + + # Create collection directory + mkdir -p "${COLLECT_DIR}" + + # Determine if we need sudo and if it's available + SUDO="" + if [ "$(id -u)" -ne 0 ]; then + if command -v sudo >/dev/null 2>&1; then + SUDO="sudo" + fi + fi + + # Helper function to run commands with output to specific file + collect() + { + output_file="$1" + shift + cmd_desc="$*" + + mkdir -p "${COLLECT_DIR}/$(dirname "$output_file")" + echo "[$(date -Iseconds)] Collecting: $cmd_desc -> ${output_file}" >> "${EXEC_LOG}" 2>&1 + if "$@" > "${COLLECT_DIR}/${output_file}" 2>> "${EXEC_LOG}"; then + echo "[$(date -Iseconds)] Success: ${output_file}" >> "${EXEC_LOG}" 2>&1 + else + exit_code=$? + echo "[$(date -Iseconds)] Failed (exit ${exit_code}): ${output_file}" >> "${EXEC_LOG}" 2>&1 + # Create placeholder file indicating failure + echo "Command failed with exit code ${exit_code}: $cmd_desc" > "${COLLECT_DIR}/${output_file}" + fi + } + + # Start collection log + echo "=== Infix Support Data Collection ===" > "${EXEC_LOG}" + echo "Started: $(date -Iseconds)" >> "${EXEC_LOG}" + echo "Hostname: $(hostname)" >> "${EXEC_LOG}" + echo "Collection directory: ${COLLECT_DIR}" >> "${EXEC_LOG}" + echo "" >> "${EXEC_LOG}" + + # Inform user that collection is starting (to stderr for SSH visibility) + echo "Starting support data collection from $(hostname)..." >&2 + echo "This may take up to a minute. Please wait..." >&2 + + # System identification + collect system-info.txt uname -a + collect hostname.txt hostname + collect uptime.txt uptime + + # Configuration files + collect running-config.json sysrepocfg -f json -d running -X + collect operational-config.json sysrepocfg -f json -d operational -X + + # Sysrepo YANG modules + if command -v sysrepoctl >/dev/null 2>&1; then + collect sysrepo-modules.txt sysrepoctl -l + fi + + # Startup config (may not exist on first boot) + if [ -f /cfg/startup-config.cfg ]; then + cp /cfg/startup-config.cfg "${COLLECT_DIR}/startup-config.cfg" 2>> "${EXEC_LOG}" + else + echo "No startup-config.cfg found" > "${COLLECT_DIR}/startup-config.cfg" + fi + + # System logs and runtime data + if [ -d /var/log ]; then + if ls -A /var/log >/dev/null 2>&1; then + tar czf "${COLLECT_DIR}/logs.tar.gz" -C / var/log 2>> "${EXEC_LOG}" || \ + echo "Failed to collect /var/log" > "${COLLECT_DIR}/logs.tar.gz.error" + else + echo "No logs in /var/log" > "${COLLECT_DIR}/logs.tar.gz.error" + fi + fi + + # Collect crash dumps if they exist + if [ -d /var/crash ]; then + if ls -A /var/crash >/dev/null 2>&1; then + tar czf "${COLLECT_DIR}/crash.tar.gz" -C / var/crash 2>> "${EXEC_LOG}" || \ + echo "Failed to collect /var/crash" > "${COLLECT_DIR}/crash.tar.gz.error" + else + echo "No crash dumps in /var/crash" > "${COLLECT_DIR}/crash.tar.gz.error" + fi + fi + + # Tail /var/log/messages to capture live logging + if [ -f /var/log/messages ]; then + echo "Tailing /var/log/messages for ${LOG_TAIL_SEC} seconds (please wait)..." >&2 + { + echo "=== Starting tail of /var/log/messages for ${LOG_TAIL_SEC} seconds ===" + timeout "${LOG_TAIL_SEC}" tail -F /var/log/messages 2>/dev/null || true + echo "" + echo "=== End of ${LOG_TAIL_SEC}-second tail ===" + } > "${COLLECT_DIR}/logs-tail-${LOG_TAIL_SEC}s.txt" 2>> "${EXEC_LOG}" + echo "Log tail complete." >&2 + fi + + if [ -d /run/net ]; then + if ls -A /run/net >/dev/null 2>&1; then + tar czf "${COLLECT_DIR}/run-net.tar.gz" -C / run/net 2>> "${EXEC_LOG}" || \ + echo "Failed to collect /run/net" > "${COLLECT_DIR}/run-net.tar.gz.error" + else + echo "No data in /run/net" > "${COLLECT_DIR}/run-net.tar.gz.error" + fi + fi + + if [ -d /run/finit ]; then + if ls -A /run/finit >/dev/null 2>&1; then + tar czf "${COLLECT_DIR}/run-finit.tar.gz" -C / run/finit 2>> "${EXEC_LOG}" || \ + echo "Failed to collect /run/finit" > "${COLLECT_DIR}/run-finit.tar.gz.error" + else + echo "No data in /run/finit" > "${COLLECT_DIR}/run-finit.tar.gz.error" + fi + fi + + # Kernel and system state + collect system/dmesg.txt ${SUDO} dmesg + collect system/free.txt free -h + collect system/stat.txt cat /proc/stat + collect system/softirqs.txt cat /proc/softirqs + collect system/locks.txt cat /proc/locks + collect system/meminfo.txt cat /proc/meminfo + collect system/file-nr.txt cat /proc/sys/fs/file-nr + collect system/ps.txt ps -o pid,rss,comm + collect system/df.txt df -h + + # Finit/init state + if command -v initctl >/dev/null 2>&1; then + collect initctl/cgroup.txt initctl cgroup + collect initctl/status.txt initctl status + fi + + # CPU statistics + if command -v mpstat >/dev/null 2>&1; then + collect system/mpstat.txt mpstat -P ALL 1 1 + else + echo "mpstat not available" > "${COLLECT_DIR}/mpstat.txt" + fi + + # Top output (two samples) + collect system/top.txt sh -c 'top -b -n 2 -d 2' + + # Interrupt statistics (before and after 2 second delay) + collect system/interrupts1.txt cat /proc/interrupts + sleep 2 + collect system/interrupts2.txt cat /proc/interrupts + + # Network information + collect network/ip/addr.txt ip -s -d -j addr show + collect network/ip/route.txt ip -s -d -j route show + collect network/ip/link.txt ip -s -d -j link show + collect network/ip/neigh.txt ip -s -d -j neigh show + collect network/ifconfig.txt ifconfig -a + + # Collect ethtool information for all ethernet interfaces + if command -v ethtool >/dev/null 2>&1; then + # Get list of ethernet interfaces (any interface with link/ether) + ip -o link show | grep 'link/ether' | awk -F': ' '{print $2}' > "${COLLECT_DIR}/.iface-list" 2>> "${EXEC_LOG}" + if [ -s "${COLLECT_DIR}/.iface-list" ]; then + while IFS= read -r iface; do + # ethtool typically needs root/sudo + collect "network/ethtool/${iface}.txt" ${SUDO} ethtool "$iface" + collect "network/ethtool/stats-${iface}.txt" ${SUDO} ethtool -S "$iface" + collect "network/ethtool/module-${iface}.txt" ${SUDO} ethtool -m "$iface" + done < "${COLLECT_DIR}/.iface-list" + fi + rm -f "${COLLECT_DIR}/.iface-list" + fi + + if command -v bridge >/dev/null 2>&1; then + collect network/bridge/link.txt bridge -d -s -j link show + collect network/bridge/fdb.txt bridge -d -s -j fdb show + fi + + # Firewall rules + if command -v nft >/dev/null 2>&1; then + collect network/nftables.txt ${SUDO} nft list ruleset + fi + + # FRR routing information + if command -v vtysh >/dev/null 2>&1; then + collect frr/running-config.txt vtysh -c "show running-config" + collect frr/ip-route.txt vtysh -c "show ip route" + collect frr/ospf-interfaces.txt vtysh -c "show ip ospf interfaces" + collect frr/ospf-neighbor.txt vtysh -c "show ip ospf neighbor" + collect frr/ospf-routes.txt vtysh -c "show ip ospf routes" + collect frr/bgp-summary.txt vtysh -c "show ip bgp summary" + collect frr/bfd-peers.txt vtysh -c "show bfd peers" + fi + + # Container information + if command -v podman >/dev/null 2>&1; then + # List all containers + collect podman/ps.txt podman ps -a + + # Collect podman system info + collect podman/info.json podman info --format=json + + # Get list of containers and inspect each + if podman ps -a --format '{{.Names}}' > "${COLLECT_DIR}/.container-list" 2>> "${EXEC_LOG}"; then + while IFS= read -r container; do + if [ -n "$container" ]; then + safe_name=$(echo "$container" | tr '/' '_') + collect "podman/inspect-${safe_name}.json" podman inspect "$container" + fi + done < "${COLLECT_DIR}/.container-list" + rm -f "${COLLECT_DIR}/.container-list" + fi + fi + + # Additional system information + collect system/lsmod.txt lsmod + if command -v lspci >/dev/null 2>&1; then + collect system/lspci.txt lspci -v + else + echo "lspci not available" > "${COLLECT_DIR}/lspci.txt" + fi + + if command -v lsusb >/dev/null 2>&1; then + collect system/lsusb.txt lsusb -v + else + echo "lsusb not available" > "${COLLECT_DIR}/lsusb.txt" + fi + + # Disk I/O stats + if command -v iostat >/dev/null 2>&1; then + collect system/iostat.txt iostat -x 1 2 + else + echo "iostat not available" > "${COLLECT_DIR}/iostat.txt" + fi + + # Process resource usage + if command -v pstree >/dev/null 2>&1; then + collect system/pstree.txt pstree -p + else + collect system/pstree.txt ps fax + fi + + # Environment and versions + collect system/env.txt env + + # Network sockets + if command -v netstat >/dev/null 2>&1; then + collect system/netstat.txt netstat -tunlp + else + collect system/netstat.txt ss -tunlp + fi + + # Completion timestamp in log + echo "" >> "${EXEC_LOG}" + echo "Completed: $(date -Iseconds)" >> "${EXEC_LOG}" + + # Notify user that collection is done + echo "Collection complete. Creating archive..." >&2 + + # Create final tar.gz and output to stdout + # Use -C to change to parent directory so paths in archive don't include full home path + cd "${HOME}" + tar czf - "$(basename "${COLLECT_DIR}")" 2>> "${EXEC_LOG}" +} + +cmd_clean() +{ + dry_run=0 + days=7 + + # Parse options + while [ $# -gt 0 ]; do + case "$1" in + --dry-run|-n) + dry_run=1 + shift + ;; + --days|-d) + if [ -z "$2" ] || [ "$2" -le 0 ] 2>/dev/null; then + echo "Error: --days requires a positive number" >&2 + exit 1 + fi + days="$2" + shift 2 + ;; + *) + echo "Error: Unknown option '$1'" >&2 + echo "Usage: support clean [--dry-run] [--days N]" >&2 + exit 1 + ;; + esac + done + + # Find support collection directories in user's home + pattern="${HOME}/support-20*" + found=0 + total_size=0 + + if [ "$dry_run" -eq 1 ]; then + echo "Dry run - no files will be deleted" + echo "" + fi + + echo "Looking for support directories older than ${days} days..." + echo "" + + # Use find to locate old support directories + # The pattern matches: support-YYYY-MM-DD* format + if [ -d "${HOME}" ]; then + find "${HOME}" -maxdepth 1 -type d -name "support-20*" -mtime "+${days}" 2>/dev/null | while IFS= read -r dir; do + found=1 + size=$(du -sh "$dir" 2>/dev/null | awk '{print $1}') + mtime=$(stat -c %y "$dir" 2>/dev/null | cut -d' ' -f1) + + if [ "$dry_run" -eq 1 ]; then + echo "Would remove: $dir ($size, modified: $mtime)" + else + echo "Removing: $dir ($size, modified: $mtime)" + rm -rf "$dir" + fi + done + + # Check if we found anything (find runs in subshell, so we check differently) + count=$(find "${HOME}" -maxdepth 1 -type d -name "support-20*" -mtime "+${days}" 2>/dev/null | wc -l) + + if [ "$count" -eq 0 ]; then + echo "No support directories older than ${days} days found." + elif [ "$dry_run" -eq 1 ]; then + echo "" + echo "Found ${count} directories. Run without --dry-run to remove them." + else + echo "" + echo "Removed ${count} directories." + fi + fi +} + +usage() +{ + echo "Usage: support [options]" + echo "" + echo "Commands:" + echo " collect Collect system information for support/troubleshooting" + echo " NOTE: may take up to a minute to finish, please wait!" + echo " clean [options] Remove old support collection directories" + echo "" + echo "Options for clean:" + echo " --dry-run, -n Show what would be deleted without deleting" + echo " --days N, -d N Remove directories older than N days (default: 7)" + echo "" + echo "Examples:" + echo " support collect > support-data.tar.gz" + echo " ssh user@device support collect > support-data.tar.gz" + echo " support clean --dry-run" + echo " support clean --days 30" + exit 1 +} + +# Main command dispatcher +if [ $# -lt 1 ]; then + usage +fi + +command="$1" +shift + +case "$command" in + collect) + cmd_collect "$@" + ;; + clean) + cmd_clean "$@" + ;; + help|--help|-h) + usage + ;; + *) + echo "Error: Unknown command '$command'" >&2 + echo "" >&2 + usage + ;; +esac From 0650e16810558704a51e13f450a95ae6b5f5a4e9 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 30 Nov 2025 13:07:46 +0100 Subject: [PATCH 2/8] doc: add Support Data Collection section to User Guide Signed-off-by: Joachim Wiberg --- doc/system.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/doc/system.md b/doc/system.md index 02d9b6445..de2b19cae 100644 --- a/doc/system.md +++ b/doc/system.md @@ -323,6 +323,53 @@ reference ID, stratum, time offsets, frequency, and root delay. > The system uses `chronyd` Network Time Protocol (NTP) daemon. The > output shown here is best explained in the [Chrony documentation][4]. +## Support Data Collection + +When troubleshooting issues or seeking support, the `support` command +provides a convenient way to collect comprehensive system diagnostics. +This command gathers configuration files, logs, network state, and other +system information into a single compressed archive. + +### Collecting Support Data + +To collect support data and save it to a file: + +```bash +admin@host:~$ support collect > support-data.tar.gz +(admin@host) Password: *********** +Starting support data collection from host... +This may take up to a minute. Please wait... +Tailing /var/log/messages for 30 seconds (please wait)... +Log tail complete. +Collection complete. Creating archive... +admin@host:~$ ls -l support-data.tar.gz +-rw-rw-r-- 1 admin admin 508362 nov 30 13:05 support-data.tar.gz +``` + +The command can also be run remotely via SSH from your workstation: + +```bash +$ ssh admin@host support collect > support-data.tar.gz +... +``` + +The collection process may take up to a minute depending on system load +and the amount of logging data. Progress messages are shown during the +collection process. + +### What is Collected + +The support archive includes: + + - System identification (hostname, uptime, kernel version) + - Running and operational configuration (sysrepo datastores) + - System logs (`/var/log` directory and live tail of messages log) + - Network configuration and state (interfaces, routes, neighbors, bridges) + - FRRouting information (OSPF, BFD status) + - Container information (podman containers and their configuration) + - System resource usage (CPU, memory, disk, processes) + - Hardware information (PCI, USB devices, network interfaces) + [1]: https://www.rfc-editor.org/rfc/rfc7317 [2]: https://github.com/kernelkit/infix/blob/main/src/confd/yang/infix-system%402024-02-29.yang [3]: https://www.rfc-editor.org/rfc/rfc8341 From 9ce47017d8d1025fb64d3f8d616b8e618f7a760a Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 1 Dec 2025 12:19:51 +0100 Subject: [PATCH 3/8] bin: add optional support for encrypting the support tarball This commit adds optional support for encrypting the tarball before it leaves the target system. Documentation and usage text updated. Signed-off-by: Joachim Wiberg --- doc/system.md | 41 +++++++++++++++++ src/bin/support | 117 +++++++++++++++++++++++++++++++++++------------- 2 files changed, 128 insertions(+), 30 deletions(-) diff --git a/doc/system.md b/doc/system.md index de2b19cae..86648df26 100644 --- a/doc/system.md +++ b/doc/system.md @@ -357,6 +357,47 @@ The collection process may take up to a minute depending on system load and the amount of logging data. Progress messages are shown during the collection process. +### Encrypted Collection + +For secure transmission of support data, the archive can be encrypted +with GPG using a password: + +```bash +admin@host:~$ support collect -p mypassword > support-data.tar.gz.gpg +Starting support data collection from host... +This may take up to a minute. Please wait... +... +Collection complete. Creating archive... +Encrypting with GPG... +``` + +The `support collect` command even supports omitting `mypassword` and +will then prompt interactively for the password. This works over SSH too, +but the local ssh client may then echo the password. + +> [!TIP] +> To hide the encryption password for an SSH session, the script supports reading from stdin: +> `echo "$MYSECRET" | ssh user@device support collect -p > file.tar.gz.gpg` + +After transferring the resulting file to your workstation, decrypt it +with the password: + +```bash +$ gpg -d support-data.tar.gz.gpg > support-data.tar.gz +$ tar xzf support-data.tar.gz +``` + +or + +```bash +$ gpg -d support-data.tar.gz.gpg | tar xz +``` + +> [!IMPORTANT] +> Make sure to share `mypassword` out-of-band from the encrypted data +> with the recipient of the data. I.e., avoid sending both in the same +> plain-text email for example. + ### What is Collected The support archive includes: diff --git a/src/bin/support b/src/bin/support index d1de72eeb..75fcaf798 100755 --- a/src/bin/support +++ b/src/bin/support @@ -1,34 +1,41 @@ #!/bin/sh # Support utilities for troubleshooting Infix systems # -# Usage: support [options] +# The following text is primarily intended for users of older Infix +# systems that do not yet have this script in the root fileystems. # -# Commands: -# collect [--log-sec N] Collect system information for support/troubleshooting -# clean [--dry-run] [--days N] Remove old support collection directories -# -# Options for collect: -# --log-sec N Tail /var/log/messages for N seconds (default: 30) -# -# Examples: -# support collect > support-data.tar.gz -# support collect --log-sec 5 > support-data.tar.gz -# ssh user@device support collect > support-data.tar.gz -# support clean --dry-run -# support clean --days 30 -# -# Installation (for older Infix versions without this script): # 1. Copy this script to the target device's home directory: -# scp support user@device:~/ +# scp support user@device: # # 2. SSH to the device and make it executable: # ssh user@device # chmod +x ~/support # # 3. Run the script from your home directory: -# ~/support collect > support-data.tar.gz -# # Or directly via SSH from your workstation: -# ssh user@device '~/support collect' > support-data.tar.gz +# +# ~/support collect > support-data.tar.gz +# +# Or directly via SSH from your workstation: +# +# ssh user@device '~/support collect' > support-data.tar.gz +# +# Optionally, the output can be encrypted with GPG using a password for +# secure transmission to support personnel, see below. +# +# Examples: +# support collect > support-data.tar.gz +# support collect -s 5 > support-data.tar.gz +# support collect -p > support-data.tar.gz.gpg +# support collect -p mypass > support-data.tar.gz.gpg +# +# ssh user@device support collect > support-data.tar.gz +# ssh user@device support collect -p mypass > support-data.tar.gz.gpg +# +# Note, interactive password prompt (-p without argument) may echo characters +# over SSH due to local terminal echo. Use -p PASSWORD for remote execution, +# or pipe the password: echo "password" | ssh user@device support collect -p +# meaning you can even: echo "$SECRET_VARIABLE" | ... which in some cases can +# come in handy. # # TODO: # Add more commands, e.g., verify (run health checks), upload , @@ -39,11 +46,12 @@ cmd_collect() { # Default values LOG_TAIL_SEC=30 + PASSWORD="" # Parse options while [ $# -gt 0 ]; do case "$1" in - --log-sec) + --log-sec|-s) if [ -z "$2" ] || [ "$2" -le 0 ] 2>/dev/null; then echo "Error: --log-sec requires a positive number" >&2 exit 1 @@ -51,9 +59,35 @@ cmd_collect() LOG_TAIL_SEC="$2" shift 2 ;; + --password|-p) + # If next arg exists and doesn't start with -, use it as password + if [ -n "$2" ] && [ "${2#-}" = "$2" ]; then + PASSWORD="$2" + shift 2 + else + # Prompt for password interactively from stdin, no echo! + # Disable echo BEFORE printing the prompt + old_stty=$(stty -g 2>/dev/null) + stty -echo 2>/dev/null || true + printf "Enter encryption password: " >&2 + read -r PASSWORD + echo "" >&2 + # Restore terminal settings + if [ -n "$old_stty" ]; then + stty "$old_stty" 2>/dev/null || true + else + stty echo 2>/dev/null || true + fi + if [ -z "$PASSWORD" ]; then + echo "Error: Empty password not allowed" >&2 + exit 1 + fi + shift + fi + ;; *) echo "Error: Unknown option '$1'" >&2 - echo "Usage: support collect [--log-sec N]" >&2 + echo "Usage: support collect [--log-sec|-s N] [--password|-p PASSWORD]" >&2 exit 1 ;; esac @@ -328,7 +362,22 @@ cmd_collect() # Create final tar.gz and output to stdout # Use -C to change to parent directory so paths in archive don't include full home path cd "${HOME}" - tar czf - "$(basename "${COLLECT_DIR}")" 2>> "${EXEC_LOG}" + + # Check if password encryption is requested + if [ -n "$PASSWORD" ]; then + if ! command -v gpg >/dev/null 2>&1; then + echo "Error: --password specified but gpg is not available" >&2 + exit 1 + fi + echo "Encrypting with GPG..." >&2 + tar czf - "$(basename "${COLLECT_DIR}")" 2>> "${EXEC_LOG}" | \ + gpg --batch --yes --passphrase "$PASSWORD" --pinentry-mode loopback -c 2>> "${EXEC_LOG}" + echo "" >&2 + echo "WARNING: Remember to share the encryption password out-of-band!" >&2 + echo " Do not send it in the same email as the encrypted file." >&2 + else + tar czf - "$(basename "${COLLECT_DIR}")" 2>> "${EXEC_LOG}" + fi } cmd_clean() @@ -408,17 +457,25 @@ usage() echo "Usage: support [options]" echo "" echo "Commands:" - echo " collect Collect system information for support/troubleshooting" - echo " NOTE: may take up to a minute to finish, please wait!" - echo " clean [options] Remove old support collection directories" + echo " collect [options] Collect system information for support/troubleshooting" + echo " NOTE: may take up to a minute to finish, please wait!" + echo " clean [options] Remove old support collection directories" + echo "" + echo "Options for collect:" + echo " -s, --log-sec SEC Tail /var/log/messages for SEC seconds (default: 30)" + echo " -p, --password [PASS] Encrypt output with GPG. If PASS is omitted, prompts" + echo " interactively or reads from stdin, so possible to do" + echo " echo "\$MYSECRET" | ... (recommended for security)" echo "" echo "Options for clean:" - echo " --dry-run, -n Show what would be deleted without deleting" - echo " --days N, -d N Remove directories older than N days (default: 7)" + echo " -n, --dry-run Show what would be deleted without deleting" + echo " -d, --days N Remove directories older than N days (default: 7)" echo "" echo "Examples:" - echo " support collect > support-data.tar.gz" - echo " ssh user@device support collect > support-data.tar.gz" + echo " support collect > support-data.tar.gz" + echo " support collect -p > support-data.tar.gz.gpg" + echo " support collect --password mypass > support-data.tar.gz.gpg" + echo " ssh user@device support collect > support-data.tar.gz" echo " support clean --dry-run" echo " support clean --days 30" exit 1 From cfeb875b2b6aed1f279f356e03c382e429f2e6bb Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 2 Dec 2025 10:52:23 +0100 Subject: [PATCH 4/8] bin: collect data by default to /var/lib/support Also, adjust file suffix for json files. Signed-off-by: Joachim Wiberg --- src/bin/support | 149 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 104 insertions(+), 45 deletions(-) diff --git a/src/bin/support b/src/bin/support index 75fcaf798..117f008ac 100755 --- a/src/bin/support +++ b/src/bin/support @@ -1,6 +1,10 @@ #!/bin/sh # Support utilities for troubleshooting Infix systems # +# The collect command gathers system information and outputs a tarball. +# Data is collected to /var/lib/support (or $HOME as fallback) and then +# streamed to stdout. The temporary directory is cleaned up automatically. +# # The following text is primarily intended for users of older Infix # systems that do not yet have this script in the root fileystems. # @@ -93,9 +97,33 @@ cmd_collect() esac done - # Create unique collection directory in user's home directory - # (avoids /tmp which may be full) - COLLECT_DIR="${HOME}/support-$(hostname -s)-$(date -Iseconds)" + # Determine if we need sudo and if it's available + SUDO="" + if [ "$(id -u)" -ne 0 ]; then + if command -v sudo >/dev/null 2>&1; then + SUDO="sudo" + fi + fi + + # If WORK_DIR not set globally, try /var/lib/support first (more space, + # persistent across user sessions). Fall back to $HOME if we can't create/write there + if [ -z "$WORK_DIR" ]; then + if [ -w /var/lib/support ] 2>/dev/null; then + WORK_DIR="/var/lib/support" + elif mkdir -p /var/lib/support 2>/dev/null; then + WORK_DIR="/var/lib/support" + elif [ -n "$SUDO" ] && $SUDO mkdir -p /var/lib/support 2>/dev/null && \ + $SUDO chown "$(id -u):$(id -g)" /var/lib/support 2>/dev/null; then + WORK_DIR="/var/lib/support" + else + WORK_DIR="${HOME}" + echo "Warning: Cannot write to /var/lib/support, using home directory instead." >&2 + echo " (This may fill up your home directory on systems with limited space)" >&2 + fi + fi + + # Create unique collection directory + COLLECT_DIR="${WORK_DIR}/support-$(hostname -s)-$(date -Iseconds)" EXEC_LOG="${COLLECT_DIR}/collection.log" # Cleanup on exit @@ -110,14 +138,6 @@ cmd_collect() # Create collection directory mkdir -p "${COLLECT_DIR}" - # Determine if we need sudo and if it's available - SUDO="" - if [ "$(id -u)" -ne 0 ]; then - if command -v sudo >/dev/null 2>&1; then - SUDO="sudo" - fi - fi - # Helper function to run commands with output to specific file collect() { @@ -141,11 +161,13 @@ cmd_collect() echo "=== Infix Support Data Collection ===" > "${EXEC_LOG}" echo "Started: $(date -Iseconds)" >> "${EXEC_LOG}" echo "Hostname: $(hostname)" >> "${EXEC_LOG}" + echo "Work directory: ${WORK_DIR}" >> "${EXEC_LOG}" echo "Collection directory: ${COLLECT_DIR}" >> "${EXEC_LOG}" echo "" >> "${EXEC_LOG}" # Inform user that collection is starting (to stderr for SSH visibility) echo "Starting support data collection from $(hostname)..." >&2 + echo "Collecting to: ${WORK_DIR}" >&2 echo "This may take up to a minute. Please wait..." >&2 # System identification @@ -252,10 +274,10 @@ cmd_collect() collect system/interrupts2.txt cat /proc/interrupts # Network information - collect network/ip/addr.txt ip -s -d -j addr show - collect network/ip/route.txt ip -s -d -j route show - collect network/ip/link.txt ip -s -d -j link show - collect network/ip/neigh.txt ip -s -d -j neigh show + collect network/ip/addr.json ip -s -d -j addr show + collect network/ip/route.json ip -s -d -j route show + collect network/ip/link.json ip -s -d -j link show + collect network/ip/neigh.json ip -s -d -j neigh show collect network/ifconfig.txt ifconfig -a # Collect ethtool information for all ethernet interfaces @@ -274,8 +296,8 @@ cmd_collect() fi if command -v bridge >/dev/null 2>&1; then - collect network/bridge/link.txt bridge -d -s -j link show - collect network/bridge/fdb.txt bridge -d -s -j fdb show + collect network/bridge/link.json bridge -d -s -j link show + collect network/bridge/fdb.json bridge -d -s -j fdb show fi # Firewall rules @@ -360,8 +382,8 @@ cmd_collect() echo "Collection complete. Creating archive..." >&2 # Create final tar.gz and output to stdout - # Use -C to change to parent directory so paths in archive don't include full home path - cd "${HOME}" + # Use -C to change to parent directory so paths in archive don't include full path + cd "${WORK_DIR}" # Check if password encryption is requested if [ -n "$PASSWORD" ]; then @@ -408,11 +430,6 @@ cmd_clean() esac done - # Find support collection directories in user's home - pattern="${HOME}/support-20*" - found=0 - total_size=0 - if [ "$dry_run" -eq 1 ]; then echo "Dry run - no files will be deleted" echo "" @@ -421,11 +438,23 @@ cmd_clean() echo "Looking for support directories older than ${days} days..." echo "" - # Use find to locate old support directories - # The pattern matches: support-YYYY-MM-DD* format - if [ -d "${HOME}" ]; then - find "${HOME}" -maxdepth 1 -type d -name "support-20*" -mtime "+${days}" 2>/dev/null | while IFS= read -r dir; do - found=1 + # If WORK_DIR is set globally, only search there + # Otherwise search in both /var/lib/support and $HOME + if [ -n "$WORK_DIR" ]; then + search_dirs="$WORK_DIR" + else + search_dirs="/var/lib/support ${HOME}" + fi + total_count=0 + + for search_dir in $search_dirs; do + if [ ! -d "$search_dir" ]; then + continue + fi + + # Use find to locate old support directories + # The pattern matches: support-YYYY-MM-DD* format + find "$search_dir" -maxdepth 1 -type d -name "support-*-20*" -mtime "+${days}" 2>/dev/null | while IFS= read -r dir; do size=$(du -sh "$dir" 2>/dev/null | awk '{print $1}') mtime=$(stat -c %y "$dir" 2>/dev/null | cut -d' ' -f1) @@ -437,24 +466,28 @@ cmd_clean() fi done - # Check if we found anything (find runs in subshell, so we check differently) - count=$(find "${HOME}" -maxdepth 1 -type d -name "support-20*" -mtime "+${days}" 2>/dev/null | wc -l) + # Count directories found in this location + count=$(find "$search_dir" -maxdepth 1 -type d -name "support-*-20*" -mtime "+${days}" 2>/dev/null | wc -l) + total_count=$((total_count + count)) + done - if [ "$count" -eq 0 ]; then - echo "No support directories older than ${days} days found." - elif [ "$dry_run" -eq 1 ]; then - echo "" - echo "Found ${count} directories. Run without --dry-run to remove them." - else - echo "" - echo "Removed ${count} directories." - fi + echo "" + if [ "$total_count" -eq 0 ]; then + echo "No support directories older than ${days} days found in /var/lib/support or home directory." + elif [ "$dry_run" -eq 1 ]; then + echo "Found ${total_count} directories. Run without --dry-run to remove them." + else + echo "Removed ${total_count} directories." fi } usage() { - echo "Usage: support [options]" + echo "Usage: support [global-options] [options]" + echo "" + echo "Global options:" + echo " -w, --work-dir PATH Use PATH as working directory for collection/cleanup" + echo " (default: /var/lib/support with fallback to \$HOME)" echo "" echo "Commands:" echo " collect [options] Collect system information for support/troubleshooting" @@ -472,16 +505,42 @@ usage() echo " -d, --days N Remove directories older than N days (default: 7)" echo "" echo "Examples:" - echo " support collect > support-data.tar.gz" - echo " support collect -p > support-data.tar.gz.gpg" - echo " support collect --password mypass > support-data.tar.gz.gpg" - echo " ssh user@device support collect > support-data.tar.gz" + echo " support collect > support-data.tar.gz" + echo " support collect -p > support-data.tar.gz.gpg" + echo " support collect --password mypass > support-data.tar.gz.gpg" + echo " support --work-dir /tmp/ram collect > support-data.tar.gz" + echo " ssh user@device support collect > support-data.tar.gz" echo " support clean --dry-run" echo " support clean --days 30" + echo " support --work-dir /tmp/ram clean" exit 1 } # Main command dispatcher +# Parse global options first +WORK_DIR="" + +while [ $# -gt 0 ]; do + case "$1" in + -w|--work-dir) + if [ -z "$2" ]; then + echo "Error: --work-dir requires a path argument" >&2 + exit 1 + fi + WORK_DIR="$2" + shift 2 + ;; + -*) + # Unknown option - might be a command-specific option + break + ;; + *) + # Not an option, must be the command + break + ;; + esac +done + if [ $# -lt 1 ]; then usage fi From d1f053b41ce028920c63df08ade2ae5d420f3e31 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 2 Dec 2025 14:10:12 +0100 Subject: [PATCH 5/8] test: minor, update test specifications Sync with latest updates, including test path changes. Signed-off-by: Joachim Wiberg --- test/case/containers/host_commands/test.adoc | 7 +++---- test/case/dhcp/client6_basic/test.adoc | 2 +- test/case/dhcp/client6_prefix_delegation/test.adoc | 2 +- test/case/syslog/hostname_filter/test.adoc | 6 ++++++ 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/test/case/containers/host_commands/test.adoc b/test/case/containers/host_commands/test.adoc index 4f0489f3b..d25151e12 100644 --- a/test/case/containers/host_commands/test.adoc +++ b/test/case/containers/host_commands/test.adoc @@ -4,10 +4,9 @@ ifdef::topdoc[:imagesdir: {topdoc}../../test/case/containers/host_commands] ==== Description -This test verifies that a container in privileged mode can break out of -the container to execute commands that affect the host system. -Specifically, it confirms that the container can change the hostname of -the host. +This test verifies that a container running on Infix can execute commands +that affect the host system. Specifically, it confirms that the container +can change the hostname of the host. ==== Topology diff --git a/test/case/dhcp/client6_basic/test.adoc b/test/case/dhcp/client6_basic/test.adoc index a5ddc41c0..2fcc3a183 100644 --- a/test/case/dhcp/client6_basic/test.adoc +++ b/test/case/dhcp/client6_basic/test.adoc @@ -1,6 +1,6 @@ === DHCPv6 Basic -ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_dhcp/client6_basic] +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/dhcp/client6_basic] ==== Description diff --git a/test/case/dhcp/client6_prefix_delegation/test.adoc b/test/case/dhcp/client6_prefix_delegation/test.adoc index e0b2f05ab..763c6a974 100644 --- a/test/case/dhcp/client6_prefix_delegation/test.adoc +++ b/test/case/dhcp/client6_prefix_delegation/test.adoc @@ -1,6 +1,6 @@ === DHCPv6 Prefix Delegation -ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_dhcp/client6_prefix_delegation] +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/dhcp/client6_prefix_delegation] ==== Description diff --git a/test/case/syslog/hostname_filter/test.adoc b/test/case/syslog/hostname_filter/test.adoc index df488d842..fa9dc406d 100644 --- a/test/case/syslog/hostname_filter/test.adoc +++ b/test/case/syslog/hostname_filter/test.adoc @@ -16,7 +16,13 @@ image::topology.svg[Syslog Hostname Filtering topology, align=center, scaledwidt . Set up topology and attach to DUTs . Clean up old log files on server . Configure server as syslog sink with hostname filtering +. Wait for server interface to be operational +. Verify server IP address is configured +. Verify syslog server is listening on UDP port 514 . Configure client to forward logs to server +. Wait for client interface to be operational +. Verify client IP address is configured +. Verify network connectivity between client and server . Send log messages with different hostnames . Verify router1 log contains only router1 messages . Verify router2 log contains only router2 messages From d6721742c4ee76ecd641021f77efa4ffc25012b2 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 30 Nov 2025 12:49:51 +0100 Subject: [PATCH 6/8] test: new test, verify support data collection Signed-off-by: Joachim Wiberg --- test/case/misc/Readme.adoc | 9 + test/case/misc/all.yaml | 3 + test/case/misc/support_collect/Readme.adoc | 1 + test/case/misc/support_collect/test.adoc | 24 +++ test/case/misc/support_collect/test.py | 185 ++++++++++++++++++++ test/case/misc/support_collect/topology.dot | 1 + test/case/misc/support_collect/topology.svg | 33 ++++ 7 files changed, 256 insertions(+) create mode 120000 test/case/misc/support_collect/Readme.adoc create mode 100644 test/case/misc/support_collect/test.adoc create mode 100755 test/case/misc/support_collect/test.py create mode 120000 test/case/misc/support_collect/topology.dot create mode 100644 test/case/misc/support_collect/topology.svg diff --git a/test/case/misc/Readme.adoc b/test/case/misc/Readme.adoc index db79f21d2..ff72130b0 100644 --- a/test/case/misc/Readme.adoc +++ b/test/case/misc/Readme.adoc @@ -1,4 +1,13 @@ :testgroup: == Miscellaneous Tests +Tests verifying system utilities and operational features: + + - Operational datastore query for all configuration + - Support data collection with work-dir and GPG encryption + include::operational_all/Readme.adoc[] + +<<< + +include::support_collect/Readme.adoc[] diff --git a/test/case/misc/all.yaml b/test/case/misc/all.yaml index 1877e5ca4..8db13da27 100644 --- a/test/case/misc/all.yaml +++ b/test/case/misc/all.yaml @@ -2,5 +2,8 @@ - name: Get operational case: operational_all/test.py +- name: Support data collection + case: support_collect/test.py + #- name: start_from_startup # case: start_from_startup.py diff --git a/test/case/misc/support_collect/Readme.adoc b/test/case/misc/support_collect/Readme.adoc new file mode 120000 index 000000000..ae32c8412 --- /dev/null +++ b/test/case/misc/support_collect/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/misc/support_collect/test.adoc b/test/case/misc/support_collect/test.adoc new file mode 100644 index 000000000..c3d4f6a64 --- /dev/null +++ b/test/case/misc/support_collect/test.adoc @@ -0,0 +1,24 @@ +=== Support data collection + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/misc/support_collect] + +==== Description + +Verify that the support collect command works and produces a valid tarball +with expected content. Tests both the --work-dir global option and GPG +encryption (when available on target). + +==== Topology + +image::topology.svg[Support data collection topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUT +. Check for GPG availability on target +. Run support collect with --work-dir and short log tail +. Verify tarball was created and is valid +. Run support collect with GPG encryption +. Verify encrypted file and decrypt it + + diff --git a/test/case/misc/support_collect/test.py b/test/case/misc/support_collect/test.py new file mode 100755 index 000000000..5713472f9 --- /dev/null +++ b/test/case/misc/support_collect/test.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Support data collection + +Verify that the support collect command works and produces a valid tarball +with expected content. Tests both the --work-dir global option and GPG +encryption (when available on target). + +""" + +import os +import subprocess +import tarfile +import tempfile +import infamy +import infamy.ssh as ssh + +with infamy.Test() as test: + with test.step("Set up topology and attach to target DUT"): + env = infamy.Env() + target = env.attach("target", "mgmt") + tgtssh = env.attach("target", "mgmt", "ssh") + + with test.step("Check for GPG availability on target"): + result = tgtssh.run("command -v gpg >/dev/null 2>&1", check=False) + has_gpg = (result.returncode == 0) + if has_gpg: + print("GPG is available on target - will test encryption") + else: + print("GPG not available on target - skipping encryption tests") + + with test.step("Run support collect with --work-dir and short log tail"): + # Create temporary file for output + with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp: + output_file = tmp.name + + # Use /tmp as work-dir to test the --work-dir option + # Run support collect via SSH with short log tail for testing + # Capture stdout (the tarball) to file + # Note: timeout is generous to handle systems with many network ports + # (ethtool collection scales with number of interfaces) + with open(output_file, 'wb') as f: + result = tgtssh.run("support --work-dir /tmp collect --log-sec 2", + stdout=f, + stderr=subprocess.PIPE, + timeout=120) + + if result.returncode != 0: + stderr_output = result.stderr.decode('utf-8') if result.stderr else "" + print(f"support collect failed with return code {result.returncode}") + print(f"stderr: {stderr_output}") + raise Exception("support collect command failed") + + with test.step("Verify tarball was created and is valid"): + if not os.path.exists(output_file): + raise Exception(f"Output file {output_file} was not created") + + file_size = os.path.getsize(output_file) + if file_size == 0: + raise Exception("Output tarball is empty") + + print(f"Tarball created: {file_size} bytes") + + # Verify it's a valid tar.gz + try: + with tarfile.open(output_file, 'r:gz') as tar: + members = tar.getnames() + print(f"Tarball contains {len(members)} files/directories") + + # Verify some expected files exist + expected_files = [ + 'collection.log', + 'operational-config.json', + 'system/dmesg.txt', + 'system/meminfo.txt', + 'network/ip/addr.json' + ] + + root_dir = members[0] if members else None + for expected in expected_files: + full_path = f"{root_dir}/{expected}" if root_dir else expected + if full_path not in members: + print(f"Warning: Expected file '{expected}' not found in tarball") + else: + print(f"Found: {expected}") + + except tarfile.TarError as e: + raise Exception(f"Invalid tarball: {e}") + + finally: + # Clean up + if os.path.exists(output_file): + os.remove(output_file) + + if has_gpg: + with test.step("Run support collect with GPG encryption"): + # Create temporary file for encrypted output + with tempfile.NamedTemporaryFile(suffix=".tar.gz.gpg", delete=False) as tmp: + encrypted_file = tmp.name + + # Use a test password + test_password = "test-support-password-123" + + # Run support collect with encryption + with open(encrypted_file, 'wb') as f: + result = tgtssh.run(f"support --work-dir /tmp collect --log-sec 2 --password {test_password}", + stdout=f, + stderr=subprocess.PIPE, + timeout=120) + + if result.returncode != 0: + stderr_output = result.stderr.decode('utf-8') if result.stderr else "" + print(f"support collect with encryption failed: {stderr_output}") + raise Exception("support collect with --password failed") + + with test.step("Verify encrypted file and decrypt it"): + if not os.path.exists(encrypted_file): + raise Exception(f"Encrypted output file {encrypted_file} was not created") + + file_size = os.path.getsize(encrypted_file) + if file_size == 0: + raise Exception("Encrypted output file is empty") + + print(f"Encrypted file created: {file_size} bytes") + + # Create temporary file for decrypted output + with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp: + decrypted_file = tmp.name + + try: + # Decrypt the file using gpg + with open(encrypted_file, 'rb') as ef: + with open(decrypted_file, 'wb') as df: + decrypt_result = subprocess.run( + ["gpg", "--batch", "--yes", "--passphrase", test_password, + "--pinentry-mode", "loopback", "-d"], + stdin=ef, + stdout=df, + stderr=subprocess.PIPE, + timeout=30 + ) + + if decrypt_result.returncode != 0: + stderr_output = decrypt_result.stderr.decode('utf-8') if decrypt_result.stderr else "" + print(f"GPG decryption failed: {stderr_output}") + raise Exception("Failed to decrypt GPG-encrypted support data") + + print("Successfully decrypted GPG file") + + # Verify the decrypted file is a valid tarball + with tarfile.open(decrypted_file, 'r:gz') as tar: + members = tar.getnames() + print(f"Decrypted tarball contains {len(members)} files/directories") + + # Verify some expected files exist + expected_files = [ + 'collection.log', + 'operational-config.json', + 'system/dmesg.txt' + ] + + root_dir = members[0] if members else None + for expected in expected_files: + full_path = f"{root_dir}/{expected}" if root_dir else expected + if full_path not in members: + print(f"Warning: Expected file '{expected}' not found in decrypted tarball") + else: + print(f"Found in decrypted tarball: {expected}") + + except tarfile.TarError as e: + raise Exception(f"Decrypted file is not a valid tarball: {e}") + + except subprocess.TimeoutExpired: + raise Exception("GPG decryption timed out") + + except FileNotFoundError: + print("Warning: gpg not available on host system - skipping decryption verification") + + finally: + # Clean up + if os.path.exists(encrypted_file): + os.remove(encrypted_file) + if os.path.exists(decrypted_file): + os.remove(decrypted_file) + + test.succeed() diff --git a/test/case/misc/support_collect/topology.dot b/test/case/misc/support_collect/topology.dot new file mode 120000 index 000000000..02b788692 --- /dev/null +++ b/test/case/misc/support_collect/topology.dot @@ -0,0 +1 @@ +../../../infamy/topologies/1x1.dot \ No newline at end of file diff --git a/test/case/misc/support_collect/topology.svg b/test/case/misc/support_collect/topology.svg new file mode 100644 index 000000000..6fc6f47a8 --- /dev/null +++ b/test/case/misc/support_collect/topology.svg @@ -0,0 +1,33 @@ + + + + + + +1x1 + + + +host + +host + +mgmt + + + +target + +mgmt + +target + + + +host:mgmt--target:mgmt + + + + From 1728b68483442cd9ef69673efe26dbbe3bf14ed2 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 30 Nov 2025 11:44:44 +0100 Subject: [PATCH 7/8] utils: initial support data analysis tool Signed-off-by: Joachim Wiberg --- utils/support | 442 +++++++++++++++++++ utils/support_tui.py | 992 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1434 insertions(+) create mode 100755 utils/support create mode 100644 utils/support_tui.py diff --git a/utils/support b/utils/support new file mode 100755 index 000000000..7e00f8595 --- /dev/null +++ b/utils/support @@ -0,0 +1,442 @@ +#!/usr/bin/env python3 +""" +Support data analysis tool for Infix + +Analyze support data collected from Infix devices. Provides TUI for exploring +logs, comparing between devices or time periods, and generating summaries. + +Installation: + This tool requires Python 3.10+ and the 'textual' library. + + Option 1: Using a virtual environment (recommended): + python3 -m venv ~/.venv/support + source ~/.venv/support/bin/activate + pip install textual rich + ./support analyze ~/support/ + + # To use later, just activate the venv again: + source ~/.venv/support/bin/activate + + Option 2: Using uvx (if you have uv installed): + uvx --from textual --with rich ./support analyze ~/support/ + + Option 3: System-wide installation: + pip install --user textual rich + ./support analyze ~/support/ + + Note: System packages (apt-get/apt) often have outdated versions. + Use pip with venv for the latest textual version. + +Usage: + support analyze # Single device TUI browser + support analyze # Comparison view (auto-detect) + support diff [--file] # Focused diff view + support summary # Quick CLI summary +""" + +import argparse +import sys +from pathlib import Path + + +class SupportArchive: + """Represents a single support collection directory.""" + + def __init__(self, path: Path): + self.path = path + self.name = path.name + + # Parse directory name: support-- + # e.g., support-infix-00-00-00-2025-11-30T06:40:58+00:00 + # Timestamp is ISO 8601: YYYY-MM-DDTHH:MM:SS+TZ + # So we look for the pattern YYYY-MM-DD (last occurrence of date pattern) + + if self.name.startswith('support-'): + remainder = self.name[8:] # Remove 'support-' prefix + + # Find the timestamp by looking for ISO 8601 date pattern + # Pattern: YYYY-MM-DDTHH:MM:SS + import re + match = re.search(r'(\d{4}-\d{2}-\d{2}T[\d:+-]+)$', remainder) + + if match: + # Everything before the timestamp is the hostname + timestamp_start = match.start() + # hostname is everything up to (but not including) the last dash before timestamp + hostname_part = remainder[:timestamp_start].rstrip('-') + self.hostname = hostname_part + self.timestamp = match.group(1) + else: + # No timestamp found + self.hostname = remainder + self.timestamp = None + else: + # Doesn't start with 'support-' + self.hostname = self.name + self.timestamp = None + + self.collection_log = path / "collection.log" + self.is_valid = self.collection_log.exists() + + def get_file(self, relative_path: str) -> Path: + """Get path to a file within the archive.""" + return self.path / relative_path + + def list_files(self, pattern: str = "*") -> list[Path]: + """List all files matching pattern (recursive).""" + return sorted(self.path.rglob(pattern)) + + def get_structure(self) -> dict: + """Get organized structure of collected data.""" + structure = { + "system": [], + "network": [], + "frr": [], + "podman": [], + "config": [], + "logs": [], + "other": [], + } + + for file in self.path.rglob("*"): + if file.is_file(): + rel_path = file.relative_to(self.path) + parts = rel_path.parts + + if len(parts) > 0: + category = parts[0] if parts[0] in structure else "other" + structure[category].append(rel_path) + + return structure + + def get_summary_data(self) -> dict: + """Parse key files and extract summary information.""" + summary = { + "uptime": "unknown", + "uptime_seconds": -1, # -1 means unknown, 0 is valid (just booted) + "memory_percent": 0, + "memory_used_mb": 0, + "memory_total_mb": 0, + "load_avg": "unknown", + "dmesg_errors": 0, + "dmesg_warnings": 0, + } + + # Parse uptime + uptime_file = self.get_file("uptime.txt") + if uptime_file.exists(): + try: + uptime_text = uptime_file.read_text().strip() + # Format: " 06:41:19 up 1 min, 1 users, load average: 0.00, 0.00, 0.00" + summary["uptime"] = uptime_text + + # Extract load average + if "load average:" in uptime_text: + load_part = uptime_text.split("load average:")[1].strip() + summary["load_avg"] = load_part + + # Extract uptime seconds (rough estimate from days/hours/minutes) + import re + if " day" in uptime_text: + days = int(re.search(r'(\d+)\s+day', uptime_text).group(1)) + summary["uptime_seconds"] = days * 86400 + elif " min" in uptime_text: + mins = int(re.search(r'(\d+)\s+min', uptime_text).group(1)) + summary["uptime_seconds"] = mins * 60 + elif ":" in uptime_text and "up" in uptime_text: + # Format like "up 1:23" (hours:minutes) + time_match = re.search(r'up\s+(\d+):(\d+)', uptime_text) + if time_match: + hours, mins = int(time_match.group(1)), int(time_match.group(2)) + summary["uptime_seconds"] = hours * 3600 + mins * 60 + except: + pass + + # Parse memory from meminfo + meminfo_file = self.get_file("system/meminfo.txt") + if meminfo_file.exists(): + try: + meminfo = meminfo_file.read_text() + mem_total = 0 + mem_available = 0 + + for line in meminfo.splitlines(): + if line.startswith("MemTotal:"): + mem_total = int(line.split()[1]) # in KB + elif line.startswith("MemAvailable:"): + mem_available = int(line.split()[1]) # in KB + + if mem_total > 0: + mem_used = mem_total - mem_available + summary["memory_total_mb"] = mem_total // 1024 + summary["memory_used_mb"] = mem_used // 1024 + summary["memory_percent"] = int((mem_used / mem_total) * 100) + except: + pass + + # Count errors/warnings in dmesg + dmesg_file = self.get_file("system/dmesg.txt") + if dmesg_file.exists(): + try: + dmesg = dmesg_file.read_text().lower() + # Count lines with error/warning (case insensitive) + for line in dmesg.splitlines(): + if 'error' in line or 'fail' in line: + summary["dmesg_errors"] += 1 + elif 'warn' in line: + summary["dmesg_warnings"] += 1 + except: + pass + + return summary + + def __repr__(self): + return f"SupportArchive({self.hostname}@{self.timestamp})" + + +def discover_archives(directory: Path) -> list[SupportArchive]: + """ + Discover all support collection directories in the given path. + + Handles both: + - Direct path to a support directory + - Parent directory containing multiple support directories + """ + archives = [] + + if not directory.exists(): + print(f"Error: Directory not found: {directory}", file=sys.stderr) + return archives + + # Check if directory itself is a support archive + if (directory / "collection.log").exists(): + archives.append(SupportArchive(directory)) + return archives + + # Otherwise, scan for support directories + for item in directory.iterdir(): + if item.is_dir() and item.name.startswith("support-"): + archive = SupportArchive(item) + if archive.is_valid: + archives.append(archive) + else: + print(f"Warning: Invalid archive (no collection.log): {item}", + file=sys.stderr) + + return sorted(archives, key=lambda a: (a.hostname, a.timestamp or "")) + + +def detect_mode(archives: list[SupportArchive]) -> str: + """ + Auto-detect analysis mode based on archives. + + Returns: + - "analyze": One archive (single archive browser) + - "compare": Two archives + - "summary": Multiple archives (fleet view) + """ + if len(archives) == 0: + return "summary" + elif len(archives) == 1: + return "analyze" # Single archive -> analyze mode + elif len(archives) == 2: + return "compare" + else: + return "summary" # Multiple archives -> summary/fleet view + + +def cmd_analyze(args): + """Main TUI analysis command.""" + # Collect all archives from provided paths + all_archives = [] + for path_str in args.directories: + path = Path(path_str).resolve() + archives = discover_archives(path) + all_archives.extend(archives) + + if not all_archives: + print("Error: No valid support archives found", file=sys.stderr) + return 1 + + # Auto-detect or use explicit mode + mode = args.mode or detect_mode(all_archives) + + # Launch TUI + try: + from support_tui import launch_tui + launch_tui(all_archives, mode) + return 0 + except ImportError as e: + print(f"Error: TUI dependencies not available: {e}", file=sys.stderr) + print("Please install: pip install textual", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error launching TUI: {e}", file=sys.stderr) + return 1 + + +def cmd_diff(args): + """Diff command for comparing two archives.""" + dir1 = Path(args.dir1).resolve() + dir2 = Path(args.dir2).resolve() + + archives1 = discover_archives(dir1) + archives2 = discover_archives(dir2) + + if not archives1 or not archives2: + print("Error: Could not find valid archives in provided paths", + file=sys.stderr) + return 1 + + archive1 = archives1[0] + archive2 = archives2[0] + + print(f"Comparing:") + print(f" {archive1.name}") + print(f" {archive2.name}") + + if args.file: + print(f"\nFile: {args.file}") + + print("\nDiff view not yet implemented - coming next!") + + # TODO: Launch diff TUI + return 0 + + +def cmd_summary(args): + """Generate quick summary of archives.""" + for path_str in args.directories: + path = Path(path_str).resolve() + archives = discover_archives(path) + + for archive in archives: + print(f"\n{archive.hostname} @ {archive.timestamp or 'unknown'}") + print("─" * 60) + + # Try to read some basic info + hostname_file = archive.get_file("hostname.txt") + if hostname_file.exists(): + hostname = hostname_file.read_text().strip() + print(f"Hostname: {hostname}") + + uptime_file = archive.get_file("uptime.txt") + if uptime_file.exists(): + uptime = uptime_file.read_text().strip() + print(f"Uptime: {uptime}") + + # Show structure + structure = archive.get_structure() + print("\nCollected data:") + for category, files in structure.items(): + if files: + print(f" {category}: {len(files)} files") + + return 0 + + +def main(): + # Special handling: if no args or first arg doesn't look like a command, treat as TUI launch + if len(sys.argv) > 1 and sys.argv[1] not in ['analyze', 'diff', 'summary', '-h', '--help']: + # Looks like directories were provided without a command - launch TUI + all_archives = [] + for path_str in sys.argv[1:]: + if path_str.startswith('-'): + print(f"Error: Unknown option: {path_str}", file=sys.stderr) + print("Usage: support [directory...]", file=sys.stderr) + print(" or: support [options]", file=sys.stderr) + return 1 + path = Path(path_str).resolve() + archives = discover_archives(path) + all_archives.extend(archives) + + if not all_archives: + print("Error: No valid support archives found", file=sys.stderr) + return 1 + + # Launch TUI with summary as default + try: + from support_tui import launch_tui + launch_tui(all_archives, mode="summary") + return 0 + except ImportError as e: + print(f"Error: TUI dependencies not available: {e}", file=sys.stderr) + print("Please install: pip install textual rich", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error launching TUI: {e}", file=sys.stderr) + import traceback + traceback.print_exc() + return 1 + + # Otherwise, parse as normal with subcommands + parser = argparse.ArgumentParser( + description="Analyze support data from Infix devices", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s ~/support/ # Launch TUI (default: summary view) + %(prog)s ~/support/support-router1-* # Launch TUI with specific archives + %(prog)s analyze ~/support/ --mode compare # Force compare mode + %(prog)s diff archive1/ archive2/ + %(prog)s summary ~/support/ + """ + ) + + subparsers = parser.add_subparsers(dest="command", required=True) + + # analyze command + analyze_parser = subparsers.add_parser( + "analyze", + help="Interactive TUI for exploring support data" + ) + analyze_parser.add_argument( + "directories", + nargs="+", + help="Support archive directories to analyze" + ) + analyze_parser.add_argument( + "--mode", + choices=["analyze", "compare", "summary"], + help="Force specific analysis mode (auto-detected by default)" + ) + + # diff command + diff_parser = subparsers.add_parser( + "diff", + help="Compare two support archives" + ) + diff_parser.add_argument("dir1", help="First archive directory") + diff_parser.add_argument("dir2", help="Second archive directory") + diff_parser.add_argument( + "--file", + help="Specific file to compare (relative path within archive)" + ) + + # summary command + summary_parser = subparsers.add_parser( + "summary", + help="Generate quick text summary of archive(s)" + ) + summary_parser.add_argument( + "directories", + nargs="+", + help="Support archive directories to summarize" + ) + + args = parser.parse_args() + + # Dispatch to appropriate command handler + if args.command == "analyze": + return cmd_analyze(args) + elif args.command == "diff": + return cmd_diff(args) + elif args.command == "summary": + return cmd_summary(args) + else: + parser.print_help() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/utils/support_tui.py b/utils/support_tui.py new file mode 100644 index 000000000..ea3c3ec86 --- /dev/null +++ b/utils/support_tui.py @@ -0,0 +1,992 @@ +""" +Textual TUI components for support data analysis +""" + +from pathlib import Path +from textual.app import App, ComposeResult +from textual.containers import Container, Horizontal, Vertical, VerticalScroll +from textual.widgets import Header, Footer, Static, Label, Tree, DataTable, RichLog, Select, Input +from textual.widgets.tree import TreeNode +from textual.binding import Binding +from textual.screen import Screen +from textual.reactive import reactive +from textual.message import Message +from rich.text import Text +from rich.table import Table as RichTable +from typing import Optional +import re + + +class FileContentViewer(RichLog): + """Widget to display file contents with proper text selection support.""" + + BINDINGS = [ + Binding("/", "start_search", "Search", show=True), + Binding("n", "next_match", "Next", show=False), + Binding("N", "prev_match", "Prev", show=False), + Binding("ctrl+l", "toggle_line_numbers", "Line #", show=True), + ] + + class StartSearch(Message): + """Message sent when search is requested.""" + def __init__(self, viewer): + super().__init__() + self.viewer = viewer + + class ToggleLineNumbers(Message): + """Message sent when line numbers toggle is requested.""" + def __init__(self, viewer): + super().__init__() + self.viewer = viewer + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs, wrap=False, highlight=True, markup=False) + self.border_title = "File Content" + self.current_file = None + self.linked_viewer = None # For scroll synchronization + self.scroll_locked = False + self._syncing_scroll = False # Prevent circular scroll updates + self._file_lines = [] # Store original lines + self._search_pattern = None + self._search_matches = [] # List of line numbers with matches + self._current_match_index = -1 + self._show_line_numbers = False + + def load_file(self, file_path: Path): + """Load and display a file.""" + self.current_file = file_path + self.border_title = f"File: {file_path.name}" + + try: + content = file_path.read_text() + self._file_lines = content.splitlines() + self._refresh_display() + except Exception as e: + self.clear() + self.write(f"Error reading file: {e}", style="bold red") + self._file_lines = [] + + def _refresh_display(self): + """Refresh the display with current settings (line numbers, search highlights).""" + self.clear() + + for line_num, line in enumerate(self._file_lines, 1): + display_line = line + + # Add line numbers if enabled + if self._show_line_numbers: + line_prefix = f"{line_num:4d} | " + display_line = line_prefix + line + + # Highlight search matches + if self._search_pattern and self._search_pattern.lower() in line.lower(): + # Simple highlight - wrap matches in style tags + highlighted = self._highlight_matches(display_line, self._search_pattern) + self.write(Text.from_markup(highlighted)) + else: + self.write(display_line) + + def _highlight_matches(self, text: str, pattern: str) -> str: + """Highlight search pattern in text (case insensitive).""" + if not pattern: + return text + + # Use regex for case-insensitive replacement + def replacer(match): + return f"[black on yellow]{match.group(0)}[/black on yellow]" + + try: + highlighted = re.sub(re.escape(pattern), replacer, text, flags=re.IGNORECASE) + return highlighted + except: + return text + + def search(self, pattern: str): + """Search for pattern in file and highlight matches.""" + self._search_pattern = pattern + self._search_matches = [] + self._current_match_index = -1 + + if pattern: + # Find all lines with matches + for line_num, line in enumerate(self._file_lines, 1): + if pattern.lower() in line.lower(): + self._search_matches.append(line_num) + + if self._search_matches: + self._current_match_index = 0 + # Update title with match count + self.border_title = f"File: {self.current_file.name} - {len(self._search_matches)} matches" + # Jump to first match + self._jump_to_current_match() + else: + self.border_title = f"File: {self.current_file.name} - No matches" + + self._refresh_display() + + def _jump_to_current_match(self): + """Scroll to the current match.""" + if self._current_match_index >= 0 and self._current_match_index < len(self._search_matches): + line_num = self._search_matches[self._current_match_index] + # Scroll to make the line visible (approximate) + # RichLog doesn't have direct line-based scrolling, so we estimate + total_lines = len(self._file_lines) + if total_lines > 0: + scroll_ratio = (line_num - 1) / total_lines + # This is approximate - adjust max scroll as needed + self.scroll_y = scroll_ratio * self.max_scroll_y if self.max_scroll_y > 0 else 0 + + def action_next_match(self): + """Jump to next search match.""" + if self._search_matches: + self._current_match_index = (self._current_match_index + 1) % len(self._search_matches) + self._jump_to_current_match() + # Update title to show current match position + current = self._current_match_index + 1 + total = len(self._search_matches) + self.border_title = f"File: {self.current_file.name} - Match {current}/{total}" + + def action_prev_match(self): + """Jump to previous search match.""" + if self._search_matches: + self._current_match_index = (self._current_match_index - 1) % len(self._search_matches) + self._jump_to_current_match() + # Update title to show current match position + current = self._current_match_index + 1 + total = len(self._search_matches) + self.border_title = f"File: {self.current_file.name} - Match {current}/{total}" + + def action_toggle_line_numbers(self): + """Toggle line numbers display - post message for parent to handle.""" + # Post message to parent so it can coordinate between panes + self.post_message(self.ToggleLineNumbers(self)) + + def toggle_line_numbers_internal(self): + """Actually toggle line numbers (called by parent).""" + self._show_line_numbers = not self._show_line_numbers + self._refresh_display() + return self._show_line_numbers + + def action_start_search(self): + """Start search - post a message that parent can handle.""" + # Post a message to notify parent + self.post_message(self.StartSearch(self)) + + def clear_content(self): + """Clear the viewer.""" + self.clear() + self.border_title = "File Content" + self.current_file = None + self._file_lines = [] + self._search_pattern = None + self._search_matches = [] + + def on_mount(self) -> None: + """Set up scroll watching.""" + self.watch(self, "scroll_y", self._on_scroll_change) + + def _on_scroll_change(self, old_y: float, new_y: float) -> None: + """Handle scroll position changes.""" + if self._syncing_scroll or not self.scroll_locked or not self.linked_viewer: + return + + # Sync scroll to linked viewer + if self.linked_viewer and new_y != old_y: + self.linked_viewer._syncing_scroll = True + self.linked_viewer.scroll_y = new_y + self.linked_viewer._syncing_scroll = False + + +class SupportTree(Tree): + """Custom tree widget for navigating support archive structure.""" + + def __init__(self, archive, *args, **kwargs): + super().__init__("Support Data", *args, **kwargs) + self.archive = archive + self.show_root = True + self.guide_depth = 4 + + # Build tree structure + self._build_tree() + + def _build_tree(self): + """Build the tree structure from archive.""" + structure = self.archive.get_structure() + + # Add categories as top-level nodes + for category, files in sorted(structure.items()): + if not files: + continue + + category_node = self.root.add(f"📁 {category}", expand=True) + category_node.data = {"type": "category", "name": category} + + # Group files by subdirectory - keep full paths + file_tree = {} + for file_path in sorted(files): + parts = list(file_path.parts) + if parts[0] == category: + parts = parts[1:] # Remove category prefix + + # Build nested structure, storing full Path objects + current = file_tree + for i, part in enumerate(parts): + if i == len(parts) - 1: + # It's a file - store the full path + if part not in current: + current[part] = file_path # Store full relative path + else: + # It's a directory + if part not in current: + current[part] = {} + elif not isinstance(current[part], dict): + # Edge case: name collision + current[part] = {} + current = current[part] + + # Add to tree + self._add_tree_nodes(category_node, file_tree) + + def _add_tree_nodes(self, parent_node: TreeNode, tree_dict: dict): + """Recursively add nodes to the tree.""" + for name, subtree in sorted(tree_dict.items()): + if isinstance(subtree, Path): + # It's a file - use the stored full path + full_path = self.archive.path / subtree + node = parent_node.add_leaf(f"📄 {name}") + node.data = {"type": "file", "path": full_path} + elif isinstance(subtree, dict): + # It's a directory + dir_node = parent_node.add(f"📁 {name}", expand=False) + dir_node.data = {"type": "directory", "name": name} + self._add_tree_nodes(dir_node, subtree) + + +class SummaryView(Container): + """View displaying summary of all archives with interactive selection.""" + + BINDINGS = [ + Binding("space", "toggle_selection", "Select/Deselect", show=True), + Binding("enter", "confirm_selection", "Confirm", show=True), + ] + + def __init__(self, archives, *args, **kwargs): + super().__init__(*args, **kwargs) + self.archives = archives + self.selected_indices = set() # Track selected archive indices + self.border_title = "Summary - Select archive(s) to analyze" + + def compose(self) -> ComposeResult: + """Create the summary layout.""" + # Instructions + yield Static("Use Space to select/deselect archives, Enter to confirm (1 for analyze, 2 for compare)", + classes="summary-instructions") + # Interactive table + table = DataTable(id="archive-table", cursor_type="row") + table.zebra_stripes = True + yield table + + def on_mount(self) -> None: + """Populate the table when mounted.""" + table = self.query_one("#archive-table", DataTable) + + # Add columns + table.add_column("✓", key="selected", width=3) # Selection indicator + table.add_column("Hostname", key="hostname") + table.add_column("Timestamp", key="timestamp") + table.add_column("Uptime", key="uptime") + table.add_column("Load", key="load") + table.add_column("Memory", key="memory") + table.add_column("Issues", key="issues") + + # Add rows + for idx, archive in enumerate(self.archives): + data = archive.get_summary_data() + + # Format data + uptime_str = self._format_uptime(data["uptime_seconds"]) + mem_pct = data["memory_percent"] + mem_str = f"{data['memory_used_mb']}M/{data['memory_total_mb']}M ({mem_pct}%)" + + # Format issues + errors = data["dmesg_errors"] + warnings = data["dmesg_warnings"] + if errors > 0: + issues_str = f"{errors} err" + if warnings > 0: + issues_str += f", {warnings} warn" + elif warnings > 0: + issues_str = f"{warnings} warn" + else: + issues_str = "none" + + table.add_row( + "", # Selection indicator (empty initially) + archive.hostname, + archive.timestamp or "unknown", + uptime_str, + data["load_avg"], + mem_str, + issues_str, + key=str(idx) + ) + + # Focus the table + table.focus() + + def action_toggle_selection(self) -> None: + """Toggle selection of current row.""" + table = self.query_one("#archive-table", DataTable) + if table.cursor_row is None: + return + + # Get the cursor position (row index) + cursor_row_idx = table.cursor_row + + # Get all row keys and find the one at cursor position + row_keys = list(table.rows.keys()) + if cursor_row_idx >= len(row_keys): + return + + row_key = row_keys[cursor_row_idx] + idx = int(row_key.value) + + # Toggle selection + if idx in self.selected_indices: + self.selected_indices.remove(idx) + # Update the row to remove checkmark + table.update_cell(row_key, "selected", "") + else: + self.selected_indices.add(idx) + # Update the row to add checkmark + table.update_cell(row_key, "selected", "✓") + + # Update border title with selection count + count = len(self.selected_indices) + if count == 0: + self.border_title = "Summary - Select archive(s) to analyze" + elif count == 1: + self.border_title = f"Summary - {count} archive selected (analyze)" + elif count == 2: + self.border_title = f"Summary - {count} archives selected (compare)" + else: + self.border_title = f"Summary - {count} archives selected (too many for compare)" + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + """Handle Enter key on a row - same as confirm selection.""" + self.action_confirm_selection() + + def action_confirm_selection(self) -> None: + """Confirm selection and switch to appropriate view.""" + if len(self.selected_indices) == 0: + self.app.notify("No archives selected. Use Space to select.", severity="warning", timeout=2) + return + elif len(self.selected_indices) == 1: + # Single archive - analyze mode + idx = list(self.selected_indices)[0] + self.app.selected_archives = [self.archives[idx]] + self.app.current_mode = "analyze" + elif len(self.selected_indices) == 2: + # Two archives - compare mode + indices = sorted(self.selected_indices) + self.app.selected_archives = [self.archives[indices[0]], self.archives[indices[1]]] + # Debug: show which archives are selected + arch1 = self.archives[indices[0]].hostname + arch2 = self.archives[indices[1]].hostname + self.app.notify(f"Comparing: {arch1} vs {arch2}", timeout=2) + self.app.current_mode = "compare" + else: + self.app.notify(f"Selected {len(self.selected_indices)} archives. Select 1 for analyze or 2 for compare.", + severity="warning", timeout=3) + + def _format_uptime(self, seconds: int) -> str: + """Format uptime seconds into human-readable string.""" + if seconds < 0: # Only negative indicates truly unknown + return "unknown" + + days = seconds // 86400 + hours = (seconds % 86400) // 3600 + minutes = (seconds % 3600) // 60 + + if days > 0: + return f"{days}d {hours}h" + elif hours > 0: + return f"{hours}h {minutes}m" + else: + return f"{minutes}m" + + +class ComparisonPane(Container): + """Single pane in comparison view with file selector and viewer.""" + + def __init__(self, archive, pane_id: str, *args, **kwargs): + super().__init__(*args, **kwargs) + self.archive = archive + self.pane_id = pane_id + self.border_title = f"{archive.hostname} @ {archive.timestamp or 'unknown'}" + self.current_file = None + self._search_visible = False + + def compose(self) -> ComposeResult: + """Create the pane layout.""" + # Search input (initially hidden) + search_input = Input(placeholder="Search (press Enter)", id=f"search-input-{self.pane_id}") + search_input.display = False + yield search_input + + # File selector dropdown + yield Static("Select file:", classes="file-selector-label") + yield Select( + options=self._get_file_options(), + prompt="Choose a file...", + id=f"file-select-{self.pane_id}", + classes="file-selector" + ) + # File viewer + viewer = FileContentViewer(id=f"viewer-{self.pane_id}") + viewer.border_title = "File Content" + yield viewer + + def _get_file_options(self): + """Get list of all files in archive as select options.""" + options = [] + structure = self.archive.get_structure() + + for category in sorted(structure.keys()): + files = structure[category] + for file_path in sorted(files): + # Create a readable label with category prefix + label = str(file_path) + value = str(file_path) + options.append((label, value)) + + return options + + def on_select_changed(self, event: Select.Changed) -> None: + """Handle file selection change.""" + if event.select.id == f"file-select-{self.pane_id}" and event.value != Select.BLANK: + file_path = Path(event.value) + full_path = self.archive.path / file_path + viewer = self.query_one(f"#viewer-{self.pane_id}", FileContentViewer) + viewer.load_file(full_path) + self.current_file = file_path + + def on_file_content_viewer_start_search(self, message: FileContentViewer.StartSearch) -> None: + """Handle search request from viewer.""" + self.action_start_search() + + def load_file(self, relative_path: Path) -> bool: + """Load a specific file by relative path. Returns True if successful.""" + full_path = self.archive.path / relative_path + if full_path.exists(): + viewer = self.query_one(f"#viewer-{self.pane_id}", FileContentViewer) + viewer.load_file(full_path) + self.current_file = relative_path + # Update selector + selector = self.query_one(f"#file-select-{self.pane_id}", Select) + selector.value = str(relative_path) + return True + return False + + def action_start_search(self) -> None: + """Show search input for this pane.""" + search_input = self.query_one(f"#search-input-{self.pane_id}", Input) + search_input.display = True + self._search_visible = True + search_input.focus() + + def on_input_submitted(self, event: Input.Submitted) -> None: + """Handle search submission.""" + if event.input.id == f"search-input-{self.pane_id}": + pattern = event.value + viewer = self.query_one(f"#viewer-{self.pane_id}", FileContentViewer) + viewer.search(pattern) + + # Hide search input and refocus viewer + search_input = self.query_one(f"#search-input-{self.pane_id}", Input) + search_input.display = False + self._search_visible = False + viewer.focus() + + # Notify parent to sync search to other pane + self.post_message(self.SearchSubmitted(self.pane_id, pattern)) + + class SearchSubmitted(Message): + """Message sent when search is submitted in a pane.""" + def __init__(self, pane_id: str, pattern: str): + super().__init__() + self.pane_id = pane_id + self.pattern = pattern + + def on_key(self, event) -> None: + """Handle escape key to close search input.""" + if event.key == "escape" and self._search_visible: + search_input = self.query_one(f"#search-input-{self.pane_id}", Input) + search_input.display = False + self._search_visible = False + viewer = self.query_one(f"#viewer-{self.pane_id}", FileContentViewer) + viewer.focus() + event.stop() + event.prevent_default() + + +class ComparisonView(Container): + """View for comparing two archives side-by-side.""" + + scroll_locked = reactive(False) + + def __init__(self, archive1, archive2, *args, **kwargs): + super().__init__(*args, **kwargs) + self.archive1 = archive1 + self.archive2 = archive2 + self._syncing = False # Prevent circular updates + + def compose(self) -> ComposeResult: + """Create the comparison layout.""" + with Horizontal(): + # Left pane + yield ComparisonPane(self.archive1, "left", id="pane-left", classes="comparison-pane") + # Right pane + yield ComparisonPane(self.archive2, "right", id="pane-right", classes="comparison-pane") + + def on_mount(self) -> None: + """Try to load operational-config.json or a common file.""" + # Preference: operational-config.json + preferred = Path("operational-config.json") + + left_pane = self.query_one("#pane-left", ComparisonPane) + right_pane = self.query_one("#pane-right", ComparisonPane) + + # Try preferred file first + left_loaded = left_pane.load_file(preferred) + right_loaded = right_pane.load_file(preferred) + + # If preferred didn't work, find any common file + if not (left_loaded and right_loaded): + structure1 = self.archive1.get_structure() + structure2 = self.archive2.get_structure() + + # Flatten file lists + files1 = set() + for files in structure1.values(): + files1.update(str(f) for f in files) + + files2 = set() + for files in structure2.values(): + files2.update(str(f) for f in files) + + # Find common files + common = files1 & files2 + + if common: + # Load the first common file (sorted for consistency) + first_common = Path(sorted(common)[0]) + left_pane.load_file(first_common) + right_pane.load_file(first_common) + + # Set up scroll event watchers + self._setup_scroll_sync() + + # Notify that scroll is locked by default + self.app.notify("Scroll locked (press 'l' to unlock)", timeout=2) + + # Set focus to left viewer so keys work immediately + try: + left_viewer = self.query_one("#viewer-left", FileContentViewer) + left_viewer.focus() + except: + pass + + def _setup_scroll_sync(self) -> None: + """Set up scroll synchronization between panes.""" + try: + left_viewer = self.query_one("#viewer-left", FileContentViewer) + right_viewer = self.query_one("#viewer-right", FileContentViewer) + + # Link viewers for scroll sync + left_viewer.linked_viewer = right_viewer + right_viewer.linked_viewer = left_viewer + + # Enable scroll lock by default in compare mode + self.scroll_locked = True + left_viewer.scroll_locked = True + right_viewer.scroll_locked = True + + # Store references + self._left_viewer = left_viewer + self._right_viewer = right_viewer + except: + pass + + def on_select_changed(self, event: Select.Changed) -> None: + """Handle file selection - sync to other pane if same file exists.""" + if self._syncing: + return + + if event.select.id == "file-select-left" and event.value != Select.BLANK: + # Left pane changed, try to load same file in right + file_path = Path(event.value) + right_pane = self.query_one("#pane-right", ComparisonPane) + self._syncing = True + right_pane.load_file(file_path) # Will fail gracefully if doesn't exist + self._syncing = False + + elif event.select.id == "file-select-right" and event.value != Select.BLANK: + # Right pane changed, try to load same file in left + file_path = Path(event.value) + left_pane = self.query_one("#pane-left", ComparisonPane) + self._syncing = True + left_pane.load_file(file_path) # Will fail gracefully if doesn't exist + self._syncing = False + + def action_toggle_lock(self) -> None: + """Toggle scroll lock between panes.""" + self.scroll_locked = not self.scroll_locked + + # Propagate lock state to viewers + if hasattr(self, '_left_viewer') and hasattr(self, '_right_viewer'): + self._left_viewer.scroll_locked = self.scroll_locked + self._right_viewer.scroll_locked = self.scroll_locked + + # Sync current scroll positions when locking + if self.scroll_locked: + # Sync right to left's position + self._right_viewer._syncing_scroll = True + self._right_viewer.scroll_y = self._left_viewer.scroll_y + self._right_viewer._syncing_scroll = False + + status = "locked" if self.scroll_locked else "unlocked" + self.app.notify(f"Scroll {status}", timeout=1) + + def on_file_content_viewer_toggle_line_numbers(self, message: FileContentViewer.ToggleLineNumbers) -> None: + """Handle line numbers toggle from either viewer - sync both.""" + if hasattr(self, '_left_viewer') and hasattr(self, '_right_viewer'): + # Toggle both viewers + new_state = self._left_viewer.toggle_line_numbers_internal() + self._right_viewer._show_line_numbers = new_state + self._right_viewer._refresh_display() + + status = "on" if new_state else "off" + self.app.notify(f"Line numbers {status}", timeout=1) + + def on_comparison_pane_search_submitted(self, message) -> None: + """Handle search submitted in one pane - sync to other pane.""" + # Apply the search to the other pane + if message.pane_id == "left": + # Search was in left, apply to right + right_viewer = self.query_one("#viewer-right", FileContentViewer) + right_viewer.search(message.pattern) + else: + # Search was in right, apply to left + left_viewer = self.query_one("#viewer-left", FileContentViewer) + left_viewer.search(message.pattern) + + def action_start_search(self) -> None: + """Start search in the focused pane.""" + # Find which pane has focus and trigger search there + try: + left_pane = self.query_one("#pane-left", ComparisonPane) + right_pane = self.query_one("#pane-right", ComparisonPane) + + # Check which viewer is focused + left_viewer = self.query_one("#viewer-left", FileContentViewer) + right_viewer = self.query_one("#viewer-right", FileContentViewer) + + if left_viewer.has_focus or left_viewer.has_focus_within: + left_pane.action_start_search() + else: + # Default to right or trigger on whichever had focus last + right_pane.action_start_search() + except: + pass + + +class SingleArchiveView(Container): + """View for browsing a single support archive.""" + + def __init__(self, archive, *args, **kwargs): + super().__init__(*args, **kwargs) + self.archive = archive + self._search_visible = False + + def compose(self) -> ComposeResult: + """Create the layout.""" + # Search input (initially hidden) + search_input = Input(placeholder="Search (press Enter)", id="search-input") + search_input.display = False + yield search_input + + with Horizontal(): + # Left pane: File tree + tree = SupportTree(self.archive, id="file-tree") + tree.border_title = f"Archive: {self.archive.hostname}" + yield tree + + # Right pane: File viewer + yield FileContentViewer(id="file-viewer") + + def on_mount(self) -> None: + """Set focus and pre-load operational-config.json if available.""" + # Try to pre-load operational-config.json + preferred = Path("operational-config.json") + full_path = self.archive.path / preferred + viewer = self.query_one("#file-viewer", FileContentViewer) + + if full_path.exists(): + viewer.load_file(full_path) + # Focus the viewer since we have content to show + viewer.focus() + else: + # No preferred file, focus tree for navigation + tree = self.query_one("#file-tree", SupportTree) + tree.focus() + + def on_tree_node_selected(self, event: Tree.NodeSelected) -> None: + """Handle file selection in tree.""" + node = event.node + if node.data and node.data.get("type") == "file": + file_path = node.data["path"] + viewer = self.query_one("#file-viewer", FileContentViewer) + viewer.load_file(file_path) + + def on_file_content_viewer_start_search(self, message: FileContentViewer.StartSearch) -> None: + """Handle search request from viewer.""" + self.action_start_search() + + def on_file_content_viewer_toggle_line_numbers(self, message: FileContentViewer.ToggleLineNumbers) -> None: + """Handle line numbers toggle from viewer.""" + # In single view, just toggle the viewer and notify + viewer = self.query_one("#file-viewer", FileContentViewer) + new_state = viewer.toggle_line_numbers_internal() + status = "on" if new_state else "off" + self.app.notify(f"Line numbers {status}", timeout=1) + + def action_start_search(self) -> None: + """Show search input.""" + search_input = self.query_one("#search-input", Input) + search_input.display = True + self._search_visible = True + search_input.focus() + + def on_input_submitted(self, event: Input.Submitted) -> None: + """Handle search submission.""" + if event.input.id == "search-input": + pattern = event.value + viewer = self.query_one("#file-viewer", FileContentViewer) + viewer.search(pattern) + + # Hide search input and refocus viewer + search_input = self.query_one("#search-input", Input) + search_input.display = False + self._search_visible = False + viewer.focus() + + def on_key(self, event) -> None: + """Handle escape key to close search input.""" + if event.key == "escape" and self._search_visible: + search_input = self.query_one("#search-input", Input) + search_input.display = False + self._search_visible = False + viewer = self.query_one("#file-viewer", FileContentViewer) + viewer.focus() + event.stop() + event.prevent_default() + + +class SupportAnalyzerApp(App): + """Main TUI application for analyzing support data.""" + + CSS = """ + Screen { + background: $surface; + } + + #file-tree { + width: 40%; + border: solid $primary; + padding: 1; + } + + #file-viewer { + width: 60%; + border: solid $primary; + padding: 1; + } + + SummaryView { + width: 100%; + height: 100%; + border: solid $primary; + padding: 1; + } + + .summary-instructions { + width: 100%; + height: auto; + padding: 0 1 1 1; + color: $text-muted; + } + + #archive-table { + width: 100%; + height: 100%; + } + + .summary-content { + width: 100%; + height: auto; + } + + .file-content { + width: 100%; + height: auto; + } + + .error { + color: $error; + } + + Tree { + background: $panel; + } + + /* Comparison view styles */ + .comparison-pane { + width: 50%; + border: solid $primary; + padding: 1; + } + + .file-selector-label { + height: 1; + padding: 0 1; + } + + .file-selector { + margin: 0 0 1 0; + } + """ + + BINDINGS = [ + Binding("q", "quit", "Quit", show=True, priority=True), + Binding("s", "show_summary", "Summary", show=True), + Binding("a", "show_analyze", "Analyze", show=True), + Binding("c", "show_compare", "Compare", show=True), + Binding("l", "toggle_lock", "Lock Scroll", show=False), # Only show in compare mode + Binding("?", "help", "Help", show=True), + ] + + current_mode = reactive("summary") # Reactive property for mode switching + + def __init__(self, archives: list, start_mode: str = "summary"): + super().__init__() + self.archives = archives # All available archives + self.selected_archives = archives # Currently selected archives for analyze/compare + self.title = "Infix Support Analyzer" + # Set initial mode without triggering watch (set directly on the descriptor) + self._initial_mode = start_mode + + def compose(self) -> ComposeResult: + """Create the app layout.""" + yield Header() + # Use initial mode for compose, then set reactive property after + mode = getattr(self, '_initial_mode', 'summary') + yield from self._get_view_for_mode(mode) + yield Footer() + + def on_mount(self) -> None: + """Called when app is mounted - safe to set reactive properties now.""" + # Now set the reactive property which will trigger watch if changed + initial_mode = getattr(self, '_initial_mode', 'summary') + self.current_mode = initial_mode + + def _get_view_for_mode(self, mode: str): + """Get the appropriate view widget(s) for a mode.""" + if mode == "summary": + yield SummaryView(self.archives, id="main-view") + elif mode == "analyze": + if len(self.selected_archives) > 0: + yield SingleArchiveView(self.selected_archives[0], id="main-view") + else: + yield Label("No archives to analyze", id="main-view") + elif mode == "compare": + if len(self.selected_archives) >= 2: + yield ComparisonView(self.selected_archives[0], self.selected_archives[1], id="main-view") + else: + yield Label(f"Comparison requires 2 archives. Found: {len(self.selected_archives)}\n" + "Press 's' for summary", id="main-view") + else: + yield Label(f"Unknown mode: {mode}", id="main-view") + + def action_show_summary(self) -> None: + """Switch to summary view.""" + self.current_mode = "summary" + + def action_show_analyze(self) -> None: + """Switch to analyze view.""" + if len(self.selected_archives) == 0: + self.notify("No archives selected. Press 's' for summary to select.", severity="warning", timeout=2) + else: + self.current_mode = "analyze" + + def action_show_compare(self) -> None: + """Switch to compare view.""" + if len(self.selected_archives) < 2: + self.notify(f"Compare requires 2 archives. Selected: {len(self.selected_archives)}. Press 's' for summary.", + severity="warning", timeout=3) + else: + self.current_mode = "compare" + + def watch_current_mode(self, old_mode: str, new_mode: str) -> None: + """React to mode changes (Textual reactive watch method).""" + if old_mode == new_mode: + return + + # Remove old view + try: + old_view = self.query_one("#main-view") + old_view.remove() + # Wait for removal to complete before mounting new widget + self.call_after_refresh(self._mount_new_view, new_mode) + except: + # No old view, just mount directly + self._mount_new_view(new_mode) + + def _mount_new_view(self, mode: str) -> None: + """Mount the view for the given mode.""" + for widget in self._get_view_for_mode(mode): + footer = self.query_one(Footer) + self.mount(widget, before=footer) + + def action_toggle_lock(self) -> None: + """Toggle scroll lock in comparison view.""" + try: + comparison_view = self.query_one("#main-view", ComparisonView) + comparison_view.action_toggle_lock() + except: + self.notify("Lock scroll only available in compare mode", timeout=2) + + def action_help(self) -> None: + """Show help dialog.""" + help_text = ( + "Keybindings:\n" + " q = Quit\n" + " s = Summary view\n" + " a = Analyze view (single archive)\n" + " c = Compare view (dual pane)\n" + " l = Lock/unlock scroll (compare mode)\n" + " Arrow keys = Navigate\n" + "\n" + "Compare mode:\n" + " Use dropdown menus to select different files in each pane\n" + " Files can be different between left and right\n" + "\n" + "Text selection: Click and drag, Ctrl+C to copy" + ) + self.notify(help_text, title="Help", timeout=8) + + +def launch_tui(archives: list, mode: str = "summary"): + """Launch the TUI with given archives and mode.""" + app = SupportAnalyzerApp(archives, mode) + app.run() From 1620fb56f686d74c0ca207a27863b56035d250a6 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 30 Nov 2025 13:08:13 +0100 Subject: [PATCH 8/8] Update ChangeLog, new 'support' command Signed-off-by: Joachim Wiberg --- doc/ChangeLog.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index eb2c5fdd6..6d66373b6 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -15,6 +15,10 @@ All notable changes to the project are documented in this file. ### Changes +- New `support` command for collecting system diagnostics to aid in both + troubleshooting and support. Run `support collect > data.tar.gz` + locally or remotely via SSH to gather configuration, logs, network state, + and system information - Upgrade Buildroot to 2025.02.8 (LTS) - Upgrade Linux kernel to 6.12.59 (LTS) - Initial support for 32-bit ARM systems, reference board: Raspberry Pi 2B