- Add full dmesg (was tail -200), kern.log, syslog - Add /proc/cmdline, lspci -vvv, nvidia-smi -q - Add per-GPU PCIe link speed/width from sysfs (NVIDIA devices only) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
458 lines
11 KiB
Go
458 lines
11 KiB
Go
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-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-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/nvidia-smi-q.txt", cmd: []string{"nvidia-smi", "-q"}},
|
|
{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
|
|
`}},
|
|
}
|
|
|
|
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
|
|
})
|
|
}
|