feat: add support bundle and raw audit export
This commit is contained in:
@@ -44,6 +44,10 @@ func run(args []string, stdout, stderr io.Writer) int {
|
|||||||
return runTUI(args[1:], stdout, stderr)
|
return runTUI(args[1:], stdout, stderr)
|
||||||
case "export":
|
case "export":
|
||||||
return runExport(args[1:], stdout, stderr)
|
return runExport(args[1:], stdout, stderr)
|
||||||
|
case "preflight":
|
||||||
|
return runPreflight(args[1:], stdout, stderr)
|
||||||
|
case "support-bundle":
|
||||||
|
return runSupportBundle(args[1:], stdout, stderr)
|
||||||
case "web":
|
case "web":
|
||||||
return runWeb(args[1:], stdout, stderr)
|
return runWeb(args[1:], stdout, stderr)
|
||||||
case "sat":
|
case "sat":
|
||||||
@@ -61,9 +65,11 @@ func run(args []string, stdout, stderr io.Writer) int {
|
|||||||
func printRootUsage(w io.Writer) {
|
func printRootUsage(w io.Writer) {
|
||||||
fmt.Fprintln(w, `bee commands:
|
fmt.Fprintln(w, `bee commands:
|
||||||
bee audit --runtime auto|local|livecd --output stdout|file:<path>
|
bee audit --runtime auto|local|livecd --output stdout|file:<path>
|
||||||
|
bee preflight --output stdout|file:<path>
|
||||||
bee tui --runtime auto|local|livecd
|
bee tui --runtime auto|local|livecd
|
||||||
bee export --target <device>
|
bee export --target <device>
|
||||||
bee web --listen :80 --audit-path /var/log/bee-audit.json
|
bee support-bundle --output stdout|file:<path>
|
||||||
|
bee web --listen :80 --audit-path `+app.DefaultAuditJSONPath+`
|
||||||
bee sat nvidia|memory|storage
|
bee sat nvidia|memory|storage
|
||||||
bee version
|
bee version
|
||||||
bee help [command]`)
|
bee help [command]`)
|
||||||
@@ -77,6 +83,10 @@ func runHelp(args []string, stdout, stderr io.Writer) int {
|
|||||||
return runTUI([]string{"--help"}, stdout, stdout)
|
return runTUI([]string{"--help"}, stdout, stdout)
|
||||||
case "export":
|
case "export":
|
||||||
return runExport([]string{"--help"}, stdout, stdout)
|
return runExport([]string{"--help"}, stdout, stdout)
|
||||||
|
case "preflight":
|
||||||
|
return runPreflight([]string{"--help"}, stdout, stdout)
|
||||||
|
case "support-bundle":
|
||||||
|
return runSupportBundle([]string{"--help"}, stdout, stdout)
|
||||||
case "web":
|
case "web":
|
||||||
return runWeb([]string{"--help"}, stdout, stdout)
|
return runWeb([]string{"--help"}, stdout, stdout)
|
||||||
case "sat":
|
case "sat":
|
||||||
@@ -219,14 +229,96 @@ func runExport(args []string, stdout, stderr io.Writer) int {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runPreflight(args []string, stdout, stderr io.Writer) int {
|
||||||
|
fs := flag.NewFlagSet("preflight", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(stderr)
|
||||||
|
output := fs.String("output", "stdout", "output destination: stdout or file:<path>")
|
||||||
|
fs.Usage = func() {
|
||||||
|
fmt.Fprintf(stderr, "usage: bee preflight [--output stdout|file:%s]\n", app.DefaultRuntimeJSONPath)
|
||||||
|
fs.PrintDefaults()
|
||||||
|
}
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
if err == flag.ErrHelp {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if fs.NArg() != 0 {
|
||||||
|
fs.Usage()
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
application := app.New(platform.New())
|
||||||
|
path, err := application.RunRuntimePreflight(*output)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("run preflight", "err", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if path != "stdout" {
|
||||||
|
slog.Info("runtime health written", "path", path)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSupportBundle(args []string, stdout, stderr io.Writer) int {
|
||||||
|
fs := flag.NewFlagSet("support-bundle", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(stderr)
|
||||||
|
output := fs.String("output", "stdout", "output destination: stdout or file:<path>")
|
||||||
|
fs.Usage = func() {
|
||||||
|
fmt.Fprintln(stderr, "usage: bee support-bundle [--output stdout|file:<path>]")
|
||||||
|
fs.PrintDefaults()
|
||||||
|
}
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
if err == flag.ErrHelp {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if fs.NArg() != 0 {
|
||||||
|
fs.Usage()
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
path, err := app.BuildSupportBundle(app.DefaultExportDir)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("build support bundle", "err", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
defer os.Remove(path)
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("read support bundle", "err", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case *output == "stdout":
|
||||||
|
if _, err := stdout.Write(raw); err != nil {
|
||||||
|
slog.Error("write support bundle stdout", "err", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(*output, "file:"):
|
||||||
|
dst := strings.TrimPrefix(*output, "file:")
|
||||||
|
if err := os.WriteFile(dst, raw, 0644); err != nil {
|
||||||
|
slog.Error("write support bundle", "err", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
slog.Info("support bundle written", "path", dst)
|
||||||
|
default:
|
||||||
|
fmt.Fprintln(stderr, "bee support-bundle: unknown output destination")
|
||||||
|
fs.Usage()
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func runWeb(args []string, stdout, stderr io.Writer) int {
|
func runWeb(args []string, stdout, stderr io.Writer) int {
|
||||||
fs := flag.NewFlagSet("web", flag.ContinueOnError)
|
fs := flag.NewFlagSet("web", flag.ContinueOnError)
|
||||||
fs.SetOutput(stderr)
|
fs.SetOutput(stderr)
|
||||||
listenAddr := fs.String("listen", ":8080", "listen address, e.g. :80")
|
listenAddr := fs.String("listen", ":8080", "listen address, e.g. :80")
|
||||||
auditPath := fs.String("audit-path", app.DefaultAuditJSONPath, "path to the latest audit JSON snapshot")
|
auditPath := fs.String("audit-path", app.DefaultAuditJSONPath, "path to the latest audit JSON snapshot")
|
||||||
|
exportDir := fs.String("export-dir", app.DefaultExportDir, "directory with logs, SAT results, and support bundles")
|
||||||
title := fs.String("title", "Bee Hardware Audit", "page title")
|
title := fs.String("title", "Bee Hardware Audit", "page title")
|
||||||
fs.Usage = func() {
|
fs.Usage = func() {
|
||||||
fmt.Fprintln(stderr, "usage: bee web [--listen :80] [--audit-path /var/log/bee-audit.json] [--title \"Bee Hardware Audit\"]")
|
fmt.Fprintf(stderr, "usage: bee web [--listen :80] [--audit-path %s] [--export-dir %s] [--title \"Bee Hardware Audit\"]\n", app.DefaultAuditJSONPath, app.DefaultExportDir)
|
||||||
fs.PrintDefaults()
|
fs.PrintDefaults()
|
||||||
}
|
}
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
@@ -244,6 +336,7 @@ func runWeb(args []string, stdout, stderr io.Writer) int {
|
|||||||
if err := webui.ListenAndServe(*listenAddr, webui.HandlerOptions{
|
if err := webui.ListenAndServe(*listenAddr, webui.HandlerOptions{
|
||||||
Title: *title,
|
Title: *title,
|
||||||
AuditPath: *auditPath,
|
AuditPath: *auditPath,
|
||||||
|
ExportDir: *exportDir,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
slog.Error("run web", "err", err)
|
slog.Error("run web", "err", err)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -91,6 +91,32 @@ func TestRunSATUsage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunPreflightRejectsExtraArgs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
rc := run([]string{"preflight", "extra"}, &stdout, &stderr)
|
||||||
|
if rc != 2 {
|
||||||
|
t.Fatalf("rc=%d want 2", rc)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "usage: bee preflight") {
|
||||||
|
t.Fatalf("stderr missing preflight usage:\n%s", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunSupportBundleRejectsExtraArgs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
rc := run([]string{"support-bundle", "extra"}, &stdout, &stderr)
|
||||||
|
if rc != 2 {
|
||||||
|
t.Fatalf("rc=%d want 2", rc)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "usage: bee support-bundle") {
|
||||||
|
t.Fatalf("stderr missing support-bundle usage:\n%s", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunHelpForSubcommand(t *testing.T) {
|
func TestRunHelpForSubcommand(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package app
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -17,9 +18,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
DefaultAuditJSONPath = "/var/log/bee-audit.json"
|
DefaultExportDir = "/appdata/bee/export"
|
||||||
DefaultAuditLogPath = "/var/log/bee-audit.log"
|
DefaultAuditJSONPath = DefaultExportDir + "/bee-audit.json"
|
||||||
DefaultSATBaseDir = "/var/log/bee-sat"
|
DefaultAuditLogPath = DefaultExportDir + "/bee-audit.log"
|
||||||
|
DefaultWebLogPath = DefaultExportDir + "/bee-web.log"
|
||||||
|
DefaultNetworkLogPath = DefaultExportDir + "/bee-network.log"
|
||||||
|
DefaultNvidiaLogPath = DefaultExportDir + "/bee-nvidia.log"
|
||||||
|
DefaultSSHLogPath = DefaultExportDir + "/bee-sshsetup.log"
|
||||||
|
DefaultRuntimeJSONPath = DefaultExportDir + "/runtime-health.json"
|
||||||
|
DefaultRuntimeLogPath = DefaultExportDir + "/runtime-health.log"
|
||||||
|
DefaultTechDumpDir = DefaultExportDir + "/techdump"
|
||||||
|
DefaultSATBaseDir = DefaultExportDir + "/bee-sat"
|
||||||
)
|
)
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
@@ -28,6 +37,7 @@ type App struct {
|
|||||||
exports exportManager
|
exports exportManager
|
||||||
tools toolManager
|
tools toolManager
|
||||||
sat satRunner
|
sat satRunner
|
||||||
|
runtime runtimeChecker
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActionResult struct {
|
type ActionResult struct {
|
||||||
@@ -65,6 +75,11 @@ type satRunner interface {
|
|||||||
RunStorageAcceptancePack(baseDir string) (string, error)
|
RunStorageAcceptancePack(baseDir string) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type runtimeChecker interface {
|
||||||
|
CollectRuntimeHealth(exportDir string) (schema.RuntimeHealth, error)
|
||||||
|
CaptureTechnicalDump(baseDir string) error
|
||||||
|
}
|
||||||
|
|
||||||
func New(platform *platform.System) *App {
|
func New(platform *platform.System) *App {
|
||||||
return &App{
|
return &App{
|
||||||
network: platform,
|
network: platform,
|
||||||
@@ -72,11 +87,20 @@ func New(platform *platform.System) *App {
|
|||||||
exports: platform,
|
exports: platform,
|
||||||
tools: platform,
|
tools: platform,
|
||||||
sat: platform,
|
sat: platform,
|
||||||
|
runtime: platform,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) RunAudit(runtimeMode runtimeenv.Mode, output string) (string, error) {
|
func (a *App) RunAudit(runtimeMode runtimeenv.Mode, output string) (string, error) {
|
||||||
|
if runtimeMode == runtimeenv.ModeLiveCD {
|
||||||
|
if err := a.runtime.CaptureTechnicalDump(DefaultTechDumpDir); err != nil {
|
||||||
|
slog.Warn("capture technical dump", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
result := collector.Run(runtimeMode)
|
result := collector.Run(runtimeMode)
|
||||||
|
if health, err := ReadRuntimeHealth(DefaultRuntimeJSONPath); err == nil {
|
||||||
|
result.Runtime = &health
|
||||||
|
}
|
||||||
data, err := json.MarshalIndent(result, "", " ")
|
data, err := json.MarshalIndent(result, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -88,6 +112,9 @@ func (a *App) RunAudit(runtimeMode runtimeenv.Mode, output string) (string, erro
|
|||||||
return "stdout", err
|
return "stdout", err
|
||||||
case strings.HasPrefix(output, "file:"):
|
case strings.HasPrefix(output, "file:"):
|
||||||
path := strings.TrimPrefix(output, "file:")
|
path := strings.TrimPrefix(output, "file:")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
if err := os.WriteFile(path, append(data, '\n'), 0644); err != nil {
|
if err := os.WriteFile(path, append(data, '\n'), 0644); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -97,6 +124,63 @@ func (a *App) RunAudit(runtimeMode runtimeenv.Mode, output string) (string, erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) RunRuntimePreflight(output string) (string, error) {
|
||||||
|
health, err := a.runtime.CollectRuntimeHealth(DefaultExportDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(health, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case output == "stdout":
|
||||||
|
_, err := os.Stdout.Write(append(data, '\n'))
|
||||||
|
return "stdout", err
|
||||||
|
case strings.HasPrefix(output, "file:"):
|
||||||
|
path := strings.TrimPrefix(output, "file:")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, append(data, '\n'), 0644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unknown output destination %q — use stdout or file:<path>", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) RunRuntimePreflightResult() (ActionResult, error) {
|
||||||
|
path, err := a.RunRuntimePreflight("file:" + DefaultRuntimeJSONPath)
|
||||||
|
body := "Runtime preflight completed."
|
||||||
|
if path != "" {
|
||||||
|
body = "Runtime health written to " + path
|
||||||
|
}
|
||||||
|
return ActionResult{Title: "Run self-check", Body: body}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) RuntimeHealthResult() ActionResult {
|
||||||
|
health, err := ReadRuntimeHealth(DefaultRuntimeJSONPath)
|
||||||
|
if err != nil {
|
||||||
|
return ActionResult{Title: "Runtime issues", Body: "No runtime health found."}
|
||||||
|
}
|
||||||
|
var body strings.Builder
|
||||||
|
fmt.Fprintf(&body, "Status: %s\n", firstNonEmpty(health.Status, "UNKNOWN"))
|
||||||
|
fmt.Fprintf(&body, "Export dir: %s\n", firstNonEmpty(health.ExportDir, DefaultExportDir))
|
||||||
|
fmt.Fprintf(&body, "Driver ready: %t\n", health.DriverReady)
|
||||||
|
fmt.Fprintf(&body, "CUDA ready: %t\n", health.CUDAReady)
|
||||||
|
fmt.Fprintf(&body, "Network: %s", firstNonEmpty(health.NetworkStatus, "UNKNOWN"))
|
||||||
|
if len(health.Issues) > 0 {
|
||||||
|
body.WriteString("\n\nIssues:\n")
|
||||||
|
for _, issue := range health.Issues {
|
||||||
|
fmt.Fprintf(&body, "- %s: %s\n", issue.Code, issue.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ActionResult{Title: "Runtime issues", Body: strings.TrimSpace(body.String())}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) RunAuditNow(runtimeMode runtimeenv.Mode) (ActionResult, error) {
|
func (a *App) RunAuditNow(runtimeMode runtimeenv.Mode) (ActionResult, error) {
|
||||||
path, err := a.RunAudit(runtimeMode, "file:"+DefaultAuditJSONPath)
|
path, err := a.RunAudit(runtimeMode, "file:"+DefaultAuditJSONPath)
|
||||||
body := "Audit completed."
|
body := "Audit completed."
|
||||||
@@ -136,6 +220,24 @@ func (a *App) ExportLatestAuditResult(target platform.RemovableTarget) (ActionRe
|
|||||||
return ActionResult{Title: "Export audit", Body: body}, err
|
return ActionResult{Title: "Export audit", Body: body}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) ExportSupportBundle(target platform.RemovableTarget) (string, error) {
|
||||||
|
archive, err := BuildSupportBundle(DefaultExportDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer os.Remove(archive)
|
||||||
|
return a.exports.ExportFileToTarget(archive, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ExportSupportBundleResult(target platform.RemovableTarget) (ActionResult, error) {
|
||||||
|
path, err := a.ExportSupportBundle(target)
|
||||||
|
body := "Support bundle exported."
|
||||||
|
if path != "" {
|
||||||
|
body = "Support bundle exported to " + path
|
||||||
|
}
|
||||||
|
return ActionResult{Title: "Export support bundle", Body: body}, err
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) ListInterfaces() ([]platform.InterfaceInfo, error) {
|
func (a *App) ListInterfaces() ([]platform.InterfaceInfo, error) {
|
||||||
return a.network.ListInterfaces()
|
return a.network.ListInterfaces()
|
||||||
}
|
}
|
||||||
@@ -278,11 +380,14 @@ func (a *App) AuditLogTailResult() ActionResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) RunNvidiaAcceptancePack(baseDir string) (string, error) {
|
func (a *App) RunNvidiaAcceptancePack(baseDir string) (string, error) {
|
||||||
|
if strings.TrimSpace(baseDir) == "" {
|
||||||
|
baseDir = DefaultSATBaseDir
|
||||||
|
}
|
||||||
return a.sat.RunNvidiaAcceptancePack(baseDir)
|
return a.sat.RunNvidiaAcceptancePack(baseDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) RunNvidiaAcceptancePackResult(baseDir string) (ActionResult, error) {
|
func (a *App) RunNvidiaAcceptancePackResult(baseDir string) (ActionResult, error) {
|
||||||
path, err := a.sat.RunNvidiaAcceptancePack(baseDir)
|
path, err := a.RunNvidiaAcceptancePack(baseDir)
|
||||||
body := "Archive written."
|
body := "Archive written."
|
||||||
if path != "" {
|
if path != "" {
|
||||||
body = "Archive written to " + path
|
body = "Archive written to " + path
|
||||||
@@ -291,11 +396,14 @@ func (a *App) RunNvidiaAcceptancePackResult(baseDir string) (ActionResult, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) RunMemoryAcceptancePack(baseDir string) (string, error) {
|
func (a *App) RunMemoryAcceptancePack(baseDir string) (string, error) {
|
||||||
|
if strings.TrimSpace(baseDir) == "" {
|
||||||
|
baseDir = DefaultSATBaseDir
|
||||||
|
}
|
||||||
return a.sat.RunMemoryAcceptancePack(baseDir)
|
return a.sat.RunMemoryAcceptancePack(baseDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) RunMemoryAcceptancePackResult(baseDir string) (ActionResult, error) {
|
func (a *App) RunMemoryAcceptancePackResult(baseDir string) (ActionResult, error) {
|
||||||
path, err := a.sat.RunMemoryAcceptancePack(baseDir)
|
path, err := a.RunMemoryAcceptancePack(baseDir)
|
||||||
body := "Archive written."
|
body := "Archive written."
|
||||||
if path != "" {
|
if path != "" {
|
||||||
body = "Archive written to " + path
|
body = "Archive written to " + path
|
||||||
@@ -304,11 +412,14 @@ func (a *App) RunMemoryAcceptancePackResult(baseDir string) (ActionResult, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) RunStorageAcceptancePack(baseDir string) (string, error) {
|
func (a *App) RunStorageAcceptancePack(baseDir string) (string, error) {
|
||||||
|
if strings.TrimSpace(baseDir) == "" {
|
||||||
|
baseDir = DefaultSATBaseDir
|
||||||
|
}
|
||||||
return a.sat.RunStorageAcceptancePack(baseDir)
|
return a.sat.RunStorageAcceptancePack(baseDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) RunStorageAcceptancePackResult(baseDir string) (ActionResult, error) {
|
func (a *App) RunStorageAcceptancePackResult(baseDir string) (ActionResult, error) {
|
||||||
path, err := a.sat.RunStorageAcceptancePack(baseDir)
|
path, err := a.RunStorageAcceptancePack(baseDir)
|
||||||
body := "Archive written."
|
body := "Archive written."
|
||||||
if path != "" {
|
if path != "" {
|
||||||
body = "Archive written to " + path
|
body = "Archive written to " + path
|
||||||
@@ -435,6 +546,18 @@ func bodyOr(body, fallback string) string {
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ReadRuntimeHealth(path string) (schema.RuntimeHealth, error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return schema.RuntimeHealth{}, err
|
||||||
|
}
|
||||||
|
var health schema.RuntimeHealth
|
||||||
|
if err := json.Unmarshal(raw, &health); err != nil {
|
||||||
|
return schema.RuntimeHealth{}, err
|
||||||
|
}
|
||||||
|
return health, nil
|
||||||
|
}
|
||||||
|
|
||||||
func latestSATSummaries() []string {
|
func latestSATSummaries() []string {
|
||||||
patterns := []struct {
|
patterns := []struct {
|
||||||
label string
|
label string
|
||||||
|
|||||||
@@ -66,6 +66,22 @@ func (f fakeExports) ExportFileToTarget(src string, target platform.RemovableTar
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeRuntime struct {
|
||||||
|
collectFn func(string) (schema.RuntimeHealth, error)
|
||||||
|
dumpFn func(string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeRuntime) CollectRuntimeHealth(exportDir string) (schema.RuntimeHealth, error) {
|
||||||
|
return f.collectFn(exportDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeRuntime) CaptureTechnicalDump(baseDir string) error {
|
||||||
|
if f.dumpFn != nil {
|
||||||
|
return f.dumpFn(baseDir)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type fakeTools struct {
|
type fakeTools struct {
|
||||||
tailFileFn func(string, int) string
|
tailFileFn func(string, int) string
|
||||||
checkToolsFn func([]string) []platform.ToolStatus
|
checkToolsFn func([]string) []platform.ToolStatus
|
||||||
@@ -110,6 +126,9 @@ func TestNetworkStatusFormatsInterfacesAndRoute(t *testing.T) {
|
|||||||
},
|
},
|
||||||
defaultRouteFn: func() string { return "10.0.0.1" },
|
defaultRouteFn: func() string { return "10.0.0.1" },
|
||||||
},
|
},
|
||||||
|
runtime: fakeRuntime{
|
||||||
|
collectFn: func(string) (schema.RuntimeHealth, error) { return schema.RuntimeHealth{}, nil },
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := a.NetworkStatus()
|
result, err := a.NetworkStatus()
|
||||||
@@ -138,6 +157,9 @@ func TestNetworkStatusHandlesNoInterfaces(t *testing.T) {
|
|||||||
listInterfacesFn: func() ([]platform.InterfaceInfo, error) { return nil, nil },
|
listInterfacesFn: func() ([]platform.InterfaceInfo, error) { return nil, nil },
|
||||||
defaultRouteFn: func() string { return "" },
|
defaultRouteFn: func() string { return "" },
|
||||||
},
|
},
|
||||||
|
runtime: fakeRuntime{
|
||||||
|
collectFn: func(string) (schema.RuntimeHealth, error) { return schema.RuntimeHealth{}, nil },
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := a.NetworkStatus()
|
result, err := a.NetworkStatus()
|
||||||
@@ -159,6 +181,9 @@ func TestNetworkStatusPropagatesListError(t *testing.T) {
|
|||||||
},
|
},
|
||||||
defaultRouteFn: func() string { return "" },
|
defaultRouteFn: func() string { return "" },
|
||||||
},
|
},
|
||||||
|
runtime: fakeRuntime{
|
||||||
|
collectFn: func(string) (schema.RuntimeHealth, error) { return schema.RuntimeHealth{}, nil },
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := a.NetworkStatus()
|
result, err := a.NetworkStatus()
|
||||||
@@ -183,6 +208,9 @@ func TestParseStaticIPv4ConfigAndDefaults(t *testing.T) {
|
|||||||
dhcpAllFn: func() (string, error) { return "", nil },
|
dhcpAllFn: func() (string, error) { return "", nil },
|
||||||
setStaticIPv4Fn: func(platform.StaticIPv4Config) (string, error) { return "", nil },
|
setStaticIPv4Fn: func(platform.StaticIPv4Config) (string, error) { return "", nil },
|
||||||
},
|
},
|
||||||
|
runtime: fakeRuntime{
|
||||||
|
collectFn: func(string) (schema.RuntimeHealth, error) { return schema.RuntimeHealth{}, nil },
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
defaults := a.DefaultStaticIPv4FormFields("eth0")
|
defaults := a.DefaultStaticIPv4FormFields("eth0")
|
||||||
@@ -219,6 +247,9 @@ func TestServiceActionResults(t *testing.T) {
|
|||||||
return string(action) + " ok", nil
|
return string(action) + " ok", nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
runtime: fakeRuntime{
|
||||||
|
collectFn: func(string) (schema.RuntimeHealth, error) { return schema.RuntimeHealth{}, nil },
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
statusResult, err := a.ServiceStatusResult("bee-audit")
|
statusResult, err := a.ServiceStatusResult("bee-audit")
|
||||||
@@ -301,6 +332,11 @@ func TestActionResultsUseFallbackBody(t *testing.T) {
|
|||||||
runMemoryFn: func(string) (string, error) { return "", nil },
|
runMemoryFn: func(string) (string, error) { return "", nil },
|
||||||
runStorageFn: func(string) (string, error) { return "", nil },
|
runStorageFn: func(string) (string, error) { return "", nil },
|
||||||
},
|
},
|
||||||
|
runtime: fakeRuntime{
|
||||||
|
collectFn: func(string) (schema.RuntimeHealth, error) {
|
||||||
|
return schema.RuntimeHealth{Status: "PARTIAL", ExportDir: "/tmp/export"}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if got, _ := a.DHCPOneResult("eth0"); got.Body != "DHCP completed." {
|
if got, _ := a.DHCPOneResult("eth0"); got.Body != "DHCP completed." {
|
||||||
@@ -349,6 +385,9 @@ func TestRunNvidiaAcceptancePackResult(t *testing.T) {
|
|||||||
runMemoryFn: func(string) (string, error) { return "", nil },
|
runMemoryFn: func(string) (string, error) { return "", nil },
|
||||||
runStorageFn: func(string) (string, error) { return "", nil },
|
runStorageFn: func(string) (string, error) { return "", nil },
|
||||||
},
|
},
|
||||||
|
runtime: fakeRuntime{
|
||||||
|
collectFn: func(string) (schema.RuntimeHealth, error) { return schema.RuntimeHealth{}, nil },
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := a.RunNvidiaAcceptancePackResult("/tmp/sat")
|
result, err := a.RunNvidiaAcceptancePackResult("/tmp/sat")
|
||||||
@@ -360,6 +399,50 @@ func TestRunNvidiaAcceptancePackResult(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunSATDefaultsToExportDir(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
oldSATBaseDir := DefaultSATBaseDir
|
||||||
|
DefaultSATBaseDir = "/tmp/export/bee-sat"
|
||||||
|
t.Cleanup(func() { DefaultSATBaseDir = oldSATBaseDir })
|
||||||
|
|
||||||
|
a := &App{
|
||||||
|
sat: fakeSAT{
|
||||||
|
runNvidiaFn: func(baseDir string) (string, error) {
|
||||||
|
if baseDir != "/tmp/export/bee-sat" {
|
||||||
|
t.Fatalf("nvidia baseDir=%q", baseDir)
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
},
|
||||||
|
runMemoryFn: func(baseDir string) (string, error) {
|
||||||
|
if baseDir != "/tmp/export/bee-sat" {
|
||||||
|
t.Fatalf("memory baseDir=%q", baseDir)
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
},
|
||||||
|
runStorageFn: func(baseDir string) (string, error) {
|
||||||
|
if baseDir != "/tmp/export/bee-sat" {
|
||||||
|
t.Fatalf("storage baseDir=%q", baseDir)
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
runtime: fakeRuntime{
|
||||||
|
collectFn: func(string) (schema.RuntimeHealth, error) { return schema.RuntimeHealth{}, nil },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := a.RunNvidiaAcceptancePack(""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := a.RunMemoryAcceptancePack(""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := a.RunStorageAcceptancePack(""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFormatSATSummary(t *testing.T) {
|
func TestFormatSATSummary(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -398,6 +481,28 @@ func TestHealthSummaryResultIncludesCompactSATSummary(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildSupportBundleIncludesExportDirContents(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
exportDir := filepath.Join(tmp, "export")
|
||||||
|
if err := os.MkdirAll(filepath.Join(exportDir, "bee-sat", "memory-run"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(exportDir, "bee-audit.json"), []byte(`{"ok":true}`), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(exportDir, "bee-sat", "memory-run", "verbose.log"), []byte("sat verbose"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
archive, err := BuildSupportBundle(exportDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildSupportBundle error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(archive); err != nil {
|
||||||
|
t.Fatalf("archive stat: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMainBanner(t *testing.T) {
|
func TestMainBanner(t *testing.T) {
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
oldAuditPath := DefaultAuditJSONPath
|
oldAuditPath := DefaultAuditJSONPath
|
||||||
|
|||||||
300
audit/internal/app/support_bundle.go
Normal file
300
audit/internal/app/support_bundle.go
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
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/lsmod.txt", cmd: []string{"lsmod"}},
|
||||||
|
{name: "system/lspci-nn.txt", cmd: []string{"lspci", "-nn"}},
|
||||||
|
{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-tail.txt", cmd: []string{"sh", "-c", "dmesg | tail -n 200"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := copyDirContents(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 cleanupOldSupportBundles(dir string) error {
|
||||||
|
matches, err := filepath.Glob(filepath.Join(dir, "bee-support-*.tar.gz"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
type entry struct {
|
||||||
|
path string
|
||||||
|
mod time.Time
|
||||||
|
}
|
||||||
|
list := make([]entry, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
info, err := os.Stat(match)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if time.Since(info.ModTime()) > 24*time.Hour {
|
||||||
|
_ = os.Remove(match)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
list = append(list, entry{path: match, mod: info.ModTime()})
|
||||||
|
}
|
||||||
|
sort.Slice(list, func(i, j int) bool { return list[i].mod.After(list[j].mod) })
|
||||||
|
if len(list) > 3 {
|
||||||
|
for _, old := range list[3:] {
|
||||||
|
_ = os.Remove(old.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 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 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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -57,6 +57,8 @@ func shouldIncludePCIeDevice(class string) bool {
|
|||||||
"host bridge",
|
"host bridge",
|
||||||
"isa bridge",
|
"isa bridge",
|
||||||
"pci bridge",
|
"pci bridge",
|
||||||
|
"performance counter",
|
||||||
|
"performance counters",
|
||||||
"ram memory",
|
"ram memory",
|
||||||
"system peripheral",
|
"system peripheral",
|
||||||
"communication controller",
|
"communication controller",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ func TestShouldIncludePCIeDevice(t *testing.T) {
|
|||||||
{"Host bridge", false},
|
{"Host bridge", false},
|
||||||
{"PCI bridge", false},
|
{"PCI bridge", false},
|
||||||
{"SMBus", false},
|
{"SMBus", false},
|
||||||
|
{"Performance counters", false},
|
||||||
{"Ethernet controller", true},
|
{"Ethernet controller", true},
|
||||||
{"RAID bus controller", true},
|
{"RAID bus controller", true},
|
||||||
{"Non-Volatile memory controller", true},
|
{"Non-Volatile memory controller", true},
|
||||||
|
|||||||
@@ -5,21 +5,31 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func collectPSUs() []schema.HardwarePowerSupply {
|
func collectPSUs() []schema.HardwarePowerSupply {
|
||||||
// ipmitool requires /dev/ipmi0 — not available on non-server hardware
|
var psus []schema.HardwarePowerSupply
|
||||||
out, err := exec.Command("ipmitool", "fru", "print").Output()
|
if out, err := exec.Command("ipmitool", "fru", "print").Output(); err == nil {
|
||||||
if err != nil {
|
psus = parseFRU(string(out))
|
||||||
|
} else {
|
||||||
|
slog.Info("psu: fru unavailable", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sdrData := map[int]psuSDR{}
|
||||||
|
if sdrOut, err := exec.Command("ipmitool", "sdr").Output(); err == nil {
|
||||||
|
sdrData = parsePSUSDR(string(sdrOut))
|
||||||
|
if len(psus) == 0 {
|
||||||
|
psus = synthesizePSUsFromSDR(sdrData)
|
||||||
|
} else {
|
||||||
|
mergePSUSDR(psus, sdrData)
|
||||||
|
}
|
||||||
|
} else if len(psus) == 0 {
|
||||||
slog.Info("psu: ipmitool unavailable, skipping", "err", err)
|
slog.Info("psu: ipmitool unavailable, skipping", "err", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
psus := parseFRU(string(out))
|
|
||||||
if sdrOut, err := exec.Command("ipmitool", "sdr").Output(); err == nil {
|
|
||||||
mergePSUSDR(psus, parsePSUSDR(string(sdrOut)))
|
|
||||||
}
|
|
||||||
slog.Info("psu: collected", "count", len(psus))
|
slog.Info("psu: collected", "count", len(psus))
|
||||||
return psus
|
return psus
|
||||||
}
|
}
|
||||||
@@ -79,9 +89,7 @@ func parseFRUBlock(block string, slotIdx int) (schema.HardwarePowerSupply, bool)
|
|||||||
|
|
||||||
// Only process PSU FRU records
|
// Only process PSU FRU records
|
||||||
headerLower := strings.ToLower(header)
|
headerLower := strings.ToLower(header)
|
||||||
if !strings.Contains(headerLower, "psu") &&
|
if !isPSUHeader(headerLower) {
|
||||||
!strings.Contains(headerLower, "power supply") &&
|
|
||||||
!strings.Contains(headerLower, "power_supply") {
|
|
||||||
return schema.HardwarePowerSupply{}, false
|
return schema.HardwarePowerSupply{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,21 +97,24 @@ func parseFRUBlock(block string, slotIdx int) (schema.HardwarePowerSupply, bool)
|
|||||||
psu := schema.HardwarePowerSupply{Present: &present}
|
psu := schema.HardwarePowerSupply{Present: &present}
|
||||||
|
|
||||||
slotStr := strconv.Itoa(slotIdx)
|
slotStr := strconv.Itoa(slotIdx)
|
||||||
|
if slot, ok := parsePSUSlot(header); ok && slot > 0 {
|
||||||
|
slotStr = strconv.Itoa(slot - 1)
|
||||||
|
}
|
||||||
psu.Slot = &slotStr
|
psu.Slot = &slotStr
|
||||||
|
|
||||||
if v := cleanDMIValue(fields["Board Product"]); v != "" {
|
if v := firstNonEmptyField(fields, "Board Product", "Product Name", "Product Part Number"); v != "" {
|
||||||
psu.Model = &v
|
psu.Model = &v
|
||||||
}
|
}
|
||||||
if v := cleanDMIValue(fields["Board Mfg"]); v != "" {
|
if v := firstNonEmptyField(fields, "Board Mfg", "Product Manufacturer", "Product Manufacturer Name"); v != "" {
|
||||||
psu.Vendor = &v
|
psu.Vendor = &v
|
||||||
}
|
}
|
||||||
if v := cleanDMIValue(fields["Board Serial"]); v != "" {
|
if v := firstNonEmptyField(fields, "Board Serial", "Product Serial", "Product Serial Number"); v != "" {
|
||||||
psu.SerialNumber = &v
|
psu.SerialNumber = &v
|
||||||
}
|
}
|
||||||
if v := cleanDMIValue(fields["Board Part Number"]); v != "" {
|
if v := firstNonEmptyField(fields, "Board Part Number", "Product Part Number", "Part Number"); v != "" {
|
||||||
psu.PartNumber = &v
|
psu.PartNumber = &v
|
||||||
}
|
}
|
||||||
if v := cleanDMIValue(fields["Board Extra"]); v != "" {
|
if v := firstNonEmptyField(fields, "Board Extra", "Product Version", "Board Version"); v != "" {
|
||||||
psu.Firmware = &v
|
psu.Firmware = &v
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +131,23 @@ func parseFRUBlock(block string, slotIdx int) (schema.HardwarePowerSupply, bool)
|
|||||||
return psu, true
|
return psu, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isPSUHeader(headerLower string) bool {
|
||||||
|
return strings.Contains(headerLower, "psu") ||
|
||||||
|
strings.Contains(headerLower, "pws") ||
|
||||||
|
strings.Contains(headerLower, "power supply") ||
|
||||||
|
strings.Contains(headerLower, "power_supply") ||
|
||||||
|
strings.Contains(headerLower, "power module")
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyField(fields map[string]string, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
if value := cleanDMIValue(fields[key]); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type psuSDR struct {
|
type psuSDR struct {
|
||||||
slot int
|
slot int
|
||||||
status string
|
status string
|
||||||
@@ -131,7 +159,13 @@ type psuSDR struct {
|
|||||||
healthPct *float64
|
healthPct *float64
|
||||||
}
|
}
|
||||||
|
|
||||||
var psuSlotRe = regexp.MustCompile(`(?i)\bpsu?\s*([0-9]+)\b|\bps\s*([0-9]+)\b`)
|
var psuSlotPatterns = []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`(?i)\bpsu?\s*([0-9]+)\b`),
|
||||||
|
regexp.MustCompile(`(?i)\bps\s*([0-9]+)\b`),
|
||||||
|
regexp.MustCompile(`(?i)\bpws\s*([0-9]+)\b`),
|
||||||
|
regexp.MustCompile(`(?i)\bpower\s*supply(?:\s*bay)?\s*([0-9]+)\b`),
|
||||||
|
regexp.MustCompile(`(?i)\bbay\s*([0-9]+)\b`),
|
||||||
|
}
|
||||||
|
|
||||||
func parsePSUSDR(raw string) map[int]psuSDR {
|
func parsePSUSDR(raw string) map[int]psuSDR {
|
||||||
out := map[int]psuSDR{}
|
out := map[int]psuSDR{}
|
||||||
@@ -164,6 +198,8 @@ func parsePSUSDR(raw string) map[int]psuSDR {
|
|||||||
entry.inputPowerW = parseFloatPtr(value)
|
entry.inputPowerW = parseFloatPtr(value)
|
||||||
case strings.Contains(lowerName, "output power"):
|
case strings.Contains(lowerName, "output power"):
|
||||||
entry.outputPowerW = parseFloatPtr(value)
|
entry.outputPowerW = parseFloatPtr(value)
|
||||||
|
case strings.Contains(lowerName, "power supply bay"), strings.Contains(lowerName, "psu bay"):
|
||||||
|
entry.outputPowerW = parseFloatPtr(value)
|
||||||
case strings.Contains(lowerName, "input voltage"), strings.Contains(lowerName, "ac input"):
|
case strings.Contains(lowerName, "input voltage"), strings.Contains(lowerName, "ac input"):
|
||||||
entry.inputVoltage = parseFloatPtr(value)
|
entry.inputVoltage = parseFloatPtr(value)
|
||||||
case strings.Contains(lowerName, "temp"):
|
case strings.Contains(lowerName, "temp"):
|
||||||
@@ -176,6 +212,49 @@ func parsePSUSDR(raw string) map[int]psuSDR {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func synthesizePSUsFromSDR(sdr map[int]psuSDR) []schema.HardwarePowerSupply {
|
||||||
|
if len(sdr) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
slots := make([]int, 0, len(sdr))
|
||||||
|
for slot := range sdr {
|
||||||
|
slots = append(slots, slot)
|
||||||
|
}
|
||||||
|
sort.Ints(slots)
|
||||||
|
|
||||||
|
out := make([]schema.HardwarePowerSupply, 0, len(slots))
|
||||||
|
for _, slot := range slots {
|
||||||
|
entry := sdr[slot]
|
||||||
|
present := true
|
||||||
|
status := entry.status
|
||||||
|
if status == "" {
|
||||||
|
status = statusUnknown
|
||||||
|
}
|
||||||
|
slotStr := strconv.Itoa(slot - 1)
|
||||||
|
model := "PSU"
|
||||||
|
psu := schema.HardwarePowerSupply{
|
||||||
|
HardwareComponentStatus: schema.HardwareComponentStatus{Status: &status},
|
||||||
|
Slot: &slotStr,
|
||||||
|
Present: &present,
|
||||||
|
Model: &model,
|
||||||
|
InputPowerW: entry.inputPowerW,
|
||||||
|
OutputPowerW: entry.outputPowerW,
|
||||||
|
InputVoltage: entry.inputVoltage,
|
||||||
|
TemperatureC: entry.temperatureC,
|
||||||
|
}
|
||||||
|
if entry.healthPct != nil {
|
||||||
|
psu.LifeRemainingPct = entry.healthPct
|
||||||
|
lifeUsed := 100 - *entry.healthPct
|
||||||
|
psu.LifeUsedPct = &lifeUsed
|
||||||
|
}
|
||||||
|
if entry.reason != "" {
|
||||||
|
psu.ErrorDescription = &entry.reason
|
||||||
|
}
|
||||||
|
out = append(out, psu)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func mergePSUSDR(psus []schema.HardwarePowerSupply, sdr map[int]psuSDR) {
|
func mergePSUSDR(psus []schema.HardwarePowerSupply, sdr map[int]psuSDR) {
|
||||||
for i := range psus {
|
for i := range psus {
|
||||||
slotIdx, err := strconv.Atoi(derefPSUSlot(psus[i].Slot))
|
slotIdx, err := strconv.Atoi(derefPSUSlot(psus[i].Slot))
|
||||||
@@ -231,17 +310,19 @@ func splitSDRFields(line string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parsePSUSlot(name string) (int, bool) {
|
func parsePSUSlot(name string) (int, bool) {
|
||||||
m := psuSlotRe.FindStringSubmatch(strings.ToLower(name))
|
for _, re := range psuSlotPatterns {
|
||||||
if len(m) == 0 {
|
m := re.FindStringSubmatch(strings.ToLower(name))
|
||||||
return 0, false
|
if len(m) == 0 {
|
||||||
}
|
|
||||||
for _, group := range m[1:] {
|
|
||||||
if group == "" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
n, err := strconv.Atoi(group)
|
for _, group := range m[1:] {
|
||||||
if err == nil && n > 0 {
|
if group == "" {
|
||||||
return n, true
|
continue
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(group)
|
||||||
|
if err == nil && n > 0 {
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 0, false
|
return 0, false
|
||||||
|
|||||||
@@ -38,3 +38,54 @@ PS2 Input Power | 0 Watts | cr
|
|||||||
t.Fatalf("ps2 status=%q", got[2].status)
|
t.Fatalf("ps2 status=%q", got[2].status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParsePSUSlotVendorVariants(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{name: "PWS1 Status", want: 1},
|
||||||
|
{name: "Power Supply Bay 8", want: 8},
|
||||||
|
{name: "PS 6 Input Power", want: 6},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got, ok := parsePSUSlot(tt.name)
|
||||||
|
if !ok || got != tt.want {
|
||||||
|
t.Fatalf("parsePSUSlot(%q)=(%d,%v) want (%d,true)", tt.name, got, ok, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSynthesizePSUsFromSDR(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
health := 97.0
|
||||||
|
outputPower := 915.0
|
||||||
|
got := synthesizePSUsFromSDR(map[int]psuSDR{
|
||||||
|
1: {
|
||||||
|
slot: 1,
|
||||||
|
status: statusOK,
|
||||||
|
outputPowerW: &outputPower,
|
||||||
|
healthPct: &health,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("len(got)=%d want 1", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Slot == nil || *got[0].Slot != "0" {
|
||||||
|
t.Fatalf("slot=%v want 0", got[0].Slot)
|
||||||
|
}
|
||||||
|
if got[0].OutputPowerW == nil || *got[0].OutputPowerW != 915 {
|
||||||
|
t.Fatalf("output power=%v", got[0].OutputPowerW)
|
||||||
|
}
|
||||||
|
if got[0].LifeRemainingPct == nil || *got[0].LifeRemainingPct != 97 {
|
||||||
|
t.Fatalf("life remaining=%v", got[0].LifeRemainingPct)
|
||||||
|
}
|
||||||
|
if got[0].LifeUsedPct == nil || *got[0].LifeUsedPct != 3 {
|
||||||
|
t.Fatalf("life used=%v", got[0].LifeUsedPct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,19 +113,8 @@ func isLikelyPSUTemp(chip, feature string) bool {
|
|||||||
|
|
||||||
func detectPSUSlot(parts ...string) (string, bool) {
|
func detectPSUSlot(parts ...string) (string, bool) {
|
||||||
for _, part := range parts {
|
for _, part := range parts {
|
||||||
lower := strings.ToLower(part)
|
if value, ok := parsePSUSlot(part); ok && value > 0 {
|
||||||
matches := psuSlotRe.FindStringSubmatch(lower)
|
return strconv.Itoa(value - 1), true
|
||||||
if len(matches) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, group := range matches[1:] {
|
|
||||||
if group == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
value, err := strconv.Atoi(group)
|
|
||||||
if err == nil && value > 0 {
|
|
||||||
return strconv.Itoa(value - 1), true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", false
|
return "", false
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func collectStorage() []schema.HardwareStorage {
|
func collectStorage() []schema.HardwareStorage {
|
||||||
devs := lsblkDevices()
|
devs := discoverStorageDevices()
|
||||||
result := make([]schema.HardwareStorage, 0, len(devs))
|
result := make([]schema.HardwareStorage, 0, len(devs))
|
||||||
for _, dev := range devs {
|
for _, dev := range devs {
|
||||||
var s schema.HardwareStorage
|
var s schema.HardwareStorage
|
||||||
@@ -39,6 +41,47 @@ type lsblkRoot struct {
|
|||||||
Blockdevices []lsblkDevice `json:"blockdevices"`
|
Blockdevices []lsblkDevice `json:"blockdevices"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type nvmeListRoot struct {
|
||||||
|
Devices []nvmeListDevice `json:"Devices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type nvmeListDevice struct {
|
||||||
|
DevicePath string `json:"DevicePath"`
|
||||||
|
ModelNumber string `json:"ModelNumber"`
|
||||||
|
SerialNumber string `json:"SerialNumber"`
|
||||||
|
Firmware string `json:"Firmware"`
|
||||||
|
PhysicalSize int64 `json:"PhysicalSize"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func discoverStorageDevices() []lsblkDevice {
|
||||||
|
merged := map[string]lsblkDevice{}
|
||||||
|
for _, dev := range lsblkDevices() {
|
||||||
|
if dev.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
merged[dev.Name] = dev
|
||||||
|
}
|
||||||
|
for _, dev := range nvmeListDevices() {
|
||||||
|
if dev.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current := merged[dev.Name]
|
||||||
|
merged[dev.Name] = mergeStorageDevice(current, dev)
|
||||||
|
}
|
||||||
|
|
||||||
|
disks := make([]lsblkDevice, 0, len(merged))
|
||||||
|
for _, dev := range merged {
|
||||||
|
if dev.Type == "" {
|
||||||
|
dev.Type = "disk"
|
||||||
|
}
|
||||||
|
if dev.Type != "disk" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
disks = append(disks, dev)
|
||||||
|
}
|
||||||
|
return disks
|
||||||
|
}
|
||||||
|
|
||||||
func lsblkDevices() []lsblkDevice {
|
func lsblkDevices() []lsblkDevice {
|
||||||
out, err := exec.Command("lsblk", "-J", "-d",
|
out, err := exec.Command("lsblk", "-J", "-d",
|
||||||
"-o", "NAME,TYPE,SIZE,SERIAL,MODEL,TRAN,HCTL").Output()
|
"-o", "NAME,TYPE,SIZE,SERIAL,MODEL,TRAN,HCTL").Output()
|
||||||
@@ -60,6 +103,59 @@ func lsblkDevices() []lsblkDevice {
|
|||||||
return disks
|
return disks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nvmeListDevices() []lsblkDevice {
|
||||||
|
out, err := exec.Command("nvme", "list", "-o", "json").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var root nvmeListRoot
|
||||||
|
if err := json.Unmarshal(out, &root); err != nil {
|
||||||
|
slog.Warn("storage: nvme list parse failed", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
devices := make([]lsblkDevice, 0, len(root.Devices))
|
||||||
|
for _, dev := range root.Devices {
|
||||||
|
name := filepath.Base(strings.TrimSpace(dev.DevicePath))
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
devices = append(devices, lsblkDevice{
|
||||||
|
Name: name,
|
||||||
|
Type: "disk",
|
||||||
|
Size: strconv.FormatInt(dev.PhysicalSize, 10),
|
||||||
|
Serial: strings.TrimSpace(dev.SerialNumber),
|
||||||
|
Model: strings.TrimSpace(dev.ModelNumber),
|
||||||
|
Tran: "nvme",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeStorageDevice(existing, incoming lsblkDevice) lsblkDevice {
|
||||||
|
if existing.Name == "" {
|
||||||
|
return incoming
|
||||||
|
}
|
||||||
|
if existing.Type == "" {
|
||||||
|
existing.Type = incoming.Type
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(existing.Size) == "" {
|
||||||
|
existing.Size = incoming.Size
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(existing.Serial) == "" {
|
||||||
|
existing.Serial = incoming.Serial
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(existing.Model) == "" {
|
||||||
|
existing.Model = incoming.Model
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(existing.Tran) == "" {
|
||||||
|
existing.Tran = incoming.Tran
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(existing.Hctl) == "" {
|
||||||
|
existing.Hctl = incoming.Hctl
|
||||||
|
}
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
// smartctlInfo is the subset of smartctl -j -a output we care about.
|
// smartctlInfo is the subset of smartctl -j -a output we care about.
|
||||||
type smartctlInfo struct {
|
type smartctlInfo struct {
|
||||||
ModelFamily string `json:"model_family"`
|
ModelFamily string `json:"model_family"`
|
||||||
@@ -255,6 +351,18 @@ func enrichWithNVMe(dev lsblkDevice) schema.HardwareStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
devPath := "/dev/" + dev.Name
|
devPath := "/dev/" + dev.Name
|
||||||
|
if v := cleanDMIValue(strings.TrimSpace(dev.Model)); v != "" {
|
||||||
|
s.Model = &v
|
||||||
|
}
|
||||||
|
if v := cleanDMIValue(strings.TrimSpace(dev.Serial)); v != "" {
|
||||||
|
s.SerialNumber = &v
|
||||||
|
}
|
||||||
|
if size := parseStorageBytes(dev.Size); size > 0 {
|
||||||
|
gb := int(size / 1_000_000_000)
|
||||||
|
if gb > 0 {
|
||||||
|
s.SizeGB = &gb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// id-ctrl: model, serial, firmware, capacity
|
// id-ctrl: model, serial, firmware, capacity
|
||||||
if out, err := exec.Command("nvme", "id-ctrl", devPath, "-o", "json").Output(); err == nil {
|
if out, err := exec.Command("nvme", "id-ctrl", devPath, "-o", "json").Output(); err == nil {
|
||||||
@@ -335,6 +443,14 @@ func enrichWithNVMe(dev lsblkDevice) schema.HardwareStorage {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseStorageBytes(raw string) int64 {
|
||||||
|
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||||
|
if err == nil && value > 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func nvmeDataUnitsToBytes(units int64) int64 {
|
func nvmeDataUnitsToBytes(units int64) int64 {
|
||||||
if units <= 0 {
|
if units <= 0 {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
33
audit/internal/collector/storage_discovery_test.go
Normal file
33
audit/internal/collector/storage_discovery_test.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package collector
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestMergeStorageDevicePrefersNonEmptyFields(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := mergeStorageDevice(
|
||||||
|
lsblkDevice{Name: "nvme0n1", Type: "disk", Tran: "nvme"},
|
||||||
|
lsblkDevice{Name: "nvme0n1", Type: "disk", Size: "1024", Serial: "SN123", Model: "Kioxia"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if got.Serial != "SN123" {
|
||||||
|
t.Fatalf("serial=%q want SN123", got.Serial)
|
||||||
|
}
|
||||||
|
if got.Model != "Kioxia" {
|
||||||
|
t.Fatalf("model=%q want Kioxia", got.Model)
|
||||||
|
}
|
||||||
|
if got.Size != "1024" {
|
||||||
|
t.Fatalf("size=%q want 1024", got.Size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseStorageBytes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if got := parseStorageBytes(" 2048 "); got != 2048 {
|
||||||
|
t.Fatalf("parseStorageBytes=%d want 2048", got)
|
||||||
|
}
|
||||||
|
if got := parseStorageBytes("1.92 TB"); got != 0 {
|
||||||
|
t.Fatalf("parseStorageBytes invalid=%d want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
164
audit/internal/platform/runtime.go
Normal file
164
audit/internal/platform/runtime.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bee/audit/internal/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
var runtimeRequiredTools = []string{
|
||||||
|
"dmidecode",
|
||||||
|
"lspci",
|
||||||
|
"lsblk",
|
||||||
|
"smartctl",
|
||||||
|
"nvme",
|
||||||
|
"ipmitool",
|
||||||
|
"nvidia-smi",
|
||||||
|
"nvidia-bug-report.sh",
|
||||||
|
"bee-gpu-stress",
|
||||||
|
"dhclient",
|
||||||
|
"mount",
|
||||||
|
}
|
||||||
|
|
||||||
|
var runtimeTrackedServices = []string{
|
||||||
|
"bee-network",
|
||||||
|
"bee-nvidia",
|
||||||
|
"bee-preflight",
|
||||||
|
"bee-audit",
|
||||||
|
"bee-web",
|
||||||
|
"bee-sshsetup",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *System) CollectRuntimeHealth(exportDir string) (schema.RuntimeHealth, error) {
|
||||||
|
checkedAt := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
health := schema.RuntimeHealth{
|
||||||
|
Status: "OK",
|
||||||
|
CheckedAt: checkedAt,
|
||||||
|
ExportDir: strings.TrimSpace(exportDir),
|
||||||
|
}
|
||||||
|
|
||||||
|
if health.ExportDir != "" {
|
||||||
|
if err := os.MkdirAll(health.ExportDir, 0755); err != nil {
|
||||||
|
health.Status = "FAILED"
|
||||||
|
health.Issues = append(health.Issues, schema.RuntimeIssue{
|
||||||
|
Code: "export_dir_unavailable",
|
||||||
|
Severity: "critical",
|
||||||
|
Description: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interfaces, err := s.ListInterfaces()
|
||||||
|
if err == nil {
|
||||||
|
health.Interfaces = make([]schema.RuntimeInterface, 0, len(interfaces))
|
||||||
|
hasIPv4 := false
|
||||||
|
missingIPv4 := false
|
||||||
|
for _, iface := range interfaces {
|
||||||
|
outcome := "no_offer"
|
||||||
|
if len(iface.IPv4) > 0 {
|
||||||
|
outcome = "lease_acquired"
|
||||||
|
hasIPv4 = true
|
||||||
|
} else if strings.EqualFold(iface.State, "DOWN") {
|
||||||
|
outcome = "link_down"
|
||||||
|
} else {
|
||||||
|
missingIPv4 = true
|
||||||
|
}
|
||||||
|
health.Interfaces = append(health.Interfaces, schema.RuntimeInterface{
|
||||||
|
Name: iface.Name,
|
||||||
|
State: iface.State,
|
||||||
|
IPv4: iface.IPv4,
|
||||||
|
Outcome: outcome,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case hasIPv4 && !missingIPv4:
|
||||||
|
health.NetworkStatus = "OK"
|
||||||
|
case hasIPv4:
|
||||||
|
health.NetworkStatus = "PARTIAL"
|
||||||
|
health.Issues = append(health.Issues, schema.RuntimeIssue{
|
||||||
|
Code: "dhcp_partial",
|
||||||
|
Severity: "warning",
|
||||||
|
Description: "At least one interface did not obtain IPv4 connectivity.",
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
health.NetworkStatus = "FAILED"
|
||||||
|
health.Issues = append(health.Issues, schema.RuntimeIssue{
|
||||||
|
Code: "dhcp_failed",
|
||||||
|
Severity: "warning",
|
||||||
|
Description: "No physical interface obtained IPv4 connectivity.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tool := range s.CheckTools(runtimeRequiredTools) {
|
||||||
|
health.Tools = append(health.Tools, schema.RuntimeToolStatus{
|
||||||
|
Name: tool.Name,
|
||||||
|
Path: tool.Path,
|
||||||
|
OK: tool.OK,
|
||||||
|
})
|
||||||
|
if !tool.OK {
|
||||||
|
health.Issues = append(health.Issues, schema.RuntimeIssue{
|
||||||
|
Code: "tool_missing",
|
||||||
|
Severity: "warning",
|
||||||
|
Description: "Required tool missing: " + tool.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range runtimeTrackedServices {
|
||||||
|
health.Services = append(health.Services, schema.RuntimeServiceStatus{
|
||||||
|
Name: name,
|
||||||
|
Status: s.ServiceState(name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
lsmodText := commandText("lsmod")
|
||||||
|
health.DriverReady = strings.Contains(lsmodText, "nvidia ")
|
||||||
|
if !health.DriverReady {
|
||||||
|
health.Issues = append(health.Issues, schema.RuntimeIssue{
|
||||||
|
Code: "nvidia_kernel_module_missing",
|
||||||
|
Severity: "warning",
|
||||||
|
Description: "NVIDIA kernel module is not loaded.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if health.DriverReady && !strings.Contains(lsmodText, "nvidia_modeset") {
|
||||||
|
health.Issues = append(health.Issues, schema.RuntimeIssue{
|
||||||
|
Code: "nvidia_modeset_failed",
|
||||||
|
Severity: "warning",
|
||||||
|
Description: "nvidia-modeset is not loaded; display/CUDA stack may be partial.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if out, err := exec.Command("nvidia-smi", "-L").CombinedOutput(); err == nil && strings.TrimSpace(string(out)) != "" {
|
||||||
|
health.DriverReady = true
|
||||||
|
}
|
||||||
|
|
||||||
|
health.CUDAReady = false
|
||||||
|
if lookErr := exec.Command("sh", "-c", "command -v bee-gpu-stress >/dev/null 2>&1").Run(); lookErr == nil {
|
||||||
|
out, err := exec.Command("bee-gpu-stress", "--seconds", "1", "--size-mb", "1").CombinedOutput()
|
||||||
|
if err == nil {
|
||||||
|
health.CUDAReady = true
|
||||||
|
} else if strings.Contains(strings.ToLower(string(out)), "cuda_error_system_not_ready") {
|
||||||
|
health.Issues = append(health.Issues, schema.RuntimeIssue{
|
||||||
|
Code: "cuda_runtime_not_ready",
|
||||||
|
Severity: "warning",
|
||||||
|
Description: "CUDA runtime is not ready for GPU SAT.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if health.Status != "FAILED" && len(health.Issues) > 0 {
|
||||||
|
health.Status = "PARTIAL"
|
||||||
|
}
|
||||||
|
return health, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandText(name string, args ...string) string {
|
||||||
|
raw, err := exec.Command(name, args...).CombinedOutput()
|
||||||
|
if err != nil && len(raw) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ func (s *System) RunStorageAcceptancePack(baseDir string) (string, error) {
|
|||||||
if err := os.MkdirAll(runDir, 0755); err != nil {
|
if err := os.MkdirAll(runDir, 0755); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
verboseLog := filepath.Join(runDir, "verbose.log")
|
||||||
|
|
||||||
devices, err := listStorageDevices()
|
devices, err := listStorageDevices()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -59,7 +60,7 @@ func (s *System) RunStorageAcceptancePack(baseDir string) (string, error) {
|
|||||||
commands := storageSATCommands(devPath)
|
commands := storageSATCommands(devPath)
|
||||||
for cmdIndex, job := range commands {
|
for cmdIndex, job := range commands {
|
||||||
name := fmt.Sprintf("%s-%02d-%s.log", prefix, cmdIndex+1, job.name)
|
name := fmt.Sprintf("%s-%02d-%s.log", prefix, cmdIndex+1, job.name)
|
||||||
out, err := exec.Command(job.cmd[0], job.cmd[1:]...).CombinedOutput()
|
out, err := runSATCommand(verboseLog, job.name, job.cmd)
|
||||||
if writeErr := os.WriteFile(filepath.Join(runDir, name), out, 0644); writeErr != nil {
|
if writeErr := os.WriteFile(filepath.Join(runDir, name), out, 0644); writeErr != nil {
|
||||||
return "", writeErr
|
return "", writeErr
|
||||||
}
|
}
|
||||||
@@ -100,7 +101,7 @@ func nvidiaSATJobs() []satJob {
|
|||||||
{name: "01-nvidia-smi-q.log", cmd: []string{"nvidia-smi", "-q"}},
|
{name: "01-nvidia-smi-q.log", cmd: []string{"nvidia-smi", "-q"}},
|
||||||
{name: "02-dmidecode-baseboard.log", cmd: []string{"dmidecode", "-t", "baseboard"}},
|
{name: "02-dmidecode-baseboard.log", cmd: []string{"dmidecode", "-t", "baseboard"}},
|
||||||
{name: "03-dmidecode-system.log", cmd: []string{"dmidecode", "-t", "system"}},
|
{name: "03-dmidecode-system.log", cmd: []string{"dmidecode", "-t", "system"}},
|
||||||
{name: "04-nvidia-bug-report.log", cmd: []string{"nvidia-bug-report.sh", "--output", "{{run_dir}}/nvidia-bug-report.log"}},
|
{name: "04-nvidia-bug-report.log", cmd: []string{"nvidia-bug-report.sh", "--output-file", "{{run_dir}}/nvidia-bug-report.log"}},
|
||||||
{name: "05-bee-gpu-stress.log", cmd: []string{"bee-gpu-stress", "--seconds", fmt.Sprintf("%d", seconds), "--size-mb", fmt.Sprintf("%d", sizeMB)}},
|
{name: "05-bee-gpu-stress.log", cmd: []string{"bee-gpu-stress", "--seconds", fmt.Sprintf("%d", seconds), "--size-mb", fmt.Sprintf("%d", sizeMB)}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,6 +115,7 @@ func runAcceptancePack(baseDir, prefix string, jobs []satJob) (string, error) {
|
|||||||
if err := os.MkdirAll(runDir, 0755); err != nil {
|
if err := os.MkdirAll(runDir, 0755); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
verboseLog := filepath.Join(runDir, "verbose.log")
|
||||||
|
|
||||||
var summary strings.Builder
|
var summary strings.Builder
|
||||||
stats := satStats{}
|
stats := satStats{}
|
||||||
@@ -123,7 +125,7 @@ func runAcceptancePack(baseDir, prefix string, jobs []satJob) (string, error) {
|
|||||||
for _, arg := range job.cmd {
|
for _, arg := range job.cmd {
|
||||||
cmd = append(cmd, strings.ReplaceAll(arg, "{{run_dir}}", runDir))
|
cmd = append(cmd, strings.ReplaceAll(arg, "{{run_dir}}", runDir))
|
||||||
}
|
}
|
||||||
out, err := exec.Command(cmd[0], cmd[1:]...).CombinedOutput()
|
out, err := runSATCommand(verboseLog, job.name, cmd)
|
||||||
if writeErr := os.WriteFile(filepath.Join(runDir, job.name), out, 0644); writeErr != nil {
|
if writeErr := os.WriteFile(filepath.Join(runDir, job.name), out, 0644); writeErr != nil {
|
||||||
return "", writeErr
|
return "", writeErr
|
||||||
}
|
}
|
||||||
@@ -219,6 +221,7 @@ func classifySATResult(name string, out []byte, err error) (string, int) {
|
|||||||
strings.Contains(text, "unknown command") ||
|
strings.Contains(text, "unknown command") ||
|
||||||
strings.Contains(text, "not implemented") ||
|
strings.Contains(text, "not implemented") ||
|
||||||
strings.Contains(text, "not available") ||
|
strings.Contains(text, "not available") ||
|
||||||
|
strings.Contains(text, "cuda_error_system_not_ready") ||
|
||||||
strings.Contains(text, "no such device") ||
|
strings.Contains(text, "no such device") ||
|
||||||
(strings.Contains(name, "self-test") && strings.Contains(text, "aborted")) {
|
(strings.Contains(name, "self-test") && strings.Contains(text, "aborted")) {
|
||||||
return "UNSUPPORTED", rc
|
return "UNSUPPORTED", rc
|
||||||
@@ -226,6 +229,42 @@ func classifySATResult(name string, out []byte, err error) (string, int) {
|
|||||||
return "FAILED", rc
|
return "FAILED", rc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runSATCommand(verboseLog, name string, cmd []string) ([]byte, error) {
|
||||||
|
start := time.Now().UTC()
|
||||||
|
appendSATVerboseLog(verboseLog,
|
||||||
|
fmt.Sprintf("[%s] start %s", start.Format(time.RFC3339), name),
|
||||||
|
"cmd: "+strings.Join(cmd, " "),
|
||||||
|
)
|
||||||
|
|
||||||
|
out, err := exec.Command(cmd[0], cmd[1:]...).CombinedOutput()
|
||||||
|
|
||||||
|
rc := 0
|
||||||
|
if err != nil {
|
||||||
|
rc = 1
|
||||||
|
}
|
||||||
|
appendSATVerboseLog(verboseLog,
|
||||||
|
fmt.Sprintf("[%s] finish %s", time.Now().UTC().Format(time.RFC3339), name),
|
||||||
|
fmt.Sprintf("rc: %d", rc),
|
||||||
|
fmt.Sprintf("duration_ms: %d", time.Since(start).Milliseconds()),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendSATVerboseLog(path string, lines ...string) {
|
||||||
|
if path == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
for _, line := range lines {
|
||||||
|
_, _ = io.WriteString(f, line+"\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func envInt(name string, fallback int) int {
|
func envInt(name string, fallback int) int {
|
||||||
raw := strings.TrimSpace(os.Getenv(name))
|
raw := strings.TrimSpace(os.Getenv(name))
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ func TestRunNvidiaAcceptancePackIncludesGPUStress(t *testing.T) {
|
|||||||
if got := jobs[4].cmd[0]; got != "bee-gpu-stress" {
|
if got := jobs[4].cmd[0]; got != "bee-gpu-stress" {
|
||||||
t.Fatalf("gpu stress command=%q want bee-gpu-stress", got)
|
t.Fatalf("gpu stress command=%q want bee-gpu-stress", got)
|
||||||
}
|
}
|
||||||
|
if got := jobs[3].cmd[1]; got != "--output-file" {
|
||||||
|
t.Fatalf("bug report flag=%q want --output-file", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNvidiaSATJobsUseEnvOverrides(t *testing.T) {
|
func TestNvidiaSATJobsUseEnvOverrides(t *testing.T) {
|
||||||
@@ -76,6 +79,7 @@ func TestClassifySATResult(t *testing.T) {
|
|||||||
{name: "ok", job: "memtester", out: "done", err: nil, status: "OK"},
|
{name: "ok", job: "memtester", out: "done", err: nil, status: "OK"},
|
||||||
{name: "unsupported", job: "smartctl-self-test-short", out: "Self-test not supported", err: errors.New("rc 1"), status: "UNSUPPORTED"},
|
{name: "unsupported", job: "smartctl-self-test-short", out: "Self-test not supported", err: errors.New("rc 1"), status: "UNSUPPORTED"},
|
||||||
{name: "failed", job: "bee-gpu-stress", out: "cuda error", err: errors.New("rc 1"), status: "FAILED"},
|
{name: "failed", job: "bee-gpu-stress", out: "cuda error", err: errors.New("rc 1"), status: "FAILED"},
|
||||||
|
{name: "cuda not ready", job: "bee-gpu-stress", out: "cuInit failed: CUDA_ERROR_SYSTEM_NOT_READY", err: errors.New("rc 1"), status: "UNSUPPORTED"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
122
audit/internal/platform/techdump.go
Normal file
122
audit/internal/platform/techdump.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var techDumpFixedCommands = []struct {
|
||||||
|
Name string
|
||||||
|
Args []string
|
||||||
|
File string
|
||||||
|
}{
|
||||||
|
{Name: "dmidecode", Args: []string{"-t", "0"}, File: "dmidecode-type0.txt"},
|
||||||
|
{Name: "dmidecode", Args: []string{"-t", "1"}, File: "dmidecode-type1.txt"},
|
||||||
|
{Name: "dmidecode", Args: []string{"-t", "2"}, File: "dmidecode-type2.txt"},
|
||||||
|
{Name: "dmidecode", Args: []string{"-t", "4"}, File: "dmidecode-type4.txt"},
|
||||||
|
{Name: "dmidecode", Args: []string{"-t", "17"}, File: "dmidecode-type17.txt"},
|
||||||
|
{Name: "lspci", Args: []string{"-vmm", "-D"}, File: "lspci-vmm.txt"},
|
||||||
|
{Name: "lsblk", Args: []string{"-J", "-d", "-o", "NAME,TYPE,SIZE,SERIAL,MODEL,TRAN,HCTL"}, File: "lsblk.json"},
|
||||||
|
{Name: "sensors", Args: []string{"-j"}, File: "sensors.json"},
|
||||||
|
{Name: "ipmitool", Args: []string{"fru", "print"}, File: "ipmitool-fru.txt"},
|
||||||
|
{Name: "ipmitool", Args: []string{"sdr"}, File: "ipmitool-sdr.txt"},
|
||||||
|
{Name: "nvidia-smi", Args: []string{"-q"}, File: "nvidia-smi-q.txt"},
|
||||||
|
{Name: "nvidia-smi", Args: []string{"--query-gpu=index,pci.bus_id,serial,vbios_version,temperature.gpu,power.draw,ecc.errors.uncorrected.aggregate.total,ecc.errors.corrected.aggregate.total,clocks_throttle_reasons.hw_slowdown", "--format=csv,noheader,nounits"}, File: "nvidia-smi-query.csv"},
|
||||||
|
{Name: "nvme", Args: []string{"list", "-o", "json"}, File: "nvme-list.json"},
|
||||||
|
}
|
||||||
|
|
||||||
|
type lsblkDumpRoot struct {
|
||||||
|
Blockdevices []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
} `json:"blockdevices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type nvmeDumpRoot struct {
|
||||||
|
Devices []struct {
|
||||||
|
DevicePath string `json:"DevicePath"`
|
||||||
|
} `json:"Devices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *System) CaptureTechnicalDump(baseDir string) error {
|
||||||
|
if err := os.MkdirAll(baseDir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cmd := range techDumpFixedCommands {
|
||||||
|
writeCommandDump(filepath.Join(baseDir, cmd.File), cmd.Name, cmd.Args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dev := range lsblkDumpDevices(filepath.Join(baseDir, "lsblk.json")) {
|
||||||
|
writeCommandDump(filepath.Join(baseDir, "smartctl-"+sanitizeDumpName(dev)+".json"), "smartctl", "-j", "-a", "/dev/"+dev)
|
||||||
|
}
|
||||||
|
for _, dev := range nvmeDumpDevices(filepath.Join(baseDir, "nvme-list.json")) {
|
||||||
|
writeCommandDump(filepath.Join(baseDir, "nvme-id-ctrl-"+sanitizeDumpName(dev)+".json"), "nvme", "id-ctrl", dev, "-o", "json")
|
||||||
|
writeCommandDump(filepath.Join(baseDir, "nvme-smart-log-"+sanitizeDumpName(dev)+".json"), "nvme", "smart-log", dev, "-o", "json")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCommandDump(path, name string, args ...string) {
|
||||||
|
out, err := exec.Command(name, args...).CombinedOutput()
|
||||||
|
if err != nil && len(out) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(path, out, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func lsblkDumpDevices(path string) []string {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var root lsblkDumpRoot
|
||||||
|
if err := json.Unmarshal(raw, &root); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var devices []string
|
||||||
|
for _, dev := range root.Blockdevices {
|
||||||
|
if dev.Type == "disk" && strings.TrimSpace(dev.Name) != "" {
|
||||||
|
devices = append(devices, strings.TrimSpace(dev.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(devices)
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
|
func nvmeDumpDevices(path string) []string {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var root nvmeDumpRoot
|
||||||
|
if err := json.Unmarshal(raw, &root); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var devices []string
|
||||||
|
for _, dev := range root.Devices {
|
||||||
|
name := strings.TrimSpace(dev.DevicePath)
|
||||||
|
if name == "" || seen[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
devices = append(devices, name)
|
||||||
|
}
|
||||||
|
sort.Strings(devices)
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeDumpName(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
value = strings.TrimPrefix(value, "/dev/")
|
||||||
|
value = strings.ReplaceAll(value, "/", "_")
|
||||||
|
if value == "" {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
48
audit/internal/platform/techdump_test.go
Normal file
48
audit/internal/platform/techdump_test.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLSBLKDumpDevices(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "lsblk.json")
|
||||||
|
if err := os.WriteFile(path, []byte(`{"blockdevices":[{"name":"sda","type":"disk"},{"name":"sda1","type":"part"},{"name":"nvme0n1","type":"disk"}]}`), 0644); err != nil {
|
||||||
|
t.Fatalf("write lsblk fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := lsblkDumpDevices(path)
|
||||||
|
want := []string{"nvme0n1", "sda"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("lsblkDumpDevices=%v want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNVMEDumpDevices(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "nvme-list.json")
|
||||||
|
if err := os.WriteFile(path, []byte(`{"Devices":[{"DevicePath":"/dev/nvme1n1"},{"DevicePath":"/dev/nvme0n1"},{"DevicePath":"/dev/nvme1n1"}]}`), 0644); err != nil {
|
||||||
|
t.Fatalf("write nvme fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := nvmeDumpDevices(path)
|
||||||
|
want := []string{"/dev/nvme0n1", "/dev/nvme1n1"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("nvmeDumpDevices=%v want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeDumpName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if got := sanitizeDumpName("/dev/nvme0n1"); got != "nvme0n1" {
|
||||||
|
t.Fatalf("sanitizeDumpName=%q want nvme0n1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,9 +10,47 @@ type HardwareIngestRequest struct {
|
|||||||
Protocol *string `json:"protocol,omitempty"`
|
Protocol *string `json:"protocol,omitempty"`
|
||||||
TargetHost *string `json:"target_host,omitempty"`
|
TargetHost *string `json:"target_host,omitempty"`
|
||||||
CollectedAt string `json:"collected_at"`
|
CollectedAt string `json:"collected_at"`
|
||||||
|
Runtime *RuntimeHealth `json:"runtime,omitempty"`
|
||||||
Hardware HardwareSnapshot `json:"hardware"`
|
Hardware HardwareSnapshot `json:"hardware"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RuntimeHealth struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
CheckedAt string `json:"checked_at"`
|
||||||
|
ExportDir string `json:"export_dir,omitempty"`
|
||||||
|
DriverReady bool `json:"driver_ready,omitempty"`
|
||||||
|
CUDAReady bool `json:"cuda_ready,omitempty"`
|
||||||
|
NetworkStatus string `json:"network_status,omitempty"`
|
||||||
|
Issues []RuntimeIssue `json:"issues,omitempty"`
|
||||||
|
Tools []RuntimeToolStatus `json:"tools,omitempty"`
|
||||||
|
Services []RuntimeServiceStatus `json:"services,omitempty"`
|
||||||
|
Interfaces []RuntimeInterface `json:"interfaces,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuntimeIssue struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Severity string `json:"severity,omitempty"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuntimeToolStatus struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuntimeServiceStatus struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuntimeInterface struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
State string `json:"state,omitempty"`
|
||||||
|
IPv4 []string `json:"ipv4,omitempty"`
|
||||||
|
Outcome string `json:"outcome,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type HardwareSnapshot struct {
|
type HardwareSnapshot struct {
|
||||||
Board HardwareBoard `json:"board"`
|
Board HardwareBoard `json:"board"`
|
||||||
Firmware []HardwareFirmwareRecord `json:"firmware,omitempty"`
|
Firmware []HardwareFirmwareRecord `json:"firmware,omitempty"`
|
||||||
|
|||||||
@@ -78,6 +78,13 @@ func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
result, err := m.app.ExportLatestAuditResult(target)
|
result, err := m.app.ExportLatestAuditResult(target)
|
||||||
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenMain}
|
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenMain}
|
||||||
}
|
}
|
||||||
|
case actionExportBundle:
|
||||||
|
m.busyTitle = "Export support bundle"
|
||||||
|
target := *m.selectedTarget
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
result, err := m.app.ExportSupportBundleResult(target)
|
||||||
|
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenMain}
|
||||||
|
}
|
||||||
case actionRunNvidiaSAT:
|
case actionRunNvidiaSAT:
|
||||||
m.busyTitle = "NVIDIA SAT"
|
m.busyTitle = "NVIDIA SAT"
|
||||||
return m, func() tea.Msg {
|
return m, func() tea.Msg {
|
||||||
@@ -107,6 +114,8 @@ func (m model) confirmCancelTarget() screen {
|
|||||||
switch m.pendingAction {
|
switch m.pendingAction {
|
||||||
case actionExportAudit:
|
case actionExportAudit:
|
||||||
return screenExportTargets
|
return screenExportTargets
|
||||||
|
case actionExportBundle:
|
||||||
|
return screenExportTargets
|
||||||
case actionRunNvidiaSAT:
|
case actionRunNvidiaSAT:
|
||||||
fallthrough
|
fallthrough
|
||||||
case actionRunMemorySAT:
|
case actionRunMemorySAT:
|
||||||
|
|||||||
@@ -4,11 +4,17 @@ import tea "github.com/charmbracelet/bubbletea"
|
|||||||
|
|
||||||
func (m model) handleExportTargetsMenu() (tea.Model, tea.Cmd) {
|
func (m model) handleExportTargetsMenu() (tea.Model, tea.Cmd) {
|
||||||
if len(m.targets) == 0 {
|
if len(m.targets) == 0 {
|
||||||
return m, resultCmd("Export audit", "No removable filesystems found", nil, screenMain)
|
title := "Export audit"
|
||||||
|
if m.pendingAction == actionExportBundle {
|
||||||
|
title = "Export support bundle"
|
||||||
|
}
|
||||||
|
return m, resultCmd(title, "No removable filesystems found", nil, screenMain)
|
||||||
}
|
}
|
||||||
target := m.targets[m.cursor]
|
target := m.targets[m.cursor]
|
||||||
m.selectedTarget = &target
|
m.selectedTarget = &target
|
||||||
m.pendingAction = actionExportAudit
|
if m.pendingAction == actionNone {
|
||||||
|
m.pendingAction = actionExportAudit
|
||||||
|
}
|
||||||
m.screen = screenConfirm
|
m.screen = screenConfirm
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,34 +29,57 @@ func (m model) handleMainMenu() (tea.Model, tea.Cmd) {
|
|||||||
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenMain}
|
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenMain}
|
||||||
}
|
}
|
||||||
case 4:
|
case 4:
|
||||||
|
m.busy = true
|
||||||
|
m.busyTitle = "Run self-check"
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
result, err := m.app.RunRuntimePreflightResult()
|
||||||
|
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenMain}
|
||||||
|
}
|
||||||
|
case 5:
|
||||||
|
m.pendingAction = actionExportAudit
|
||||||
m.busy = true
|
m.busy = true
|
||||||
m.busyTitle = "Export audit"
|
m.busyTitle = "Export audit"
|
||||||
return m, func() tea.Msg {
|
return m, func() tea.Msg {
|
||||||
targets, err := m.app.ListRemovableTargets()
|
targets, err := m.app.ListRemovableTargets()
|
||||||
return exportTargetsMsg{targets: targets, err: err}
|
return exportTargetsMsg{targets: targets, err: err}
|
||||||
}
|
}
|
||||||
case 5:
|
case 6:
|
||||||
|
m.pendingAction = actionExportBundle
|
||||||
|
m.busy = true
|
||||||
|
m.busyTitle = "Export support bundle"
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
targets, err := m.app.ListRemovableTargets()
|
||||||
|
return exportTargetsMsg{targets: targets, err: err}
|
||||||
|
}
|
||||||
|
case 7:
|
||||||
m.busy = true
|
m.busy = true
|
||||||
m.busyTitle = "Required tools"
|
m.busyTitle = "Required tools"
|
||||||
return m, func() tea.Msg {
|
return m, func() tea.Msg {
|
||||||
result := m.app.ToolCheckResult([]string{"dmidecode", "smartctl", "nvme", "ipmitool", "lspci", "ethtool", "bee", "nvidia-smi", "bee-gpu-stress", "memtester", "dhclient", "lsblk", "mount"})
|
result := m.app.ToolCheckResult([]string{"dmidecode", "smartctl", "nvme", "ipmitool", "lspci", "ethtool", "bee", "nvidia-smi", "bee-gpu-stress", "memtester", "dhclient", "lsblk", "mount"})
|
||||||
return resultMsg{title: result.Title, body: result.Body, back: screenMain}
|
return resultMsg{title: result.Title, body: result.Body, back: screenMain}
|
||||||
}
|
}
|
||||||
case 6:
|
case 8:
|
||||||
m.busy = true
|
m.busy = true
|
||||||
m.busyTitle = "Health summary"
|
m.busyTitle = "Health summary"
|
||||||
return m, func() tea.Msg {
|
return m, func() tea.Msg {
|
||||||
result := m.app.HealthSummaryResult()
|
result := m.app.HealthSummaryResult()
|
||||||
return resultMsg{title: result.Title, body: result.Body, back: screenMain}
|
return resultMsg{title: result.Title, body: result.Body, back: screenMain}
|
||||||
}
|
}
|
||||||
case 7:
|
case 9:
|
||||||
|
m.busy = true
|
||||||
|
m.busyTitle = "Runtime issues"
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
result := m.app.RuntimeHealthResult()
|
||||||
|
return resultMsg{title: result.Title, body: result.Body, back: screenMain}
|
||||||
|
}
|
||||||
|
case 10:
|
||||||
m.busy = true
|
m.busy = true
|
||||||
m.busyTitle = "Audit logs"
|
m.busyTitle = "Audit logs"
|
||||||
return m, func() tea.Msg {
|
return m, func() tea.Msg {
|
||||||
result := m.app.AuditLogTailResult()
|
result := m.app.AuditLogTailResult()
|
||||||
return resultMsg{title: result.Title, body: result.Body, back: screenMain}
|
return resultMsg{title: result.Title, body: result.Body, back: screenMain}
|
||||||
}
|
}
|
||||||
case 8:
|
case 11:
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|||||||
@@ -454,13 +454,13 @@ func TestViewOutputScreenRendersBodyAndBackHint(t *testing.T) {
|
|||||||
m := newTestModel()
|
m := newTestModel()
|
||||||
m.screen = screenOutput
|
m.screen = screenOutput
|
||||||
m.title = "Run audit"
|
m.title = "Run audit"
|
||||||
m.body = "audit output: /var/log/bee-audit.json\n"
|
m.body = "audit output: /appdata/bee/export/bee-audit.json\n"
|
||||||
|
|
||||||
view := m.View()
|
view := m.View()
|
||||||
|
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"Run audit",
|
"Run audit",
|
||||||
"audit output: /var/log/bee-audit.json",
|
"audit output: /appdata/bee/export/bee-audit.json",
|
||||||
"[enter/esc] back [ctrl+c] quit",
|
"[enter/esc] back [ctrl+c] quit",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(view, want) {
|
if !strings.Contains(view, want) {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const (
|
|||||||
actionDHCPOne actionKind = "dhcp_one"
|
actionDHCPOne actionKind = "dhcp_one"
|
||||||
actionStaticIPv4 actionKind = "static_ipv4"
|
actionStaticIPv4 actionKind = "static_ipv4"
|
||||||
actionExportAudit actionKind = "export_audit"
|
actionExportAudit actionKind = "export_audit"
|
||||||
|
actionExportBundle actionKind = "export_bundle"
|
||||||
actionRunNvidiaSAT actionKind = "run_nvidia_sat"
|
actionRunNvidiaSAT actionKind = "run_nvidia_sat"
|
||||||
actionRunMemorySAT actionKind = "run_memory_sat"
|
actionRunMemorySAT actionKind = "run_memory_sat"
|
||||||
actionRunStorageSAT actionKind = "run_storage_sat"
|
actionRunStorageSAT actionKind = "run_storage_sat"
|
||||||
@@ -89,9 +90,12 @@ func newModel(application *app.App, runtimeMode runtimeenv.Mode) model {
|
|||||||
"Services",
|
"Services",
|
||||||
"Acceptance tests",
|
"Acceptance tests",
|
||||||
"Run audit",
|
"Run audit",
|
||||||
|
"Run self-check",
|
||||||
"Export audit",
|
"Export audit",
|
||||||
|
"Export support bundle",
|
||||||
"Check tools",
|
"Check tools",
|
||||||
"Show health summary",
|
"Show health summary",
|
||||||
|
"Show runtime issues",
|
||||||
"Show audit logs",
|
"Show audit logs",
|
||||||
"Exit",
|
"Exit",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ func (m model) View() string {
|
|||||||
case screenAcceptance:
|
case screenAcceptance:
|
||||||
return renderMenu("Acceptance tests", "Select action", []string{"Run NVIDIA command pack", "Run memory test", "Run storage diagnostic pack", "Back"}, m.cursor)
|
return renderMenu("Acceptance tests", "Select action", []string{"Run NVIDIA command pack", "Run memory test", "Run storage diagnostic pack", "Back"}, m.cursor)
|
||||||
case screenExportTargets:
|
case screenExportTargets:
|
||||||
return renderMenu("Export audit", "Select removable filesystem", renderTargetItems(m.targets), m.cursor)
|
title := "Export audit"
|
||||||
|
if m.pendingAction == actionExportBundle {
|
||||||
|
title = "Export support bundle"
|
||||||
|
}
|
||||||
|
return renderMenu(title, "Select removable filesystem", renderTargetItems(m.targets), m.cursor)
|
||||||
case screenInterfacePick:
|
case screenInterfacePick:
|
||||||
return renderMenu("Interfaces", "Select interface", renderInterfaceItems(m.interfaces), m.cursor)
|
return renderMenu("Interfaces", "Select interface", renderInterfaceItems(m.interfaces), m.cursor)
|
||||||
case screenStaticForm:
|
case screenStaticForm:
|
||||||
@@ -53,6 +57,11 @@ func (m model) confirmBody() (string, string) {
|
|||||||
return "Export audit", "No target selected"
|
return "Export audit", "No target selected"
|
||||||
}
|
}
|
||||||
return "Export audit", fmt.Sprintf("Copy latest audit JSON to %s?", m.selectedTarget.Device)
|
return "Export audit", fmt.Sprintf("Copy latest audit JSON to %s?", m.selectedTarget.Device)
|
||||||
|
case actionExportBundle:
|
||||||
|
if m.selectedTarget == nil {
|
||||||
|
return "Export support bundle", "No target selected"
|
||||||
|
}
|
||||||
|
return "Export support bundle", fmt.Sprintf("Copy support bundle archive to %s?", m.selectedTarget.Device)
|
||||||
case actionRunNvidiaSAT:
|
case actionRunNvidiaSAT:
|
||||||
return "NVIDIA SAT", "Run NVIDIA acceptance command pack?"
|
return "NVIDIA SAT", "Run NVIDIA acceptance command pack?"
|
||||||
case actionRunMemorySAT:
|
case actionRunMemorySAT:
|
||||||
|
|||||||
@@ -3,10 +3,15 @@ package webui
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"bee/audit/internal/app"
|
||||||
"reanimator/chart/viewer"
|
"reanimator/chart/viewer"
|
||||||
chartweb "reanimator/chart/web"
|
chartweb "reanimator/chart/web"
|
||||||
)
|
)
|
||||||
@@ -16,6 +21,7 @@ const defaultTitle = "Bee Hardware Audit"
|
|||||||
type HandlerOptions struct {
|
type HandlerOptions struct {
|
||||||
Title string
|
Title string
|
||||||
AuditPath string
|
AuditPath string
|
||||||
|
ExportDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(opts HandlerOptions) http.Handler {
|
func NewHandler(opts HandlerOptions) http.Handler {
|
||||||
@@ -25,6 +31,10 @@ func NewHandler(opts HandlerOptions) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
auditPath := strings.TrimSpace(opts.AuditPath)
|
auditPath := strings.TrimSpace(opts.AuditPath)
|
||||||
|
exportDir := strings.TrimSpace(opts.ExportDir)
|
||||||
|
if exportDir == "" {
|
||||||
|
exportDir = app.DefaultExportDir
|
||||||
|
}
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("GET /static/", http.StripPrefix("/static/", chartweb.Static()))
|
mux.Handle("GET /static/", http.StripPrefix("/static/", chartweb.Static()))
|
||||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -46,14 +56,67 @@ func NewHandler(opts HandlerOptions) http.Handler {
|
|||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
_, _ = w.Write(data)
|
_, _ = w.Write(data)
|
||||||
})
|
})
|
||||||
|
mux.HandleFunc("GET /export/support.tar.gz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
archive, err := app.BuildSupportBundle(exportDir)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("build support bundle: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("Content-Type", "application/gzip")
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(archive)))
|
||||||
|
http.ServeFile(w, r, archive)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /runtime-health.json", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, err := loadSnapshot(filepath.Join(exportDir, "runtime-health.json"))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
http.Error(w, "runtime health not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, fmt.Sprintf("read runtime health: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /export/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := renderExportIndex(exportDir)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("render export index: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /export/file", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rel := strings.TrimSpace(r.URL.Query().Get("path"))
|
||||||
|
if rel == "" {
|
||||||
|
http.Error(w, "path is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clean := filepath.Clean(rel)
|
||||||
|
if clean == "." || strings.HasPrefix(clean, "..") {
|
||||||
|
http.Error(w, "invalid path", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeFile(w, r, filepath.Join(exportDir, clean))
|
||||||
|
})
|
||||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||||
snapshot, err := loadSnapshot(auditPath)
|
snapshot, err := loadSnapshot(auditPath)
|
||||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
http.Error(w, fmt.Sprintf("read audit snapshot: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("read audit snapshot: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
noticeTitle, noticeBody := runtimeNotice(filepath.Join(exportDir, "runtime-health.json"))
|
||||||
html, err := viewer.RenderHTML(snapshot, title)
|
html, err := viewer.RenderHTMLWithOptions(snapshot, title, viewer.RenderOptions{
|
||||||
|
DownloadArchiveURL: "/export/support.tar.gz",
|
||||||
|
DownloadArchiveLabel: "Download support bundle",
|
||||||
|
NoticeTitle: noticeTitle,
|
||||||
|
NoticeBody: noticeBody,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("render snapshot: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("render snapshot: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -75,3 +138,67 @@ func loadSnapshot(path string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
return os.ReadFile(path)
|
return os.ReadFile(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runtimeNotice(path string) (string, string) {
|
||||||
|
health, err := app.ReadRuntimeHealth(path)
|
||||||
|
if err != nil {
|
||||||
|
return "Runtime Health", "No runtime health snapshot found yet."
|
||||||
|
}
|
||||||
|
body := fmt.Sprintf("Status: %s. Export dir: %s. Driver ready: %t. CUDA ready: %t. Network: %s. Export files: /export/",
|
||||||
|
firstNonEmpty(health.Status, "UNKNOWN"),
|
||||||
|
firstNonEmpty(health.ExportDir, app.DefaultExportDir),
|
||||||
|
health.DriverReady,
|
||||||
|
health.CUDAReady,
|
||||||
|
firstNonEmpty(health.NetworkStatus, "UNKNOWN"),
|
||||||
|
)
|
||||||
|
if len(health.Issues) > 0 {
|
||||||
|
body += " Issues: "
|
||||||
|
parts := make([]string, 0, len(health.Issues))
|
||||||
|
for _, issue := range health.Issues {
|
||||||
|
parts = append(parts, issue.Code)
|
||||||
|
}
|
||||||
|
body += strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
return "Runtime Health", body
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderExportIndex(exportDir string) (string, error) {
|
||||||
|
var entries []string
|
||||||
|
err := filepath.Walk(strings.TrimSpace(exportDir), func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(exportDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entries = append(entries, rel)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sort.Strings(entries)
|
||||||
|
var body strings.Builder
|
||||||
|
body.WriteString("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Bee Export Files</title></head><body>")
|
||||||
|
body.WriteString("<h1>Bee Export Files</h1><ul>")
|
||||||
|
for _, entry := range entries {
|
||||||
|
body.WriteString("<li><a href=\"/export/file?path=" + url.QueryEscape(entry) + "\">" + html.EscapeString(entry) + "</a></li>")
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
body.WriteString("<li>No export files found.</li>")
|
||||||
|
}
|
||||||
|
body.WriteString("</ul></body></html>")
|
||||||
|
return body.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(value, fallback string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import (
|
|||||||
func TestRootRendersLatestSnapshot(t *testing.T) {
|
func TestRootRendersLatestSnapshot(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "audit.json")
|
path := filepath.Join(dir, "audit.json")
|
||||||
|
exportDir := filepath.Join(dir, "export")
|
||||||
|
if err := os.MkdirAll(exportDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if err := os.WriteFile(path, []byte(`{"collected_at":"2026-03-15T00:00:00Z","hardware":{"board":{"serial_number":"SERIAL-OLD"}}}`), 0644); err != nil {
|
if err := os.WriteFile(path, []byte(`{"collected_at":"2026-03-15T00:00:00Z","hardware":{"board":{"serial_number":"SERIAL-OLD"}}}`), 0644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -19,6 +23,7 @@ func TestRootRendersLatestSnapshot(t *testing.T) {
|
|||||||
handler := NewHandler(HandlerOptions{
|
handler := NewHandler(HandlerOptions{
|
||||||
Title: "Bee Hardware Audit",
|
Title: "Bee Hardware Audit",
|
||||||
AuditPath: path,
|
AuditPath: path,
|
||||||
|
ExportDir: exportDir,
|
||||||
})
|
})
|
||||||
|
|
||||||
first := httptest.NewRecorder()
|
first := httptest.NewRecorder()
|
||||||
@@ -29,6 +34,9 @@ func TestRootRendersLatestSnapshot(t *testing.T) {
|
|||||||
if !strings.Contains(first.Body.String(), "SERIAL-OLD") {
|
if !strings.Contains(first.Body.String(), "SERIAL-OLD") {
|
||||||
t.Fatalf("first body missing old serial: %s", first.Body.String())
|
t.Fatalf("first body missing old serial: %s", first.Body.String())
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(first.Body.String(), "/export/support.tar.gz") {
|
||||||
|
t.Fatalf("first body missing support bundle link: %s", first.Body.String())
|
||||||
|
}
|
||||||
if got := first.Header().Get("Cache-Control"); got != "no-store" {
|
if got := first.Header().Get("Cache-Control"); got != "no-store" {
|
||||||
t.Fatalf("first cache-control=%q", got)
|
t.Fatalf("first cache-control=%q", got)
|
||||||
}
|
}
|
||||||
@@ -80,3 +88,49 @@ func TestMissingAuditJSONReturnsNotFound(t *testing.T) {
|
|||||||
t.Fatalf("status=%d want %d", rec.Code, http.StatusNotFound)
|
t.Fatalf("status=%d want %d", rec.Code, http.StatusNotFound)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSupportBundleEndpointReturnsArchive(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
exportDir := filepath.Join(dir, "export")
|
||||||
|
if err := os.MkdirAll(exportDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(exportDir, "bee-audit.log"), []byte("audit log"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := NewHandler(HandlerOptions{ExportDir: exportDir})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/export/support.tar.gz", nil))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Content-Disposition"); !strings.Contains(got, "attachment;") {
|
||||||
|
t.Fatalf("content-disposition=%q", got)
|
||||||
|
}
|
||||||
|
if rec.Body.Len() == 0 {
|
||||||
|
t.Fatal("empty archive body")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeHealthEndpointReturnsJSON(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
exportDir := filepath.Join(dir, "export")
|
||||||
|
if err := os.MkdirAll(exportDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body := `{"status":"PARTIAL","checked_at":"2026-03-16T10:00:00Z"}`
|
||||||
|
if err := os.WriteFile(filepath.Join(exportDir, "runtime-health.json"), []byte(body), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := NewHandler(HandlerOptions{ExportDir: exportDir})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime-health.json", nil))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(rec.Body.String()) != body {
|
||||||
|
t.Fatalf("body=%q want %q", strings.TrimSpace(rec.Body.String()), body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ echo "=== bee chroot setup ==="
|
|||||||
# Enable bee services
|
# Enable bee services
|
||||||
systemctl enable bee-network.service
|
systemctl enable bee-network.service
|
||||||
systemctl enable bee-nvidia.service
|
systemctl enable bee-nvidia.service
|
||||||
|
systemctl enable bee-preflight.service
|
||||||
systemctl enable bee-audit.service
|
systemctl enable bee-audit.service
|
||||||
systemctl enable bee-web.service
|
systemctl enable bee-web.service
|
||||||
systemctl enable bee-sshsetup.service
|
systemctl enable bee-sshsetup.service
|
||||||
@@ -26,8 +27,8 @@ chmod +x /usr/local/bin/bee 2>/dev/null || true
|
|||||||
# Reload udev rules
|
# Reload udev rules
|
||||||
udevadm control --reload-rules 2>/dev/null || true
|
udevadm control --reload-rules 2>/dev/null || true
|
||||||
|
|
||||||
# Create log directory
|
# Create export directory
|
||||||
mkdir -p /var/log
|
mkdir -p /appdata/bee/export
|
||||||
|
|
||||||
if [ -f /etc/sudoers.d/bee ]; then
|
if [ -f /etc/sudoers.d/bee ]; then
|
||||||
chmod 0440 /etc/sudoers.d/bee
|
chmod 0440 /etc/sudoers.d/bee
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ done
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "-- systemd services --"
|
echo "-- systemd services --"
|
||||||
for svc in bee-nvidia bee-network bee-audit bee-web; do
|
for svc in bee-nvidia bee-network bee-preflight bee-audit bee-web; do
|
||||||
if systemctl is-active --quiet "$svc" 2>/dev/null; then
|
if systemctl is-active --quiet "$svc" 2>/dev/null; then
|
||||||
ok "service active: $svc"
|
ok "service active: $svc"
|
||||||
else
|
else
|
||||||
@@ -104,6 +104,20 @@ for svc in bee-nvidia bee-network bee-audit bee-web; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "-- runtime health --"
|
||||||
|
if [ -f /appdata/bee/export/runtime-health.json ] && [ -s /appdata/bee/export/runtime-health.json ]; then
|
||||||
|
ok "runtime: runtime-health.json present and non-empty"
|
||||||
|
else
|
||||||
|
fail "runtime: runtime-health.json missing or empty"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f /appdata/bee/export/runtime-health.log ]; then
|
||||||
|
info "last runtime log line: $(tail -1 /appdata/bee/export/runtime-health.log)"
|
||||||
|
else
|
||||||
|
warn "runtime: no log found at /appdata/bee/export/runtime-health.log"
|
||||||
|
fi
|
||||||
|
|
||||||
for svc in ssh bee-sshsetup; do
|
for svc in ssh bee-sshsetup; do
|
||||||
if systemctl is-active --quiet "$svc" 2>/dev/null \
|
if systemctl is-active --quiet "$svc" 2>/dev/null \
|
||||||
|| systemctl show "$svc" --property=ActiveState 2>/dev/null | grep -q "inactive\|exited"; then
|
|| systemctl show "$svc" --property=ActiveState 2>/dev/null | grep -q "inactive\|exited"; then
|
||||||
@@ -126,37 +140,37 @@ fi
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "-- audit last run --"
|
echo "-- audit last run --"
|
||||||
if [ -f /var/log/bee-audit.json ] && [ -s /var/log/bee-audit.json ]; then
|
if [ -f /appdata/bee/export/bee-audit.json ] && [ -s /appdata/bee/export/bee-audit.json ]; then
|
||||||
ok "audit: bee-audit.json present and non-empty"
|
ok "audit: bee-audit.json present and non-empty"
|
||||||
info "size: $(du -sh /var/log/bee-audit.json | cut -f1)"
|
info "size: $(du -sh /appdata/bee/export/bee-audit.json | cut -f1)"
|
||||||
else
|
else
|
||||||
fail "audit: bee-audit.json missing or empty"
|
fail "audit: bee-audit.json missing or empty"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -f /var/log/bee-audit.log ]; then
|
if [ -f /appdata/bee/export/bee-audit.log ]; then
|
||||||
last_line=$(tail -1 /var/log/bee-audit.log)
|
last_line=$(tail -1 /appdata/bee/export/bee-audit.log)
|
||||||
info "last log line: $last_line"
|
info "last log line: $last_line"
|
||||||
if grep -q "audit output written" /var/log/bee-audit.log 2>/dev/null; then
|
if grep -q "audit output written" /appdata/bee/export/bee-audit.log 2>/dev/null; then
|
||||||
ok "audit: completed successfully"
|
ok "audit: completed successfully"
|
||||||
else
|
else
|
||||||
warn "audit: 'audit output written' not found in log — may have failed"
|
warn "audit: 'audit output written' not found in log — may have failed"
|
||||||
fi
|
fi
|
||||||
if grep -q "nvidia: enrichment skipped\|nvidia.*skipped\|enrichment skipped" /var/log/bee-audit.log 2>/dev/null; then
|
if grep -q "nvidia: enrichment skipped\|nvidia.*skipped\|enrichment skipped" /appdata/bee/export/bee-audit.log 2>/dev/null; then
|
||||||
reason=$(grep -E "nvidia.*skipped|enrichment skipped" /var/log/bee-audit.log | tail -1)
|
reason=$(grep -E "nvidia.*skipped|enrichment skipped" /appdata/bee/export/bee-audit.log | tail -1)
|
||||||
fail "audit: nvidia enrichment skipped — $reason"
|
fail "audit: nvidia enrichment skipped — $reason"
|
||||||
else
|
else
|
||||||
ok "audit: nvidia enrichment OK (no skip message)"
|
ok "audit: nvidia enrichment OK (no skip message)"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
warn "audit: no log found at /var/log/bee-audit.log"
|
warn "audit: no log found at /appdata/bee/export/bee-audit.log"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "-- bee web --"
|
echo "-- bee web --"
|
||||||
if [ -f /var/log/bee-web.log ]; then
|
if [ -f /appdata/bee/export/bee-web.log ]; then
|
||||||
info "last web log line: $(tail -1 /var/log/bee-web.log)"
|
info "last web log line: $(tail -1 /appdata/bee/export/bee-web.log)"
|
||||||
else
|
else
|
||||||
warn "web: no log found at /var/log/bee-web.log"
|
warn "web: no log found at /appdata/bee/export/bee-web.log"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if bash -c 'exec 3<>/dev/tcp/127.0.0.1/80 && printf "GET /healthz HTTP/1.0\r\nHost: localhost\r\n\r\n" >&3 && grep -q "^ok$" <&3'; then
|
if bash -c 'exec 3<>/dev/tcp/127.0.0.1/80 && printf "GET /healthz HTTP/1.0\r\nHost: localhost\r\n\r\n" >&3 && grep -q "^ok$" <&3'; then
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
Hardware Audit LiveCD
|
Hardware Audit LiveCD
|
||||||
Build: %%BUILD_INFO%%
|
Build: %%BUILD_INFO%%
|
||||||
|
|
||||||
Logs: /var/log/bee-audit.json /var/log/bee-network.log
|
Export dir: /appdata/bee/export
|
||||||
|
Self-check: /appdata/bee/export/runtime-health.json
|
||||||
|
|
||||||
Open TUI: bee-tui
|
Open TUI: bee-tui
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Bee: run hardware audit
|
Description=Bee: run hardware audit
|
||||||
After=bee-network.service bee-nvidia.service
|
After=bee-network.service bee-nvidia.service bee-preflight.service
|
||||||
Before=bee-web.service
|
Before=bee-web.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
ExecStart=/bin/sh -c '/usr/local/bin/bee audit --runtime livecd --output file:/var/log/bee-audit.json; rc=$?; if [ "$rc" -ne 0 ]; then echo "[bee-audit] WARN: audit exited with rc=$rc"; fi; exit 0'
|
ExecStart=/bin/sh -c '/usr/local/bin/bee audit --runtime livecd --output file:/appdata/bee/export/bee-audit.json; rc=$?; if [ "$rc" -ne 0 ]; then echo "[bee-audit] WARN: audit exited with rc=$rc"; fi; exit 0'
|
||||||
StandardOutput=append:/var/log/bee-audit.log
|
StandardOutput=append:/appdata/bee/export/bee-audit.log
|
||||||
StandardError=append:/var/log/bee-audit.log
|
StandardError=append:/appdata/bee/export/bee-audit.log
|
||||||
RemainAfterExit=yes
|
RemainAfterExit=yes
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ Before=network-online.target bee-audit.service
|
|||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
ExecStart=/usr/local/bin/bee-network.sh
|
ExecStart=/usr/local/bin/bee-network.sh
|
||||||
StandardOutput=append:/var/log/bee-network.log
|
StandardOutput=append:/appdata/bee/export/bee-network.log
|
||||||
StandardError=append:/var/log/bee-network.log
|
StandardError=append:/appdata/bee/export/bee-network.log
|
||||||
RemainAfterExit=yes
|
RemainAfterExit=yes
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ Before=bee-audit.service
|
|||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
ExecStart=/usr/local/bin/bee-nvidia-load
|
ExecStart=/usr/local/bin/bee-nvidia-load
|
||||||
StandardOutput=journal
|
StandardOutput=append:/appdata/bee/export/bee-nvidia.log
|
||||||
StandardError=journal
|
StandardError=append:/appdata/bee/export/bee-nvidia.log
|
||||||
RemainAfterExit=yes
|
RemainAfterExit=yes
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
14
iso/overlay/etc/systemd/system/bee-preflight.service
Normal file
14
iso/overlay/etc/systemd/system/bee-preflight.service
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Bee: runtime preflight self-check
|
||||||
|
After=bee-network.service bee-nvidia.service
|
||||||
|
Before=bee-audit.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/bin/sh -c '/usr/local/bin/bee preflight --output file:/appdata/bee/export/runtime-health.json; rc=$?; if [ "$rc" -ne 0 ]; then echo "[bee-preflight] WARN: preflight exited with rc=$rc"; fi; exit 0'
|
||||||
|
StandardOutput=append:/appdata/bee/export/runtime-health.log
|
||||||
|
StandardError=append:/appdata/bee/export/runtime-health.log
|
||||||
|
RemainAfterExit=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -6,6 +6,8 @@ Before=ssh.service
|
|||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
ExecStart=/usr/local/bin/bee-sshsetup
|
ExecStart=/usr/local/bin/bee-sshsetup
|
||||||
|
StandardOutput=append:/appdata/bee/export/bee-sshsetup.log
|
||||||
|
StandardError=append:/appdata/bee/export/bee-sshsetup.log
|
||||||
RemainAfterExit=yes
|
RemainAfterExit=yes
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ Wants=bee-audit.service
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
ExecStart=/usr/local/bin/bee web --listen :80 --audit-path /var/log/bee-audit.json --title "Bee Hardware Audit"
|
ExecStart=/usr/local/bin/bee web --listen :80 --audit-path /appdata/bee/export/bee-audit.json --export-dir /appdata/bee/export --title "Bee Hardware Audit"
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
StandardOutput=append:/var/log/bee-web.log
|
StandardOutput=append:/appdata/bee/export/bee-web.log
|
||||||
StandardError=append:/var/log/bee-web.log
|
StandardError=append:/appdata/bee/export/bee-web.log
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ fi
|
|||||||
|
|
||||||
for iface in $interfaces; do
|
for iface in $interfaces; do
|
||||||
log "bringing up $iface"
|
log "bringing up $iface"
|
||||||
ip link set "$iface" up 2>/dev/null || { log "WARN: could not bring up $iface"; continue; }
|
ip link set "$iface" up || { log "WARN: could not bring up $iface"; continue; }
|
||||||
|
|
||||||
# DHCP in background — non-blocking, retries indefinitely
|
# DHCP in background — non-blocking, keep dhclient verbose output in the service log.
|
||||||
dhclient -nw "$iface" 2>/dev/null &
|
dhclient -4 -v -nw "$iface" &
|
||||||
log "DHCP started for $iface (pid $!)"
|
log "DHCP started for $iface (pid $!)"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,15 @@ fi
|
|||||||
log "module dir: $NVIDIA_KO_DIR"
|
log "module dir: $NVIDIA_KO_DIR"
|
||||||
ls "$NVIDIA_KO_DIR"/*.ko 2>/dev/null | sed 's/^/ /' || true
|
ls "$NVIDIA_KO_DIR"/*.ko 2>/dev/null | sed 's/^/ /' || true
|
||||||
|
|
||||||
|
# Some kernels expose backlight helper symbols only after loading `video`.
|
||||||
|
modprobe video >/dev/null 2>&1 && log "loaded helper module: video" || log "helper module unavailable: video"
|
||||||
|
|
||||||
# Load modules via insmod (direct load — no depmod needed)
|
# Load modules via insmod (direct load — no depmod needed)
|
||||||
for mod in nvidia nvidia-modeset nvidia-uvm; do
|
for mod in nvidia nvidia-modeset nvidia-uvm; do
|
||||||
ko="$NVIDIA_KO_DIR/${mod}.ko"
|
ko="$NVIDIA_KO_DIR/${mod}.ko"
|
||||||
[ -f "$ko" ] || ko="$NVIDIA_KO_DIR/${mod//-/_}.ko"
|
[ -f "$ko" ] || ko="$NVIDIA_KO_DIR/${mod//-/_}.ko"
|
||||||
if [ -f "$ko" ]; then
|
if [ -f "$ko" ]; then
|
||||||
if insmod "$ko" 2>/dev/null; then
|
if insmod "$ko"; then
|
||||||
log "loaded: $mod"
|
log "loaded: $mod"
|
||||||
else
|
else
|
||||||
log "WARN: failed to load: $mod"
|
log "WARN: failed to load: $mod"
|
||||||
@@ -33,25 +36,25 @@ for mod in nvidia nvidia-modeset nvidia-uvm; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
# Create /dev/nvidia* device nodes (udev rules absent since we use .run installer)
|
# Create /dev/nvidia* device nodes (udev rules absent since we use .run installer)
|
||||||
nvidia_major=$(grep -m1 ' nvidiactl$' /proc/devices 2>/dev/null | awk '{print $1}')
|
nvidia_major=$(grep -m1 ' nvidiactl$' /proc/devices | awk '{print $1}')
|
||||||
if [ -n "$nvidia_major" ]; then
|
if [ -n "$nvidia_major" ]; then
|
||||||
mknod -m 666 /dev/nvidiactl c "$nvidia_major" 255 2>/dev/null \
|
mknod -m 666 /dev/nvidiactl c "$nvidia_major" 255 \
|
||||||
&& log "created /dev/nvidiactl (major $nvidia_major)" \
|
&& log "created /dev/nvidiactl (major $nvidia_major)" \
|
||||||
|| log "WARN: /dev/nvidiactl already exists or mknod failed"
|
|| log "WARN: /dev/nvidiactl already exists or mknod failed"
|
||||||
for i in 0 1 2 3 4 5 6 7; do
|
for i in 0 1 2 3 4 5 6 7; do
|
||||||
mknod -m 666 "/dev/nvidia$i" c "$nvidia_major" "$i" 2>/dev/null || true
|
mknod -m 666 "/dev/nvidia$i" c "$nvidia_major" "$i" || true
|
||||||
done
|
done
|
||||||
log "created /dev/nvidia{0-7}"
|
log "created /dev/nvidia{0-7}"
|
||||||
else
|
else
|
||||||
log "WARN: nvidiactl not in /proc/devices — no GPU hardware present?"
|
log "WARN: nvidiactl not in /proc/devices — no GPU hardware present?"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
uvm_major=$(grep -m1 ' nvidia-uvm$' /proc/devices 2>/dev/null | awk '{print $1}')
|
uvm_major=$(grep -m1 ' nvidia-uvm$' /proc/devices | awk '{print $1}')
|
||||||
if [ -n "$uvm_major" ]; then
|
if [ -n "$uvm_major" ]; then
|
||||||
mknod -m 666 /dev/nvidia-uvm c "$uvm_major" 0 2>/dev/null \
|
mknod -m 666 /dev/nvidia-uvm c "$uvm_major" 0 \
|
||||||
&& log "created /dev/nvidia-uvm (major $uvm_major)" \
|
&& log "created /dev/nvidia-uvm (major $uvm_major)" \
|
||||||
|| log "WARN: /dev/nvidia-uvm already exists"
|
|| log "WARN: /dev/nvidia-uvm already exists"
|
||||||
mknod -m 666 /dev/nvidia-uvm-tools c "$uvm_major" 1 2>/dev/null || true
|
mknod -m 666 /dev/nvidia-uvm-tools c "$uvm_major" 1 || true
|
||||||
else
|
else
|
||||||
log "WARN: nvidia-uvm not in /proc/devices"
|
log "WARN: nvidia-uvm not in /proc/devices"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user