diff --git a/.gitmodules b/.gitmodules index fa8f9e7..5d1df85 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "bible"] path = bible url = https://git.mchus.pro/mchus/bible.git +[submodule "internal/chart"] + path = internal/chart + url = https://git.mchus.pro/reanimator/chart.git diff --git a/audit/cmd/bee/main.go b/audit/cmd/bee/main.go index 51c47ab..474e524 100644 --- a/audit/cmd/bee/main.go +++ b/audit/cmd/bee/main.go @@ -12,6 +12,7 @@ import ( "bee/audit/internal/platform" "bee/audit/internal/runtimeenv" "bee/audit/internal/tui" + "bee/audit/internal/webui" ) var Version = "dev" @@ -43,6 +44,8 @@ func run(args []string, stdout, stderr io.Writer) int { return runTUI(args[1:], stdout, stderr) case "export": return runExport(args[1:], stdout, stderr) + case "web": + return runWeb(args[1:], stdout, stderr) case "sat": return runSAT(args[1:], stdout, stderr) case "version", "--version", "-version": @@ -60,6 +63,7 @@ func printRootUsage(w io.Writer) { bee audit --runtime auto|local|livecd --output stdout|file: bee tui --runtime auto|local|livecd bee export --target + bee web --listen :80 --audit-path /var/log/bee-audit.json bee sat nvidia|memory|storage bee version bee help [command]`) @@ -73,6 +77,8 @@ func runHelp(args []string, stdout, stderr io.Writer) int { return runTUI([]string{"--help"}, stdout, stdout) case "export": return runExport([]string{"--help"}, stdout, stdout) + case "web": + return runWeb([]string{"--help"}, stdout, stdout) case "sat": return runSAT([]string{"--help"}, stdout, stderr) case "version": @@ -213,6 +219,38 @@ func runExport(args []string, stdout, stderr io.Writer) int { return 1 } +func runWeb(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("web", flag.ContinueOnError) + fs.SetOutput(stderr) + listenAddr := fs.String("listen", ":8080", "listen address, e.g. :80") + auditPath := fs.String("audit-path", app.DefaultAuditJSONPath, "path to the latest audit JSON snapshot") + title := fs.String("title", "Bee Hardware Audit", "page title") + fs.Usage = func() { + fmt.Fprintln(stderr, "usage: bee web [--listen :80] [--audit-path /var/log/bee-audit.json] [--title \"Bee Hardware Audit\"]") + 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 + } + + slog.Info("starting bee web", "listen", *listenAddr, "audit_path", *auditPath) + if err := webui.ListenAndServe(*listenAddr, webui.HandlerOptions{ + Title: *title, + AuditPath: *auditPath, + }); err != nil { + slog.Error("run web", "err", err) + return 1 + } + return 0 +} + func runSAT(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { fmt.Fprintln(stderr, "usage: bee sat nvidia|memory|storage") diff --git a/audit/go.mod b/audit/go.mod index b664001..a5cc2ff 100644 --- a/audit/go.mod +++ b/audit/go.mod @@ -1,8 +1,11 @@ module bee/audit -go 1.23 +go 1.24.0 + +replace reanimator/chart => ../internal/chart require github.com/charmbracelet/bubbletea v1.3.4 +require reanimator/chart v0.0.0 require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect diff --git a/audit/internal/webui/server.go b/audit/internal/webui/server.go new file mode 100644 index 0000000..1abd648 --- /dev/null +++ b/audit/internal/webui/server.go @@ -0,0 +1,77 @@ +package webui + +import ( + "errors" + "fmt" + "net/http" + "os" + "strings" + + "reanimator/chart/viewer" + chartweb "reanimator/chart/web" +) + +const defaultTitle = "Bee Hardware Audit" + +type HandlerOptions struct { + Title string + AuditPath string +} + +func NewHandler(opts HandlerOptions) http.Handler { + title := strings.TrimSpace(opts.Title) + if title == "" { + title = defaultTitle + } + + auditPath := strings.TrimSpace(opts.AuditPath) + mux := http.NewServeMux() + mux.Handle("GET /static/", http.StripPrefix("/static/", chartweb.Static())) + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + mux.HandleFunc("GET /audit.json", func(w http.ResponseWriter, r *http.Request) { + data, err := loadSnapshot(auditPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + http.Error(w, "audit snapshot not found", http.StatusNotFound) + return + } + http.Error(w, fmt.Sprintf("read audit snapshot: %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 /", func(w http.ResponseWriter, r *http.Request) { + snapshot, err := loadSnapshot(auditPath) + if err != nil && !errors.Is(err, os.ErrNotExist) { + http.Error(w, fmt.Sprintf("read audit snapshot: %v", err), http.StatusInternalServerError) + return + } + + html, err := viewer.RenderHTML(snapshot, title) + if err != nil { + http.Error(w, fmt.Sprintf("render snapshot: %v", err), http.StatusInternalServerError) + return + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(html) + }) + return mux +} + +func ListenAndServe(addr string, opts HandlerOptions) error { + return http.ListenAndServe(addr, NewHandler(opts)) +} + +func loadSnapshot(path string) ([]byte, error) { + if strings.TrimSpace(path) == "" { + return nil, os.ErrNotExist + } + return os.ReadFile(path) +} diff --git a/audit/internal/webui/server_test.go b/audit/internal/webui/server_test.go new file mode 100644 index 0000000..2848ef2 --- /dev/null +++ b/audit/internal/webui/server_test.go @@ -0,0 +1,82 @@ +package webui + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRootRendersLatestSnapshot(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.json") + 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) + } + + handler := NewHandler(HandlerOptions{ + Title: "Bee Hardware Audit", + AuditPath: path, + }) + + first := httptest.NewRecorder() + handler.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/", nil)) + if first.Code != http.StatusOK { + t.Fatalf("first status=%d", first.Code) + } + if !strings.Contains(first.Body.String(), "SERIAL-OLD") { + t.Fatalf("first body missing old serial: %s", first.Body.String()) + } + if got := first.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("first cache-control=%q", got) + } + + if err := os.WriteFile(path, []byte(`{"collected_at":"2026-03-15T00:05:00Z","hardware":{"board":{"serial_number":"SERIAL-NEW"}}}`), 0644); err != nil { + t.Fatal(err) + } + + second := httptest.NewRecorder() + handler.ServeHTTP(second, httptest.NewRequest(http.MethodGet, "/", nil)) + if second.Code != http.StatusOK { + t.Fatalf("second status=%d", second.Code) + } + if !strings.Contains(second.Body.String(), "SERIAL-NEW") { + t.Fatalf("second body missing new serial: %s", second.Body.String()) + } + if strings.Contains(second.Body.String(), "SERIAL-OLD") { + t.Fatalf("second body still contains old serial: %s", second.Body.String()) + } +} + +func TestAuditJSONServesLatestSnapshot(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.json") + body := `{"hardware":{"board":{"serial_number":"SERIAL-API"}}}` + if err := os.WriteFile(path, []byte(body), 0644); err != nil { + t.Fatal(err) + } + + handler := NewHandler(HandlerOptions{AuditPath: path}) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/audit.json", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d", rec.Code) + } + if got := strings.TrimSpace(rec.Body.String()); got != body { + t.Fatalf("body=%q want %q", got, body) + } + if got := rec.Header().Get("Content-Type"); !strings.Contains(got, "application/json") { + t.Fatalf("content-type=%q", got) + } +} + +func TestMissingAuditJSONReturnsNotFound(t *testing.T) { + handler := NewHandler(HandlerOptions{AuditPath: "/missing/audit.json"}) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/audit.json", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("status=%d want %d", rec.Code, http.StatusNotFound) + } +} diff --git a/bible-local/architecture/runtime-flows.md b/bible-local/architecture/runtime-flows.md index b632511..c7a693f 100644 --- a/bible-local/architecture/runtime-flows.md +++ b/bible-local/architecture/runtime-flows.md @@ -18,8 +18,10 @@ local-fs.target ├── bee-network.service (starts `dhclient -nw` on all physical interfaces, non-blocking) ├── bee-nvidia.service (insmod nvidia*.ko from /usr/local/lib/nvidia/, │ creates /dev/nvidia* nodes) - └── bee-audit.service (runs `bee audit` → /var/log/bee-audit.json, - never blocks boot on partial collector failures) + ├── bee-audit.service (runs `bee audit` → /var/log/bee-audit.json, + │ never blocks boot on partial collector failures) + └── bee-web.service (runs `bee web` on :80, + reads the latest audit snapshot on each request) ``` **Critical invariants:** @@ -29,6 +31,7 @@ local-fs.target Reason: the modules are shipped in the ISO overlay under `/usr/local/lib/nvidia/`, not in the host module tree. - `bee-audit.service` does not wait for `network-online.target`; audit is local and must run even if DHCP is broken. - `bee-audit.service` logs audit failures but does not turn partial collector problems into a boot blocker. +- `bee-web.service` binds `0.0.0.0:80` and always renders the current `/var/log/bee-audit.json` contents. - Audit JSON now includes a `hardware.summary` block with overall verdict and warning/failure counts. ## Console and login flow diff --git a/bible-local/architecture/system-overview.md b/bible-local/architecture/system-overview.md index d4a21d3..960db4a 100644 --- a/bible-local/architecture/system-overview.md +++ b/bible-local/architecture/system-overview.md @@ -26,6 +26,7 @@ Fills gaps where Redfish/logpile is blind: - NVIDIA proprietary driver loaded at boot for GPU enrichment via `nvidia-smi` - SSH access (OpenSSH) always available for inspection and debugging - Interactive Go TUI via `bee tui` for network setup, service management, and acceptance tests +- Read-only web viewer via `bee web`, rendering the latest audit snapshot through the embedded Reanimator Chart - Local `tty1` operator UX: `bee` autologin, `menu` auto-start, privileged actions via `sudo -n` ## Network isolation — CRITICAL @@ -95,6 +96,7 @@ Fills gaps where Redfish/logpile is blind: | `iso/builder/` | ISO build scripts and `live-build` profile | | `iso/overlay/` | Source overlay copied into a staged build overlay | | `iso/vendor/` | Optional pre-built vendor binaries (storcli64, sas2ircu, sas3ircu, arcconf, ssacli, …) | +| `internal/chart/` | Git submodule with `reanimator/chart`, embedded into `bee web` | | `iso/builder/VERSIONS` | Pinned versions: Debian, Go, NVIDIA driver, kernel ABI | | `iso/builder/smoketest.sh` | Post-boot smoke test — run via SSH to verify live ISO | | `iso/overlay/etc/profile.d/bee.sh` | `menu` helper + tty1 auto-start policy | diff --git a/internal/chart b/internal/chart new file mode 160000 index 0000000..05db699 --- /dev/null +++ b/internal/chart @@ -0,0 +1 @@ +Subproject commit 05db6994d4a77bc95cc9d96892f81875f2f9fa01 diff --git a/iso/builder/Dockerfile b/iso/builder/Dockerfile index c9f8dea..48a16ae 100644 --- a/iso/builder/Dockerfile +++ b/iso/builder/Dockerfile @@ -1,6 +1,6 @@ FROM debian:12 -ARG GO_VERSION=1.23.6 +ARG GO_VERSION=1.24.0 ARG DEBIAN_KERNEL_ABI=6.1.0-43 ENV DEBIAN_FRONTEND=noninteractive diff --git a/iso/builder/VERSIONS b/iso/builder/VERSIONS index 7d57b86..a26825a 100644 --- a/iso/builder/VERSIONS +++ b/iso/builder/VERSIONS @@ -1,5 +1,5 @@ DEBIAN_VERSION=12 DEBIAN_KERNEL_ABI=6.1.0-43 NVIDIA_DRIVER_VERSION=590.48.01 -GO_VERSION=1.23.6 +GO_VERSION=1.24.0 AUDIT_VERSION=0.1.1 diff --git a/iso/builder/build.sh b/iso/builder/build.sh index f0a7189..b878fbb 100755 --- a/iso/builder/build.sh +++ b/iso/builder/build.sh @@ -39,6 +39,9 @@ echo "=== bee ISO build ===" echo "Debian: ${DEBIAN_VERSION}, Kernel ABI: ${DEBIAN_KERNEL_ABI}, Go: ${GO_VERSION}" echo "" +echo "=== syncing git submodules ===" +git -C "${REPO_ROOT}" submodule update --init --recursive + # --- compile bee binary (static, Linux amd64) --- BEE_BIN="${DIST_DIR}/bee-linux-amd64" GPU_STRESS_BIN="${DIST_DIR}/bee-gpu-stress-linux-amd64" diff --git a/iso/builder/config/hooks/normal/9000-bee-setup.hook.chroot b/iso/builder/config/hooks/normal/9000-bee-setup.hook.chroot index bb70f36..a260186 100755 --- a/iso/builder/config/hooks/normal/9000-bee-setup.hook.chroot +++ b/iso/builder/config/hooks/normal/9000-bee-setup.hook.chroot @@ -9,6 +9,7 @@ echo "=== bee chroot setup ===" systemctl enable bee-network.service systemctl enable bee-nvidia.service systemctl enable bee-audit.service +systemctl enable bee-web.service systemctl enable bee-sshsetup.service systemctl enable ssh.service systemctl enable qemu-guest-agent.service 2>/dev/null || true diff --git a/iso/builder/setup-builder.sh b/iso/builder/setup-builder.sh index 14680ba..9938273 100644 --- a/iso/builder/setup-builder.sh +++ b/iso/builder/setup-builder.sh @@ -12,7 +12,7 @@ set -e . "$(dirname "$0")/VERSIONS" 2>/dev/null || true -GO_VERSION="${GO_VERSION:-1.23.6}" +GO_VERSION="${GO_VERSION:-1.24.0}" DEBIAN_VERSION="${DEBIAN_VERSION:-12}" DEBIAN_KERNEL_ABI="${DEBIAN_KERNEL_ABI:-6.1.0-28}" diff --git a/iso/builder/smoketest.sh b/iso/builder/smoketest.sh index 23eb179..652fb61 100644 --- a/iso/builder/smoketest.sh +++ b/iso/builder/smoketest.sh @@ -96,7 +96,7 @@ done echo "" echo "-- systemd services --" -for svc in bee-nvidia bee-network bee-audit; do +for svc in bee-nvidia bee-network bee-audit bee-web; do if systemctl is-active --quiet "$svc" 2>/dev/null; then ok "service active: $svc" else @@ -151,6 +151,20 @@ else warn "audit: no log found at /var/log/bee-audit.log" fi +echo "" +echo "-- bee web --" +if [ -f /var/log/bee-web.log ]; then + info "last web log line: $(tail -1 /var/log/bee-web.log)" +else + warn "web: no log found at /var/log/bee-web.log" +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 + ok "web: health endpoint reachable on 127.0.0.1:80" +else + fail "web: health endpoint not reachable on 127.0.0.1:80" +fi + echo "" echo "-- network --" if ip route show default 2>/dev/null | grep -q "default"; then diff --git a/iso/overlay/etc/systemd/system/bee-audit.service b/iso/overlay/etc/systemd/system/bee-audit.service index c63b63a..f7bbea6 100644 --- a/iso/overlay/etc/systemd/system/bee-audit.service +++ b/iso/overlay/etc/systemd/system/bee-audit.service @@ -1,6 +1,7 @@ [Unit] Description=Bee: run hardware audit After=bee-network.service bee-nvidia.service +Before=bee-web.service [Service] Type=oneshot diff --git a/iso/overlay/etc/systemd/system/bee-web.service b/iso/overlay/etc/systemd/system/bee-web.service new file mode 100644 index 0000000..3764a27 --- /dev/null +++ b/iso/overlay/etc/systemd/system/bee-web.service @@ -0,0 +1,15 @@ +[Unit] +Description=Bee: hardware audit web viewer +After=bee-network.service bee-audit.service +Wants=bee-audit.service + +[Service] +Type=simple +ExecStart=/usr/local/bin/bee web --listen :80 --audit-path /var/log/bee-audit.json --title "Bee Hardware Audit" +Restart=always +RestartSec=2 +StandardOutput=append:/var/log/bee-web.log +StandardError=append:/var/log/bee-web.log + +[Install] +WantedBy=multi-user.target