fix(webui): repair broken scenario Run button onclick, dedupe build.sh overlay staging

- page_scenario.go: onclick built via JSON.stringify() embedded raw double
  quotes inside a double-quoted HTML attribute, truncating the attribute so
  the click handler never compiled; pass the name through an escaped
  data-scenario-name attribute instead.
- build.sh: overlay staging rsyncs (OVERLAY_DIR->stage, stage->includes.chroot)
  ran without --delete, so a scenario removed from the repo (a9924b0) stayed
  baked into every ISO built from the persistent stage cache since — the
  "second script" in the Scenario page's list.
- blackbox: rewritten around a deterministic local zip + incremental
  patch-the-changed-suffix onto removable media, instead of walking/copying
  ~90 files through a synchronous ntfs-3g FUSE mount every cycle. journalctl
  captures are now "--since last sync" (were "--since boot", growing with
  uptime) and metrics.db is excluded (was copied whole every cycle).
- scenario: nvbandwidth-acs-ab now escalates GPU count (same-socket pair,
  other socket's pair, one cross-socket pair, all GPUs) under each ACS state
  instead of always running all 6 GPUs at once, using a new `bee
  gpu-bandwidth-groups` subcommand that discovers socket layout from
  `nvidia-smi topo -m` at runtime — gpu_indices is host-specific, so this
  can't be baked into the scenario file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 18:11:24 +03:00
co-authored by Claude Sonnet 5
parent 1045fa9118
commit 8a91f0f783
12 changed files with 847 additions and 32 deletions
+63 -9
View File
@@ -572,6 +572,13 @@ func adjustFlushPeriod(current, duration time.Duration, success bool, fastCycles
return next
}
// syncCycle stages the current export tree on fast local storage (never
// touching the removable-media mountpoint for the expensive part), packs it
// into a single deterministic zip, and patches only the changed suffix of
// that zip onto the target device — see blackbox_archive.go for why: walking
// and rewriting ~90 small files through a synchronous FUSE mount (ntfs-3g
// -o sync) was taking ~2x the flush period, dominated by journalctl output
// that grows with uptime and got fully re-read/re-written every cycle.
func (w *blackboxWorker) syncCycle() error {
target, marker := w.snapshotTarget()
mountpoint, mountedByBee, err := ensureMountedTarget(target, marker.EnrollmentID)
@@ -580,24 +587,59 @@ func (w *blackboxWorker) syncCycle() error {
}
w.recordMountpoint(mountpoint, mountedByBee)
root := filepath.Join(mountpoint, w.runtime.bootFolder)
if err := os.MkdirAll(root, 0755); err != nil {
stageRoot := filepath.Join(w.runtime.exportDir, ".blackbox-stage", w.enrollmentID)
if err := os.RemoveAll(stageRoot); err != nil {
return err
}
if err := categorizeExportTree(w.runtime.exportDir, root); err != nil {
// includeMetricsDB=false: metrics.db is a live, growing SQLite file: past
// versions copied it whole every cycle, which alone could dominate a
// cycle's cost as it grew. Not carried onto the blackbox mirror.
if err := categorizeExportTree(w.runtime.exportDir, stageRoot, false); err != nil {
return err
}
if err := w.captureSnapshots(stageRoot, w.lastCaptureSince()); err != nil {
return err
}
// Same doc pair the support bundle ships at its root — a blackbox
// capture on removable media has no manifest.txt/support-bundle
// equivalent to explain its layout, so without this an agent handed
// only the media would have nothing pointing it at README.md.
if err := writeBundleDocs(root); err != nil {
if err := writeBundleDocs(stageRoot); err != nil {
return err
}
if err := w.captureSnapshots(root); err != nil {
cacheDir := filepath.Join(w.runtime.exportDir, ".blackbox-cache")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return err
}
return syncFilesystem(root)
cachedPath := filepath.Join(cacheDir, w.enrollmentID+".zip")
newZipPath := filepath.Join(cacheDir, w.enrollmentID+".zip.new")
if err := buildZipArchive(stageRoot, newZipPath); err != nil {
return err
}
targetPath := filepath.Join(mountpoint, w.runtime.bootFolder+" blackbox.zip")
if err := patchArchiveOnTarget(targetPath, newZipPath, cachedPath); err != nil {
return err
}
if err := os.Rename(newZipPath, cachedPath); err != nil {
return err
}
return nil
}
// lastCaptureSince is the "--since" boundary for this cycle's incremental
// journalctl captures: the previous successful sync, or boot time on the
// very first cycle. Using the last sync instead of always "--since boot"
// keeps each cycle's journalctl output bounded by the flush period instead
// of growing with total uptime.
func (w *blackboxWorker) lastCaptureSince() time.Time {
w.mu.Lock()
defer w.mu.Unlock()
if w.lastSyncAt.IsZero() {
return w.runtime.bootStarted
}
return w.lastSyncAt
}
func (w *blackboxWorker) cleanup() {
@@ -623,13 +665,25 @@ func (w *blackboxWorker) recordMountpoint(mountpoint string, mountedByBee bool)
w.mountedByBee = mountedByBee
}
func (w *blackboxWorker) captureSnapshots(root string) error {
if err := captureCommandAtomic(filepath.Join(root, "tasks", "_services", "combined.journal.log"), "journalctl", "--no-pager", "--since", w.runtime.bootStarted.Format(time.RFC3339)); err != nil {
// captureSnapshots writes this cycle's journalctl/dmesg/status snapshots
// into root (a local staging tree — see syncCycle). journalctl output is
// captured incrementally ("--since" the last successful cycle, not boot) and
// appended as a new timestamped entry under journal-increments/ each cycle,
// rather than overwriting one ever-growing file: re-dumping "--since boot"
// every cycle made each cycle's journalctl call (and the file it produced)
// grow with total uptime, independent of how much actually happened since
// the last sync.
func (w *blackboxWorker) captureSnapshots(root string, since time.Time) error {
cycleTS := blackboxNow().Format("20060102-150405.000")
sinceArg := since.Format(time.RFC3339)
incDir := filepath.Join(root, "tasks", "_services", "journal-increments")
if err := captureCommandAtomic(filepath.Join(incDir, "combined-"+cycleTS+".log"), "journalctl", "--no-pager", "--since", sinceArg); err != nil {
return err
}
for _, svc := range supportBundleServices {
dir := filepath.Join(root, serviceBundleDir(svc))
if err := captureCommandAtomic(filepath.Join(dir, svc+".journal.log"), "journalctl", "--no-pager", "-u", svc, "--since", w.runtime.bootStarted.Format(time.RFC3339)); err != nil {
if err := captureCommandAtomic(filepath.Join(incDir, svc+"-"+cycleTS+".log"), "journalctl", "--no-pager", "-u", svc, "--since", sinceArg); err != nil {
return err
}
if err := captureCommandAtomic(filepath.Join(dir, svc+".status.txt"), "systemctl", "status", svc, "--no-pager"); err != nil {