Files
bee/audit/internal/app/support_bundle.go

937 lines
28 KiB
Go

package app
import (
"archive/tar"
"bee/audit/internal/platform"
"compress/gzip"
"context"
_ "embed"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
// defaultCommandTimeout bounds every subprocess this file shells out to. GPU
// diagnostics (nvidia-smi, nvidia-bug-report.sh, dcgmi) are the main risk: a
// GPU that has fallen off the bus or needs a physical power-cycle (see
// gpuNeedsPhysicalReboot in webui/pages.go) makes those tools hang
// indefinitely rather than error out. Without a timeout, one wedged GPU
// blocks the whole bundle build forever and the "Download Support Bundle"
// button spins on "Building..." with no way to recover short of restarting
// the service.
const defaultCommandTimeout = 30 * time.Second
// nvidiaBugReportTimeout is longer than defaultCommandTimeout because
// nvidia-bug-report.sh legitimately takes tens of seconds to run on
// multi-GPU boxes even when healthy.
const nvidiaBugReportTimeout = 120 * time.Second
//go:embed assets/README.md
var supportBundleReadmeMD []byte
// writeBundleDocs writes README.md at root, so an AI agent (or a human)
// analyzing this export (support bundle or blackbox capture) can orient
// itself without reverse-engineering the directory layout first.
func writeBundleDocs(root string) error {
if err := os.MkdirAll(root, 0755); err != nil {
return err
}
return os.WriteFile(filepath.Join(root, "README.md"), supportBundleReadmeMD, 0644)
}
var supportBundleServices = []string{
"bee-blackbox.service",
"bee-audit.service",
"bee-web.service",
"bee-network.service",
"bee-nvidia.service",
"bee-preflight.service",
"bee-selfheal.service",
"bee-selfheal.timer",
"bee-sshsetup.service",
"display-manager.service",
"lightdm.service",
"nvidia-dcgm.service",
"nvidia-fabricmanager.service",
}
// serviceBundleDir returns the bundle-relative directory a service's
// status/journal capture belongs in: bee's own daemons are internal
// bookkeeping, NVIDIA/DCGM/fabric-manager daemons are vendor-facing hardware
// diagnostics, and the display stack is LiveCD/GUI-session-only.
func serviceBundleDir(svc string) string {
switch svc {
case "nvidia-dcgm.service", "nvidia-fabricmanager.service":
return "export/gpu"
case "display-manager.service", "lightdm.service":
return "livecd/gui"
default:
return "tasks/_services"
}
}
var supportBundleCommands = []struct {
name string
cmd []string
timeout time.Duration // zero means defaultCommandTimeout
}{
{name: "livecd/host/uname.txt", cmd: []string{"uname", "-a"}},
{name: "livecd/host/cmdline.txt", cmd: []string{"cat", "/proc/cmdline"}},
{name: "livecd/host/lsmod.txt", cmd: []string{"lsmod"}},
{name: "export/platform/lspci-nn.txt", cmd: []string{"lspci", "-nn"}},
{name: "livecd/host/ip-addr.txt", cmd: []string{"ip", "addr"}},
{name: "livecd/host/ip-link.txt", cmd: []string{"ip", "-details", "link", "show"}},
{name: "livecd/host/ip-link-stats.txt", cmd: []string{"ip", "-s", "link", "show"}},
{name: "livecd/host/ip-route.txt", cmd: []string{"ip", "route"}},
{name: "livecd/host/mount.txt", cmd: []string{"mount"}},
{name: "livecd/host/df-h.txt", cmd: []string{"df", "-h"}},
{name: "livecd/host/dmesg.txt", cmd: []string{"dmesg"}},
{name: "livecd/gui/dmesg-gui-video-input.txt", cmd: []string{"sh", "-c", `
if command -v dmesg >/dev/null 2>&1; then
dmesg | grep -iE 'nvidia|drm|fb|framebuffer|vesa|efi|lightdm|Xorg|input|hid|usb|keyboard|mouse|virtual keyboard|virtual mouse|ami|aspeed|ast' || echo "no GUI/video/input kernel messages found"
else
echo "dmesg not found"
fi
`}},
{name: "export/gpu/kernel-aer-nvidia.txt", cmd: []string{"sh", "-c", platform.KernelAERNvidiaScript}},
{name: "livecd/gui/loginctl-sessions.txt", cmd: []string{"sh", "-c", `
if command -v loginctl >/dev/null 2>&1; then
loginctl list-sessions 2>&1 || true
else
echo "loginctl not found"
fi
`}},
{name: "livecd/gui/loginctl-seats.txt", cmd: []string{"sh", "-c", `
if command -v loginctl >/dev/null 2>&1; then
loginctl list-seats 2>&1 || true
echo
for seat in $(loginctl list-seats --no-legend 2>/dev/null | awk '{print $1}'); do
echo "=== $seat ==="
loginctl seat-status "$seat" 2>&1 || true
echo
done
else
echo "loginctl not found"
fi
`}},
{name: "livecd/gui/ps-gui.txt", cmd: []string{"sh", "-c", `
ps -ef | grep -iE 'lightdm|Xorg|X$|openbox|chromium|chrome|xinit|xsession' | grep -v grep || echo "no GUI processes found"
`}},
{name: "export/gpu/lspci-video-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 dev in $(lspci -Dn | awk '$2 ~ /^03(00|02):$/ {print $1}'); do
found=1
echo "=== $dev ==="
lspci -s "$dev" -vv 2>&1 || true
echo
done
if [ "$found" -eq 0 ]; then
echo "no display-class PCI devices found"
fi
`}},
{name: "livecd/gui/proc-fb.txt", cmd: []string{"cat", "/proc/fb"}},
{name: "livecd/gui/drm-cards.txt", cmd: []string{"sh", "-c", `
if [ -d /sys/class/drm ]; then
for path in /sys/class/drm/card*; do
[ -e "$path" ] || continue
card=$(basename "$path")
echo "=== $card ==="
for f in status enabled dpms modes; do
[ -r "$path/$f" ] && printf " %-8s %s\n" "$f" "$(cat "$path/$f" 2>/dev/null)"
done
device=$(readlink -f "$path/device" 2>/dev/null || true)
[ -n "$device" ] && echo " device ${device##*/}"
echo
done
else
echo "/sys/class/drm not present"
fi
`}},
{name: "livecd/gui/input-devices.txt", cmd: []string{"sh", "-c", `
if [ -r /proc/bus/input/devices ]; then
cat /proc/bus/input/devices
else
echo "/proc/bus/input/devices not readable"
fi
`}},
{name: "livecd/gui/udevadm-input.txt", cmd: []string{"sh", "-c", `
if ! command -v udevadm >/dev/null 2>&1; then
echo "udevadm not found"
exit 0
fi
found=0
for dev in /dev/input/event*; do
[ -e "$dev" ] || continue
found=1
echo "=== $dev ==="
udevadm info --query=all --name="$dev" 2>&1 || true
echo
done
if [ "$found" -eq 0 ]; then
echo "no /dev/input/event* devices found"
fi
`}},
{name: "livecd/gui/xinput-list.txt", cmd: []string{"sh", "-c", `
if command -v xinput >/dev/null 2>&1; then
DISPLAY=:0 xinput --list 2>&1 || true
else
echo "xinput not found"
fi
`}},
{name: "livecd/gui/libinput-list-devices.txt", cmd: []string{"sh", "-c", `
if command -v libinput >/dev/null 2>&1; then
libinput list-devices 2>&1 || true
else
echo "libinput not found"
fi
`}},
{name: "livecd/gui/systemctl-gui-units.txt", cmd: []string{"sh", "-c", `
if ! command -v systemctl >/dev/null 2>&1; then
echo "systemctl not found"
exit 0
fi
echo "=== unit files ==="
systemctl list-unit-files --no-pager --all 'lightdm*' 'display-manager*' 2>&1 || true
echo
echo "=== active units ==="
systemctl list-units --no-pager --all 'lightdm*' 'display-manager*' 2>&1 || true
echo
echo "=== failed units ==="
systemctl --failed --no-pager 2>&1 | grep -iE 'lightdm|display-manager|Xorg' || echo "no failed GUI units"
`}},
{name: "export/gpu/nvidia-smi-topo-fresh.txt", cmd: []string{"sh", "-c", `
if command -v nvidia-smi >/dev/null 2>&1; then
nvidia-smi topo -m 2>&1 || true
else
echo "nvidia-smi not found"
fi
`}},
{name: "export/gpu/nvidia-smi-nvlink-status-fresh.txt", cmd: []string{"sh", "-c", `
if command -v nvidia-smi >/dev/null 2>&1; then
nvidia-smi nvlink -s 2>&1 || true
else
echo "nvidia-smi not found"
fi
`}},
{name: "export/gpu/nvidia-smi-nvlink-errors-fresh.txt", cmd: []string{"sh", "-c", `
if command -v nvidia-smi >/dev/null 2>&1; then
nvidia-smi nvlink -e 2>&1 || true
else
echo "nvidia-smi not found"
fi
`}},
{name: "export/gpu/dcgmi-nvlink-status.txt", cmd: []string{"sh", "-c", `
if command -v dcgmi >/dev/null 2>&1; then
dcgmi nvlink --link-status 2>&1 || true
else
echo "dcgmi not found"
fi
`}},
{name: "export/gpu/nvidia-bug-report.txt", timeout: nvidiaBugReportTimeout, cmd: []string{"sh", "-c", `
if command -v nvidia-bug-report.sh >/dev/null 2>&1; then
rm -f /tmp/bee-nvidia-bug-report.log /tmp/bee-nvidia-bug-report.log.gz
nvidia-bug-report.sh --output-file /tmp/bee-nvidia-bug-report.log >/dev/null 2>&1
if [ -f /tmp/bee-nvidia-bug-report.log.gz ]; then
gzip -dc /tmp/bee-nvidia-bug-report.log.gz
elif [ -f /tmp/bee-nvidia-bug-report.log ]; then
cat /tmp/bee-nvidia-bug-report.log
else
echo "nvidia-bug-report.sh produced no output file"
fi
rm -f /tmp/bee-nvidia-bug-report.log /tmp/bee-nvidia-bug-report.log.gz
else
echo "nvidia-bug-report.sh not found"
fi
`}},
{name: "export/gpu/systemctl-nvidia-units.txt", cmd: []string{"sh", "-c", `
if ! command -v systemctl >/dev/null 2>&1; then
echo "systemctl not found"
exit 0
fi
echo "=== unit files ==="
systemctl list-unit-files --no-pager --all 'nvidia*' 'fabric*' 2>&1 || true
echo
echo "=== active units ==="
systemctl list-units --no-pager --all 'nvidia*' 'fabric*' 2>&1 || true
echo
echo "=== failed units ==="
systemctl --failed --no-pager 2>&1 | grep -iE 'nvidia|fabric' || echo "no failed nvidia/fabric units"
`}},
{name: "export/gpu/fabric-manager-paths.txt", cmd: []string{"sh", "-c", `
for candidate in \
/usr/bin/nvidia-fabricmanager \
/usr/bin/nv-fabricmanager \
/usr/bin/nvidia-fabricmanagerd \
/usr/bin/nvlsm; do
if [ -e "$candidate" ]; then
echo "=== $candidate ==="
ls -l "$candidate" 2>&1 || true
echo
fi
done
if ! ls /usr/bin/nvidia-fabricmanager /usr/bin/nv-fabricmanager /usr/bin/nvidia-fabricmanagerd /usr/bin/nvlsm >/dev/null 2>&1; then
echo "no fabric manager binaries found"
fi
`}},
{name: "export/gpu/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 '$2 ~ /^03(00|02):$/ && $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
`}},
// Also captured once at boot by platform.CaptureTechnicalDump (which the
// blackbox mirror picks up automatically); re-run here so an on-demand
// support bundle gets a fresh sample instead of a possibly-stale boot
// one. See bible-local/decisions/2026-08-24-pcie-gpu-gen1-idle-warning.md.
{name: "export/gpu/pcie-nvidia-link.txt", cmd: []string{"sh", "-c", platform.PCIeNvidiaLinkScript}},
// A Gen1-vs-Gen4 link speed reading at idle is ambiguous: NVIDIA drivers
// deliberately downclock PCIe in low power states and re-train to full
// speed under load, so pcie-nvidia-link.txt alone can't tell a real
// hardware/riser fault from normal power management. Re-sample the same
// sysfs attributes while bee-gpu-burn is actively loading the GPUs — if
// the link comes up here, the idle Gen1 reading above was power saving,
// not a fault.
{name: "export/gpu/pcie-nvidia-link-under-load.txt", cmd: []string{"sh", "-c", platform.PCIeNvidiaLinkUnderLoadScript}},
{name: "export/gpu/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: "export/network/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: "export/network/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: "export/network/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: "export/network/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: "livecd/host/kern.log", src: "/var/log/kern.log"},
{name: "livecd/host/syslog.txt", src: "/var/log/syslog"},
{name: "livecd/gui/Xorg.0.log", src: "/var/log/Xorg.0.log"},
{name: "livecd/gui/Xorg.0.log.old", src: "/var/log/Xorg.0.log.old"},
{name: "livecd/gui/lightdm/lightdm.log", src: "/var/log/lightdm/lightdm.log"},
{name: "livecd/gui/lightdm/x-0.log", src: "/var/log/lightdm/x-0.log"},
{name: "livecd/gui/lightdm/x-0-greeter.log", src: "/var/log/lightdm/x-0-greeter.log"},
{name: "livecd/gui/home-bee-xsession-errors.log", src: "/home/bee/.xsession-errors"},
{name: "livecd/gui/home-bee-chromium-debug.log", src: "/tmp/bee-chrome/chrome_debug.log"},
{name: "export/gpu/fabricmanager.log", src: "/var/log/fabricmanager.log"},
{name: "export/gpu/nvlsm.log", src: "/var/log/nvlsm.log"},
{name: "export/gpu/fabricmanager/fabricmanager.log", src: "/var/log/fabricmanager/fabricmanager.log"},
{name: "export/gpu/fabricmanager/nvlsm.log", src: "/var/log/fabricmanager/nvlsm.log"},
}
const supportBundleGlob = "????-??-?? (BEE-SP*)*.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
}
now := time.Now().UTC()
// Stage under a private parent dir. Two bundle builds started in the same
// wall-clock second (two operators, or an on-demand build racing the
// blackbox worker) must not share a staging tree: one's deferred
// os.RemoveAll would then wipe the other's half-populated tree and yield a
// truncated archive. The leaf name stays meaningful because it becomes the
// archive's top-level directory.
buildParent, err := os.MkdirTemp(os.TempDir(), "bee-support-build-")
if err != nil {
return "", err
}
defer os.RemoveAll(buildParent)
stageRoot := filepath.Join(buildParent, fmt.Sprintf("bee-support-stage-%s-%s", sanitizeFilename(hostnameOr("unknown")), now.Format("20060102-150405")))
if err := os.MkdirAll(stageRoot, 0755); err != nil {
return "", err
}
if err := categorizeExportTree(exportDir, stageRoot, true); err != nil {
return "", err
}
if err := writeJournalDump(filepath.Join(stageRoot, "tasks", "_services", "combined.journal.log")); err != nil {
return "", err
}
for _, svc := range supportBundleServices {
dir := filepath.Join(stageRoot, serviceBundleDir(svc))
if err := writeCommandOutput(filepath.Join(dir, svc+".status.txt"), []string{"systemctl", "status", svc, "--no-pager"}, defaultCommandTimeout); err != nil {
return "", err
}
if err := writeCommandOutput(filepath.Join(dir, svc+".journal.log"), []string{"journalctl", "--no-pager", "-u", svc}, defaultCommandTimeout); err != nil {
return "", err
}
}
for _, item := range supportBundleCommands {
timeout := item.timeout
if timeout <= 0 {
timeout = defaultCommandTimeout
}
if err := writeCommandOutput(filepath.Join(stageRoot, item.name), item.cmd, timeout); err != nil {
return "", err
}
}
for _, item := range supportBundleOptionalFiles {
_ = copyOptionalFile(item.src, filepath.Join(stageRoot, item.name))
}
if err := writeBundleDocs(stageRoot); err != nil {
return "", err
}
if err := writeManifest(filepath.Join(stageRoot, "manifest.txt"), exportDir, stageRoot); err != nil {
return "", err
}
return createSupportTarGz(os.TempDir(), SupportBundleBaseName(now), stageRoot)
}
func SupportBundleBaseName(at time.Time) string {
at = at.UTC()
date := at.Format("2006-01-02")
tod := at.Format("150405")
ver := sanitizeFilename(bundleVersion())
model := sanitizeFilename(serverModelForBundle())
sn := sanitizeFilename(serverSerialForBundle())
return fmt.Sprintf("%s (BEE-SP v%s) %s %s %s", date, ver, model, sn, tod)
}
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 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)
}
ctx, cancel := context.WithTimeout(context.Background(), defaultCommandTimeout)
defer cancel()
raw, err := exec.CommandContext(ctx, "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, timeout time.Duration) error {
if len(cmd) == 0 {
return nil
}
if timeout <= 0 {
timeout = defaultCommandTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
raw, err := exec.CommandContext(ctx, cmd[0], cmd[1:]...).CombinedOutput()
if len(raw) == 0 {
if ctx.Err() == context.DeadlineExceeded {
raw = []byte(fmt.Sprintf("timed out after %s\n", timeout))
} else 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
}
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return errors.Join(err, in.Close())
}
out, err := os.Create(dst)
if err != nil {
return errors.Join(err, in.Close())
}
_, copyErr := io.Copy(out, in)
return errors.Join(copyErr, in.Close(), out.Close())
}
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, "bee_git_commit=%s\n", buildCommit())
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)
if cfg, err := platform.LoadBenchmarkPowerAutotuneConfig(filepath.Join(exportDir, "bee-bench", "power-source-autotune.json")); err == nil && cfg != nil {
fmt.Fprintf(&body, "power_autotune_selected_source=%s\n", cfg.SelectedSource)
fmt.Fprintf(&body, "power_autotune_updated_at=%s\n", cfg.UpdatedAt.UTC().Format(time.RFC3339))
if strings.TrimSpace(cfg.Reason) != "" {
fmt.Fprintf(&body, "power_autotune_reason=%s\n", cfg.Reason)
}
}
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 bundleVersion() string {
v := buildVersion()
v = strings.TrimPrefix(v, "v")
v = strings.TrimPrefix(v, "V")
if v == "" || v == "unknown" {
return "0.0"
}
return v
}
func serverModelForBundle() string {
ctx, cancel := context.WithTimeout(context.Background(), defaultCommandTimeout)
defer cancel()
raw, err := exec.CommandContext(ctx, "dmidecode", "-t", "1").Output()
if err != nil {
return "unknown"
}
for _, line := range strings.Split(string(raw), "\n") {
line = strings.TrimSpace(line)
key, val, ok := strings.Cut(line, ": ")
if !ok {
continue
}
if strings.TrimSpace(key) == "Product Name" {
val = strings.TrimSpace(val)
if val == "" {
return "unknown"
}
return strings.ReplaceAll(val, " ", "_")
}
}
return "unknown"
}
func serverSerialForBundle() string {
ctx, cancel := context.WithTimeout(context.Background(), defaultCommandTimeout)
defer cancel()
raw, err := exec.CommandContext(ctx, "dmidecode", "-t", "1").Output()
if err != nil {
return "unknown"
}
for _, line := range strings.Split(string(raw), "\n") {
line = strings.TrimSpace(line)
key, val, ok := strings.Cut(line, ": ")
if !ok {
continue
}
if strings.TrimSpace(key) == "Serial Number" {
val = strings.TrimSpace(val)
if val == "" {
return "unknown"
}
return val
}
}
return "unknown"
}
func buildVersion() string {
ctx, cancel := context.WithTimeout(context.Background(), defaultCommandTimeout)
defer cancel()
raw, err := exec.CommandContext(ctx, "bee", "version").CombinedOutput()
if err != nil {
return "unknown"
}
return strings.TrimSpace(string(raw))
}
func buildCommit() string {
ctx, cancel := context.WithTimeout(context.Background(), defaultCommandTimeout)
defer cancel()
raw, err := exec.CommandContext(ctx, "bee", "version", "--commit").CombinedOutput()
if err != nil || strings.TrimSpace(string(raw)) == "" {
return "unknown"
}
return strings.TrimSpace(string(raw))
}
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 {
// src is read from a live export dir that bee's own SAT/task runner
// keeps writing to and renaming (e.g. a task dir's "_pending" ->
// "_done" transition) concurrently with this copy — a path present
// in the parent's os.ReadDir listing a moment ago disappearing by
// the time we get here is an expected race, not a real failure.
// Skip it; the next cycle will pick up wherever it landed under its
// new name. Aborting the whole copy over one renamed-away entry is
// what previously left blackbox stuck "degraded" indefinitely.
if os.IsNotExist(err) {
return 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 {
if os.IsNotExist(err) {
return 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
}
// Skip rewriting files the blackbox worker already copied unchanged on a
// prior cycle — avoids needless flash wear on the removable target every
// sync period. Size+mtime, not a full byte comparison, so large files
// (e.g. status/metrics.db) can still stream-copy instead of loading
// whole into memory.
if dstInfo, err := os.Stat(dst); err == nil && dstInfo.Size() == info.Size() && !dstInfo.ModTime().Before(info.ModTime()) {
return nil
}
in, err := os.Open(src)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode().Perm())
if err != nil {
return errors.Join(err, in.Close())
}
_, copyErr := io.Copy(out, in)
return errors.Join(copyErr, in.Close(), out.Close())
}
func copyPathFiltered(rootSrc, src, dst string, keep func(rel string, info os.FileInfo) bool) error {
info, err := os.Stat(src)
if err != nil {
// See the matching comment in copyPath: src disappearing between the
// parent's os.ReadDir and this Stat is an expected race against
// bee's own live task-dir renames, not a real error.
if os.IsNotExist(err) {
return 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 {
if os.IsNotExist(err) {
return 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(dir, baseName, srcDir string) (string, error) {
archiveFile, err := os.CreateTemp(dir, baseName+"-*.partial")
if err != nil {
return "", err
}
partialPath := archiveFile.Name()
if err := writeSupportTarGz(archiveFile, srcDir); err != nil {
_ = archiveFile.Close()
_ = os.Remove(partialPath)
return "", err
}
if err := archiveFile.Close(); err != nil {
_ = os.Remove(partialPath)
return "", err
}
archivePath := strings.TrimSuffix(partialPath, ".partial") + ".tar.gz"
if err := os.Rename(partialPath, archivePath); err != nil {
_ = os.Remove(partialPath)
return "", err
}
return archivePath, nil
}
func writeSupportTarGz(file *os.File, srcDir string) error {
gz := gzip.NewWriter(file)
tw := tar.NewWriter(gz)
base := filepath.Dir(srcDir)
walkErr := 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
}
_, copyErr := io.Copy(tw, f)
closeErr := f.Close()
if copyErr != nil {
return copyErr
}
return closeErr
})
if walkErr != nil {
_ = tw.Close()
_ = gz.Close()
return walkErr
}
if err := tw.Close(); err != nil {
_ = gz.Close()
return err
}
return gz.Close()
}