package app import ( "archive/tar" "compress/gzip" "fmt" "io" "os" "os/exec" "path/filepath" "sort" "strings" "time" ) var supportBundleServices = []string{ "bee-audit.service", "bee-web.service", "bee-network.service", "bee-nvidia.service", "bee-preflight.service", "bee-selfheal.service", "bee-selfheal.timer", "bee-sshsetup.service", } var supportBundleCommands = []struct { name string cmd []string }{ {name: "system/uname.txt", cmd: []string{"uname", "-a"}}, {name: "system/cmdline.txt", cmd: []string{"cat", "/proc/cmdline"}}, {name: "system/lsmod.txt", cmd: []string{"lsmod"}}, {name: "system/lspci-nn.txt", cmd: []string{"lspci", "-nn"}}, {name: "system/lspci-vvv.txt", cmd: []string{"lspci", "-vvv"}}, {name: "system/ip-addr.txt", cmd: []string{"ip", "addr"}}, {name: "system/ip-link.txt", cmd: []string{"ip", "-details", "link", "show"}}, {name: "system/ip-link-stats.txt", cmd: []string{"ip", "-s", "link", "show"}}, {name: "system/ip-route.txt", cmd: []string{"ip", "route"}}, {name: "system/mount.txt", cmd: []string{"mount"}}, {name: "system/df-h.txt", cmd: []string{"df", "-h"}}, {name: "system/dmesg.txt", cmd: []string{"dmesg"}}, {name: "system/kernel-aer-nvidia.txt", cmd: []string{"sh", "-c", ` if command -v dmesg >/dev/null 2>&1; then dmesg | grep -iE 'AER|NVRM|Xid|pcieport|nvidia' || echo "no AER/NVRM/Xid kernel messages found" else echo "dmesg not found" fi `}}, {name: "system/nvidia-smi-q.txt", cmd: []string{"nvidia-smi", "-q"}}, {name: "system/lspci-nvidia-bridges-vv.txt", cmd: []string{"sh", "-c", ` if ! command -v lspci >/dev/null 2>&1; then echo "lspci not found" exit 0 fi found=0 for gpu in $(lspci -Dn | awk '$3 ~ /^10de:/ {print $1}'); do found=1 echo "=== GPU $gpu ===" lspci -s "$gpu" -vv 2>&1 || true bridge=$(basename "$(readlink -f "/sys/bus/pci/devices/$gpu/.." 2>/dev/null)" 2>/dev/null) if [ -n "$bridge" ] && [ "$bridge" != "$gpu" ]; then echo echo "=== UPSTREAM $bridge for $gpu ===" lspci -s "$bridge" -vv 2>&1 || true fi echo done if [ "$found" -eq 0 ]; then echo "no NVIDIA PCI devices found" fi `}}, {name: "system/pcie-nvidia-link.txt", cmd: []string{"sh", "-c", ` for d in /sys/bus/pci/devices/*/; do vendor=$(cat "$d/vendor" 2>/dev/null) [ "$vendor" = "0x10de" ] || continue dev=$(basename "$d") echo "=== $dev ===" for f in current_link_speed current_link_width max_link_speed max_link_width; do printf " %-22s %s\n" "$f" "$(cat "$d/$f" 2>/dev/null)" done done `}}, {name: "system/pcie-aer-sysfs.txt", cmd: []string{"sh", "-c", ` found=0 for dev in /sys/bus/pci/devices/*; do [ -e "$dev" ] || continue bdf=$(basename "$dev") block="" for f in aer_dev_correctable aer_dev_fatal aer_dev_nonfatal aer_rootport_total_err_cor aer_rootport_total_err_fatal aer_rootport_total_err_nonfatal; do if [ -r "$dev/$f" ]; then if [ -z "$block" ]; then block=1 found=1 echo "=== $bdf ===" fi printf " %-30s %s\n" "$f" "$(cat "$dev/$f" 2>/dev/null)" fi done if [ -n "$block" ]; then echo fi done if [ "$found" -eq 0 ]; then echo "no PCIe AER sysfs counters found" fi `}}, {name: "system/ethtool-info.txt", cmd: []string{"sh", "-c", ` if ! command -v ethtool >/dev/null 2>&1; then echo "ethtool not found" exit 0 fi found=0 for path in /sys/class/net/*; do [ -e "$path" ] || continue iface=$(basename "$path") [ "$iface" = "lo" ] && continue found=1 echo "=== $iface ===" ethtool -i "$iface" 2>&1 || true echo done if [ "$found" -eq 0 ]; then echo "no interfaces found" fi `}}, {name: "system/ethtool-link.txt", cmd: []string{"sh", "-c", ` if ! command -v ethtool >/dev/null 2>&1; then echo "ethtool not found" exit 0 fi found=0 for path in /sys/class/net/*; do [ -e "$path" ] || continue iface=$(basename "$path") [ "$iface" = "lo" ] && continue found=1 echo "=== $iface ===" ethtool "$iface" 2>&1 || true echo done if [ "$found" -eq 0 ]; then echo "no interfaces found" fi `}}, {name: "system/ethtool-module.txt", cmd: []string{"sh", "-c", ` if ! command -v ethtool >/dev/null 2>&1; then echo "ethtool not found" exit 0 fi found=0 for path in /sys/class/net/*; do [ -e "$path" ] || continue iface=$(basename "$path") [ "$iface" = "lo" ] && continue found=1 echo "=== $iface ===" ethtool -m "$iface" 2>&1 || true echo done if [ "$found" -eq 0 ]; then echo "no interfaces found" fi `}}, {name: "system/mstflint-query.txt", cmd: []string{"sh", "-c", ` if ! command -v mstflint >/dev/null 2>&1; then echo "mstflint not found" exit 0 fi found=0 for path in /sys/bus/pci/devices/*; do [ -e "$path/vendor" ] || continue vendor=$(cat "$path/vendor" 2>/dev/null) [ "$vendor" = "0x15b3" ] || continue bdf=$(basename "$path") found=1 echo "=== $bdf ===" mstflint -d "$bdf" q 2>&1 || true echo done if [ "$found" -eq 0 ]; then echo "no Mellanox/NVIDIA networking devices found" fi `}}, } var supportBundleOptionalFiles = []struct { name string src string }{ {name: "system/kern.log", src: "/var/log/kern.log"}, {name: "system/syslog.txt", src: "/var/log/syslog"}, } const supportBundleGlob = "bee-support-*.tar.gz" func BuildSupportBundle(exportDir string) (string, error) { exportDir = strings.TrimSpace(exportDir) if exportDir == "" { exportDir = DefaultExportDir } if err := os.MkdirAll(exportDir, 0755); err != nil { return "", err } if err := cleanupOldSupportBundles(os.TempDir()); err != nil { return "", err } host := sanitizeFilename(hostnameOr("unknown")) ts := time.Now().UTC().Format("20060102-150405") stageRoot := filepath.Join(os.TempDir(), fmt.Sprintf("bee-support-%s-%s", host, ts)) if err := os.MkdirAll(stageRoot, 0755); err != nil { return "", err } defer os.RemoveAll(stageRoot) if err := copyExportDirForSupportBundle(exportDir, filepath.Join(stageRoot, "export")); err != nil { return "", err } if err := writeJournalDump(filepath.Join(stageRoot, "systemd", "combined.journal.log")); err != nil { return "", err } for _, svc := range supportBundleServices { if err := writeCommandOutput(filepath.Join(stageRoot, "systemd", svc+".status.txt"), []string{"systemctl", "status", svc, "--no-pager"}); err != nil { return "", err } if err := writeCommandOutput(filepath.Join(stageRoot, "systemd", svc+".journal.log"), []string{"journalctl", "--no-pager", "-u", svc}); err != nil { return "", err } } for _, item := range supportBundleCommands { if err := writeCommandOutput(filepath.Join(stageRoot, item.name), item.cmd); err != nil { return "", err } } for _, item := range supportBundleOptionalFiles { _ = copyOptionalFile(item.src, filepath.Join(stageRoot, item.name)) } if err := writeManifest(filepath.Join(stageRoot, "manifest.txt"), exportDir, stageRoot); err != nil { return "", err } archivePath := filepath.Join(os.TempDir(), fmt.Sprintf("bee-support-%s-%s.tar.gz", host, ts)) if err := createSupportTarGz(archivePath, stageRoot); err != nil { return "", err } return archivePath, nil } func LatestSupportBundlePath() (string, error) { return latestSupportBundlePath(os.TempDir()) } func cleanupOldSupportBundles(dir string) error { matches, err := filepath.Glob(filepath.Join(dir, supportBundleGlob)) if err != nil { return err } entries := supportBundleEntries(matches) for path, mod := range entries { if time.Since(mod) > 24*time.Hour { _ = os.Remove(path) delete(entries, path) } } ordered := orderSupportBundles(entries) if len(ordered) > 3 { for _, old := range ordered[3:] { _ = os.Remove(old) } } return nil } func latestSupportBundlePath(dir string) (string, error) { matches, err := filepath.Glob(filepath.Join(dir, supportBundleGlob)) if err != nil { return "", err } ordered := orderSupportBundles(supportBundleEntries(matches)) if len(ordered) == 0 { return "", os.ErrNotExist } return ordered[0], nil } func supportBundleEntries(matches []string) map[string]time.Time { entries := make(map[string]time.Time, len(matches)) for _, match := range matches { info, err := os.Stat(match) if err != nil { continue } entries[match] = info.ModTime() } return entries } func orderSupportBundles(entries map[string]time.Time) []string { ordered := make([]string, 0, len(entries)) for path := range entries { ordered = append(ordered, path) } sort.Slice(ordered, func(i, j int) bool { return entries[ordered[i]].After(entries[ordered[j]]) }) return ordered } func writeJournalDump(dst string) error { args := []string{"--no-pager"} for _, svc := range supportBundleServices { args = append(args, "-u", svc) } raw, err := exec.Command("journalctl", args...).CombinedOutput() if len(raw) == 0 && err != nil { raw = []byte(err.Error() + "\n") } if len(raw) == 0 { raw = []byte("no journal output\n") } if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { return err } return os.WriteFile(dst, raw, 0644) } func writeCommandOutput(dst string, cmd []string) error { if len(cmd) == 0 { return nil } raw, err := exec.Command(cmd[0], cmd[1:]...).CombinedOutput() if len(raw) == 0 { if err != nil { raw = []byte(err.Error() + "\n") } else { raw = []byte("no output\n") } } if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { return err } return os.WriteFile(dst, raw, 0644) } func copyOptionalFile(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { return err } out, err := os.Create(dst) if err != nil { return err } defer out.Close() _, err = io.Copy(out, in) return err } func writeManifest(dst, exportDir, stageRoot string) error { if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { return err } var body strings.Builder fmt.Fprintf(&body, "bee_version=%s\n", buildVersion()) fmt.Fprintf(&body, "host=%s\n", hostnameOr("unknown")) fmt.Fprintf(&body, "generated_at_utc=%s\n", time.Now().UTC().Format(time.RFC3339)) fmt.Fprintf(&body, "export_dir=%s\n", exportDir) fmt.Fprintf(&body, "\nfiles:\n") var files []string if err := filepath.Walk(stageRoot, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return err } if filepath.Clean(path) == filepath.Clean(dst) { return nil } rel, err := filepath.Rel(stageRoot, path) if err != nil { return err } files = append(files, fmt.Sprintf("%s\t%d", rel, info.Size())) return nil }); err != nil { return err } sort.Strings(files) for _, line := range files { body.WriteString(line) body.WriteByte('\n') } return os.WriteFile(dst, []byte(body.String()), 0644) } func buildVersion() string { raw, err := exec.Command("bee", "version").CombinedOutput() if err != nil { return "unknown" } return strings.TrimSpace(string(raw)) } func copyDirContents(srcDir, dstDir string) error { entries, err := os.ReadDir(srcDir) if err != nil { if os.IsNotExist(err) { return nil } return err } for _, entry := range entries { src := filepath.Join(srcDir, entry.Name()) dst := filepath.Join(dstDir, entry.Name()) if err := copyPath(src, dst); err != nil { return err } } return nil } func copyExportDirForSupportBundle(srcDir, dstDir string) error { if err := copyDirContentsFiltered(srcDir, dstDir, func(rel string, info os.FileInfo) bool { cleanRel := filepath.ToSlash(strings.TrimPrefix(filepath.Clean(rel), "./")) if cleanRel == "" { return true } if strings.HasPrefix(cleanRel, "bee-sat/") && strings.HasSuffix(cleanRel, ".tar.gz") { return false } if strings.HasPrefix(filepath.Base(cleanRel), "bee-support-") && strings.HasSuffix(cleanRel, ".tar.gz") { return false } return true }); err != nil { return err } return normalizeSupportBundleAuditJSON(filepath.Join(dstDir, "bee-audit.json")) } func normalizeSupportBundleAuditJSON(path string) error { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil } return err } normalized, err := ApplySATOverlay(data) if err != nil { return nil } return os.WriteFile(path, normalized, 0644) } func copyDirContentsFiltered(srcDir, dstDir string, keep func(rel string, info os.FileInfo) bool) error { entries, err := os.ReadDir(srcDir) if err != nil { if os.IsNotExist(err) { return nil } return err } for _, entry := range entries { src := filepath.Join(srcDir, entry.Name()) dst := filepath.Join(dstDir, entry.Name()) if err := copyPathFiltered(srcDir, src, dst, keep); err != nil { return err } } return nil } func copyPath(src, dst string) error { info, err := os.Stat(src) if err != nil { return err } if info.IsDir() { if err := os.MkdirAll(dst, info.Mode().Perm()); err != nil { return err } entries, err := os.ReadDir(src) if err != nil { return err } for _, entry := range entries { if err := copyPath(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name())); err != nil { return err } } return nil } if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { return err } in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode().Perm()) if err != nil { return err } defer out.Close() _, err = io.Copy(out, in) return err } func copyPathFiltered(rootSrc, src, dst string, keep func(rel string, info os.FileInfo) bool) error { info, err := os.Stat(src) if err != nil { return err } rel, err := filepath.Rel(rootSrc, src) if err != nil { return err } if keep != nil && !keep(rel, info) { return nil } if info.IsDir() { if err := os.MkdirAll(dst, info.Mode().Perm()); err != nil { return err } entries, err := os.ReadDir(src) if err != nil { return err } for _, entry := range entries { if err := copyPathFiltered(rootSrc, filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name()), keep); err != nil { return err } } return nil } return copyPath(src, dst) } func createSupportTarGz(dst, srcDir string) error { file, err := os.Create(dst) if err != nil { return err } defer file.Close() gz := gzip.NewWriter(file) defer gz.Close() tw := tar.NewWriter(gz) defer tw.Close() base := filepath.Dir(srcDir) return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } header, err := tar.FileInfoHeader(info, "") if err != nil { return err } header.Name, err = filepath.Rel(base, path) if err != nil { return err } if err := tw.WriteHeader(header); err != nil { return err } f, err := os.Open(path) if err != nil { return err } defer f.Close() _, err = io.Copy(tw, f) return err }) }