From c394845b341cff6b390c48c4adf26879a710ff54 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Wed, 1 Apr 2026 08:46:46 +0300 Subject: [PATCH] refactor(webui): queue install and bundle tasks - v3.18 --- audit/internal/app/support_bundle.go | 64 ++++++++++++++----- audit/internal/webui/api.go | 89 +++++++++++++-------------- audit/internal/webui/api_test.go | 38 ++++++++++++ audit/internal/webui/metricsdb.go | 22 +++++-- audit/internal/webui/pages.go | 91 +++++++++++++++++++++++++--- audit/internal/webui/server.go | 17 ++++-- audit/internal/webui/server_test.go | 11 ++++ audit/internal/webui/tasks.go | 79 +++++++++++++++++++++++- audit/internal/webui/tasks_test.go | 85 ++++++++++++++++++++++++-- 9 files changed, 410 insertions(+), 86 deletions(-) diff --git a/audit/internal/app/support_bundle.go b/audit/internal/app/support_bundle.go index ddaf5e9..12285dd 100644 --- a/audit/internal/app/support_bundle.go +++ b/audit/internal/app/support_bundle.go @@ -36,6 +36,8 @@ var supportBundleCommands = []struct { {name: "system/dmesg-tail.txt", cmd: []string{"sh", "-c", "dmesg | tail -n 200"}}, } +const supportBundleGlob = "bee-support-*.tar.gz" + func BuildSupportBundle(exportDir string) (string, error) { exportDir = strings.TrimSpace(exportDir) if exportDir == "" { @@ -86,34 +88,64 @@ func BuildSupportBundle(exportDir string) (string, error) { return archivePath, nil } +func LatestSupportBundlePath() (string, error) { + return latestSupportBundlePath(os.TempDir()) +} + func cleanupOldSupportBundles(dir string) error { - matches, err := filepath.Glob(filepath.Join(dir, "bee-support-*.tar.gz")) + matches, err := filepath.Glob(filepath.Join(dir, supportBundleGlob)) if err != nil { return err } - type entry struct { - path string - mod time.Time + entries := supportBundleEntries(matches) + for path, mod := range entries { + if time.Since(mod) > 24*time.Hour { + _ = os.Remove(path) + delete(entries, path) + } } - list := make([]entry, 0, len(matches)) + ordered := orderSupportBundles(entries) + if len(ordered) > 3 { + for _, old := range ordered[3:] { + _ = os.Remove(old) + } + } + return nil +} + +func latestSupportBundlePath(dir string) (string, error) { + matches, err := filepath.Glob(filepath.Join(dir, supportBundleGlob)) + if err != nil { + return "", err + } + ordered := orderSupportBundles(supportBundleEntries(matches)) + if len(ordered) == 0 { + return "", os.ErrNotExist + } + return ordered[0], nil +} + +func supportBundleEntries(matches []string) map[string]time.Time { + entries := make(map[string]time.Time, len(matches)) for _, match := range matches { info, err := os.Stat(match) if err != nil { continue } - if time.Since(info.ModTime()) > 24*time.Hour { - _ = os.Remove(match) - continue - } - list = append(list, entry{path: match, mod: info.ModTime()}) + entries[match] = 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 entries +} + +func orderSupportBundles(entries map[string]time.Time) []string { + ordered := make([]string, 0, len(entries)) + for path := range entries { + ordered = append(ordered, path) } - return nil + sort.Slice(ordered, func(i, j int) bool { + return entries[ordered[i]].After(entries[ordered[j]]) + }) + return ordered } func writeJournalDump(dst string) error { diff --git a/audit/internal/webui/api.go b/audit/internal/webui/api.go index 63e14fe..b0000b0 100644 --- a/audit/internal/webui/api.go +++ b/audit/internal/webui/api.go @@ -2,7 +2,6 @@ package webui import ( "bufio" - "context" "encoding/json" "errors" "fmt" @@ -87,15 +86,16 @@ func streamJob(w http.ResponseWriter, r *http.Request, j *jobState) { } } -// runCmdJob runs an exec.Cmd as a background job, streaming stdout+stderr lines. -func runCmdJob(j *jobState, cmd *exec.Cmd) { +// streamCmdJob runs an exec.Cmd and streams stdout+stderr lines into j. +func streamCmdJob(j *jobState, cmd *exec.Cmd) error { pr, pw := io.Pipe() cmd.Stdout = pw cmd.Stderr = pw if err := cmd.Start(); err != nil { - j.finish(err.Error()) - return + _ = pw.Close() + _ = pr.Close() + return err } // Lower the CPU scheduling priority of stress/audit subprocesses to nice+10 // so the X server and kernel interrupt handling remain responsive under load @@ -104,8 +104,10 @@ func runCmdJob(j *jobState, cmd *exec.Cmd) { _ = syscall.Setpriority(syscall.PRIO_PROCESS, cmd.Process.Pid, 10) } + scanDone := make(chan error, 1) go func() { scanner := bufio.NewScanner(pr) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for scanner.Scan() { // Split on \r to handle progress-bar style output (e.g. \r overwrites) // and strip ANSI escape codes so logs are readable in the browser. @@ -117,15 +119,21 @@ func runCmdJob(j *jobState, cmd *exec.Cmd) { } } } + if err := scanner.Err(); err != nil && !errors.Is(err, io.ErrClosedPipe) { + scanDone <- err + return + } + scanDone <- nil }() err := cmd.Wait() _ = pw.Close() + scanErr := <-scanDone + _ = pr.Close() if err != nil { - j.finish(err.Error()) - } else { - j.finish("") + return err } + return scanErr } // ── Audit ───────────────────────────────────────────────────────────────────── @@ -417,15 +425,23 @@ func (h *handler) handleAPIExportList(w http.ResponseWriter, r *http.Request) { } func (h *handler) handleAPIExportBundle(w http.ResponseWriter, r *http.Request) { - archive, err := app.BuildSupportBundle(h.opts.ExportDir) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) + if globalQueue.hasActiveTarget("support-bundle") { + writeError(w, http.StatusConflict, "support bundle task is already pending or running") return } + t := &Task{ + ID: newJobID("support-bundle"), + Name: "Support Bundle", + Target: "support-bundle", + Status: TaskPending, + CreatedAt: time.Now(), + } + globalQueue.enqueue(t) writeJSON(w, map[string]string{ - "status": "ok", - "path": archive, - "url": "/export/support.tar.gz", + "status": "queued", + "task_id": t.ID, + "job_id": t.ID, + "url": "/export/support.tar.gz", }) } @@ -513,10 +529,7 @@ func (h *handler) handleAPIInstallToRAM(w http.ResponseWriter, r *http.Request) writeError(w, http.StatusServiceUnavailable, "app not configured") return } - h.installMu.Lock() - installRunning := h.installJob != nil && !h.installJob.isDone() - h.installMu.Unlock() - if installRunning { + if globalQueue.hasActiveTarget("install") { writeError(w, http.StatusConflict, "install to disk is already running") return } @@ -631,35 +644,23 @@ func (h *handler) handleAPIInstallRun(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusConflict, "install to RAM task is already pending or running") return } - - h.installMu.Lock() - if h.installJob != nil && !h.installJob.isDone() { - h.installMu.Unlock() - writeError(w, http.StatusConflict, "install already running") + if globalQueue.hasActiveTarget("install") { + writeError(w, http.StatusConflict, "install task is already pending or running") return } - j := &jobState{} - h.installJob = j - h.installMu.Unlock() - - logFile := platform.InstallLogPath(req.Device) - go runCmdJob(j, exec.CommandContext(context.Background(), "bee-install", req.Device, logFile)) - - w.WriteHeader(http.StatusNoContent) -} - -func (h *handler) handleAPIInstallStream(w http.ResponseWriter, r *http.Request) { - h.installMu.Lock() - j := h.installJob - h.installMu.Unlock() - if j == nil { - if !sseStart(w) { - return - } - sseWrite(w, "done", "") - return + t := &Task{ + ID: newJobID("install"), + Name: "Install to Disk", + Target: "install", + Priority: 20, + Status: TaskPending, + CreatedAt: time.Now(), + params: taskParams{ + Device: req.Device, + }, } - streamJob(w, r, j) + globalQueue.enqueue(t) + writeJSON(w, map[string]string{"task_id": t.ID, "job_id": t.ID}) } // ── Metrics SSE ─────────────────────────────────────────────────────────────── diff --git a/audit/internal/webui/api_test.go b/audit/internal/webui/api_test.go index f950cce..d508073 100644 --- a/audit/internal/webui/api_test.go +++ b/audit/internal/webui/api_test.go @@ -1,6 +1,7 @@ package webui import ( + "encoding/json" "net/http/httptest" "strings" "testing" @@ -62,3 +63,40 @@ func TestHandleAPISATRunDecodesBodyWithoutContentLength(t *testing.T) { t.Fatalf("burn profile=%q want smoke", got) } } + +func TestHandleAPIExportBundleQueuesTask(t *testing.T) { + globalQueue.mu.Lock() + originalTasks := globalQueue.tasks + globalQueue.tasks = nil + globalQueue.mu.Unlock() + t.Cleanup(func() { + globalQueue.mu.Lock() + globalQueue.tasks = originalTasks + globalQueue.mu.Unlock() + }) + + h := &handler{opts: HandlerOptions{ExportDir: t.TempDir()}} + req := httptest.NewRequest("POST", "/api/export/bundle", nil) + rec := httptest.NewRecorder() + + h.handleAPIExportBundle(rec, req) + + if rec.Code != 200 { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var body map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body["task_id"] == "" { + t.Fatalf("missing task_id in response: %v", body) + } + globalQueue.mu.Lock() + defer globalQueue.mu.Unlock() + if len(globalQueue.tasks) != 1 { + t.Fatalf("tasks=%d want 1", len(globalQueue.tasks)) + } + if got := globalQueue.tasks[0].Target; got != "support-bundle" { + t.Fatalf("target=%q want support-bundle", got) + } +} diff --git a/audit/internal/webui/metricsdb.go b/audit/internal/webui/metricsdb.go index 704ffb2..1e31280 100644 --- a/audit/internal/webui/metricsdb.go +++ b/audit/internal/webui/metricsdb.go @@ -4,6 +4,8 @@ import ( "database/sql" "encoding/csv" "io" + "os" + "path/filepath" "strconv" "time" @@ -20,6 +22,9 @@ type MetricsDB struct { // openMetricsDB opens (or creates) the metrics database at the given path. func openMetricsDB(path string) (*MetricsDB, error) { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return nil, err + } db, err := sql.Open("sqlite", path+"?_journal=WAL&_busy_timeout=5000") if err != nil { return nil, err @@ -132,7 +137,7 @@ func (m *MetricsDB) loadSamples(query string, args ...any) ([]platform.LiveMetri defer rows.Close() type sysRow struct { - ts int64 + ts int64 cpu, mem, pwr float64 } var sysRows []sysRow @@ -156,7 +161,10 @@ func (m *MetricsDB) loadSamples(query string, args ...any) ([]platform.LiveMetri maxTS := sysRows[len(sysRows)-1].ts // Load GPU rows in range - type gpuKey struct{ ts int64; idx int } + type gpuKey struct { + ts int64 + idx int + } gpuData := map[gpuKey]platform.GPUMetricRow{} gRows, err := m.db.Query( `SELECT ts,gpu_index,temp_c,usage_pct,mem_usage_pct,power_w FROM gpu_metrics WHERE ts>=? AND ts<=? ORDER BY ts,gpu_index`, @@ -174,7 +182,10 @@ func (m *MetricsDB) loadSamples(query string, args ...any) ([]platform.LiveMetri } // Load fan rows in range - type fanKey struct{ ts int64; name string } + type fanKey struct { + ts int64 + name string + } fanData := map[fanKey]float64{} fRows, err := m.db.Query( `SELECT ts,name,rpm FROM fan_metrics WHERE ts>=? AND ts<=?`, minTS, maxTS, @@ -192,7 +203,10 @@ func (m *MetricsDB) loadSamples(query string, args ...any) ([]platform.LiveMetri } // Load temp rows in range - type tempKey struct{ ts int64; name string } + type tempKey struct { + ts int64 + name string + } tempData := map[tempKey]platform.TempReading{} tRows, err := m.db.Query( `SELECT ts,name,grp,celsius FROM temp_metrics WHERE ts>=? AND ts<=?`, minTS, maxTS, diff --git a/audit/internal/webui/pages.go b/audit/internal/webui/pages.go index 1f29548..4d0c220 100644 --- a/audit/internal/webui/pages.go +++ b/audit/internal/webui/pages.go @@ -926,7 +926,7 @@ func renderExport(exportDir string) string { return `
Support Bundle

Creates a tar.gz archive of all audit files, SAT results, and logs.

-↓ Download Support Bundle +` + renderSupportBundleInline() + `
Export Files
` + rows.String() + `
File
@@ -1024,6 +1024,77 @@ func listExportFiles(exportDir string) ([]string, error) { return entries, nil } +func renderSupportBundleInline() string { + return ` + +
No support bundle built in this session.
+ +` +} + // ── Display Resolution ──────────────────────────────────────────────────────── func renderDisplayInline() string { @@ -1113,7 +1184,7 @@ function installToRAM() {
Support Bundle

Downloads a tar.gz archive of all audit files, SAT results, and logs.

-↓ Download Support Bundle +` + renderSupportBundleInline() + `
Tool Check
@@ -1292,21 +1363,23 @@ function installStart() { headers: {'Content-Type': 'application/json'}, body: JSON.stringify({device: _installSelected.device}) }).then(function(r){ - if (r.status === 204) { - installStreamLog(); - } else { - return r.json().then(function(j){ throw new Error(j.error || r.statusText); }); - } + return r.json().then(function(j){ + if (!r.ok) throw new Error(j.error || r.statusText); + return j; + }); + }).then(function(j){ + if (!j.task_id) throw new Error('missing task id'); + installStreamLog(j.task_id); }).catch(function(e){ status.textContent = 'Error: ' + e; status.style.color = 'var(--crit-fg)'; }); } -function installStreamLog() { +function installStreamLog(taskId) { var term = document.getElementById('install-terminal'); var status = document.getElementById('install-status'); - var es = new EventSource('/api/install/stream'); + var es = new EventSource('/api/tasks/' + taskId + '/stream'); es.onmessage = function(e) { term.textContent += e.data + '\n'; term.scrollTop = term.scrollHeight; diff --git a/audit/internal/webui/server.go b/audit/internal/webui/server.go index 621be72..d342153 100644 --- a/audit/internal/webui/server.go +++ b/audit/internal/webui/server.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "html" + "log/slog" "mime" "net/http" "os" @@ -143,9 +144,6 @@ type handler struct { latest *platform.LiveMetricSample // metrics persistence (nil if DB unavailable) metricsDB *MetricsDB - // install job (at most one at a time) - installJob *jobState - installMu sync.Mutex // pending network change (rollback on timeout) pendingNet *pendingNetChange pendingNetMu sync.Mutex @@ -180,7 +178,11 @@ func NewHandler(opts HandlerOptions) http.Handler { if len(samples) > 0 { h.setLatestMetric(samples[len(samples)-1]) } + } else { + slog.Warn("metrics history unavailable", "path", metricsDBPath, "err", err) } + } else { + slog.Warn("metrics db disabled", "path", metricsDBPath, "err", err) } h.startMetricsCollector() @@ -266,7 +268,6 @@ func NewHandler(opts HandlerOptions) http.Handler { // Install mux.HandleFunc("GET /api/install/disks", h.handleAPIInstallDisks) mux.HandleFunc("POST /api/install/run", h.handleAPIInstallRun) - mux.HandleFunc("GET /api/install/stream", h.handleAPIInstallStream) // Metrics — SSE stream of live sensor data + server-side SVG charts + CSV export mux.HandleFunc("GET /api/metrics/stream", h.handleAPIMetricsStream) @@ -366,9 +367,13 @@ func (h *handler) handleRuntimeHealthJSON(w http.ResponseWriter, r *http.Request } func (h *handler) handleSupportBundleDownload(w http.ResponseWriter, r *http.Request) { - archive, err := app.BuildSupportBundle(h.opts.ExportDir) + archive, err := app.LatestSupportBundlePath() if err != nil { - http.Error(w, fmt.Sprintf("build support bundle: %v", err), http.StatusInternalServerError) + if errors.Is(err, os.ErrNotExist) { + http.Error(w, "support bundle not built yet", http.StatusNotFound) + return + } + http.Error(w, fmt.Sprintf("locate support bundle: %v", err), http.StatusInternalServerError) return } w.Header().Set("Cache-Control", "no-store") diff --git a/audit/internal/webui/server_test.go b/audit/internal/webui/server_test.go index ab0b216..f6c5886 100644 --- a/audit/internal/webui/server_test.go +++ b/audit/internal/webui/server_test.go @@ -259,6 +259,17 @@ func TestSupportBundleEndpointReturnsArchive(t *testing.T) { if err := os.WriteFile(filepath.Join(exportDir, "bee-audit.log"), []byte("audit log"), 0644); err != nil { t.Fatal(err) } + archive, err := os.CreateTemp(os.TempDir(), "bee-support-server-test-*.tar.gz") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Remove(archive.Name()) }) + if _, err := archive.WriteString("support-bundle"); err != nil { + t.Fatal(err) + } + if err := archive.Close(); err != nil { + t.Fatal(err) + } handler := NewHandler(HandlerOptions{ExportDir: exportDir}) rec := httptest.NewRecorder() diff --git a/audit/internal/webui/tasks.go b/audit/internal/webui/tasks.go index 60f4c96..07825a3 100644 --- a/audit/internal/webui/tasks.go +++ b/audit/internal/webui/tasks.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "os" + "os/exec" "path/filepath" "sort" "strings" @@ -40,6 +41,7 @@ var taskNames = map[string]string{ "sat-stress": "SAT Stress (stressapptest)", "platform-stress": "Platform Thermal Cycling", "audit": "Audit", + "support-bundle": "Support Bundle", "install": "Install to Disk", "install-to-ram": "Install to RAM", } @@ -213,6 +215,10 @@ var ( runSATStressPackCtx = func(a *app.App, ctx context.Context, baseDir string, durationSec int, logFunc func(string)) (string, error) { return a.RunSATStressPackCtx(ctx, baseDir, durationSec, logFunc) } + buildSupportBundle = app.BuildSupportBundle + installCommand = func(ctx context.Context, device string, logPath string) *exec.Cmd { + return exec.CommandContext(ctx, "bee-install", device, logPath) + } ) // enqueue adds a task to the queue and notifies the worker. @@ -410,9 +416,9 @@ func setCPUGovernor(governor string) { // runTask executes the work for a task, writing output to j. func (q *taskQueue) runTask(t *Task, j *jobState, ctx context.Context) { - if q.opts == nil || q.opts.App == nil { - j.append("ERROR: app not configured") - j.finish("app not configured") + if q.opts == nil { + j.append("ERROR: handler options not configured") + j.finish("handler options not configured") return } a := q.opts.App @@ -429,6 +435,10 @@ func (q *taskQueue) runTask(t *Task, j *jobState, ctx context.Context) { switch t.Target { case "nvidia": + if a == nil { + err = fmt.Errorf("app not configured") + break + } diagLevel := t.params.DiagLevel if t.params.BurnProfile != "" && diagLevel <= 0 { diagLevel = resolveBurnPreset(t.params.BurnProfile).NvidiaDiag @@ -446,6 +456,10 @@ func (q *taskQueue) runTask(t *Task, j *jobState, ctx context.Context) { archive, err = a.RunNvidiaAcceptancePack("", j.append) } case "nvidia-stress": + if a == nil { + err = fmt.Errorf("app not configured") + break + } dur := t.params.Duration if t.params.BurnProfile != "" && dur <= 0 { dur = resolveBurnPreset(t.params.BurnProfile).DurationSec @@ -457,10 +471,22 @@ func (q *taskQueue) runTask(t *Task, j *jobState, ctx context.Context) { ExcludeGPUIndices: t.params.ExcludeGPUIndices, }, j.append) case "memory": + if a == nil { + err = fmt.Errorf("app not configured") + break + } archive, err = runMemoryAcceptancePackCtx(a, ctx, "", j.append) case "storage": + if a == nil { + err = fmt.Errorf("app not configured") + break + } archive, err = runStorageAcceptancePackCtx(a, ctx, "", j.append) case "cpu": + if a == nil { + err = fmt.Errorf("app not configured") + break + } dur := t.params.Duration if t.params.BurnProfile != "" && dur <= 0 { dur = resolveBurnPreset(t.params.BurnProfile).DurationSec @@ -471,33 +497,65 @@ func (q *taskQueue) runTask(t *Task, j *jobState, ctx context.Context) { j.append(fmt.Sprintf("CPU stress duration: %ds", dur)) archive, err = runCPUAcceptancePackCtx(a, ctx, "", dur, j.append) case "amd": + if a == nil { + err = fmt.Errorf("app not configured") + break + } archive, err = runAMDAcceptancePackCtx(a, ctx, "", j.append) case "amd-mem": + if a == nil { + err = fmt.Errorf("app not configured") + break + } archive, err = runAMDMemIntegrityPackCtx(a, ctx, "", j.append) case "amd-bandwidth": + if a == nil { + err = fmt.Errorf("app not configured") + break + } archive, err = runAMDMemBandwidthPackCtx(a, ctx, "", j.append) case "amd-stress": + if a == nil { + err = fmt.Errorf("app not configured") + break + } dur := t.params.Duration if t.params.BurnProfile != "" && dur <= 0 { dur = resolveBurnPreset(t.params.BurnProfile).DurationSec } archive, err = runAMDStressPackCtx(a, ctx, "", dur, j.append) case "memory-stress": + if a == nil { + err = fmt.Errorf("app not configured") + break + } dur := t.params.Duration if t.params.BurnProfile != "" && dur <= 0 { dur = resolveBurnPreset(t.params.BurnProfile).DurationSec } archive, err = runMemoryStressPackCtx(a, ctx, "", dur, j.append) case "sat-stress": + if a == nil { + err = fmt.Errorf("app not configured") + break + } dur := t.params.Duration if t.params.BurnProfile != "" && dur <= 0 { dur = resolveBurnPreset(t.params.BurnProfile).DurationSec } archive, err = runSATStressPackCtx(a, ctx, "", dur, j.append) case "platform-stress": + if a == nil { + err = fmt.Errorf("app not configured") + break + } opts := resolvePlatformStressPreset(t.params.BurnProfile) archive, err = a.RunPlatformStress(ctx, "", opts, j.append) case "audit": + if a == nil { + err = fmt.Errorf("app not configured") + break + } result, e := a.RunAuditNow(q.opts.RuntimeMode) if e != nil { err = e @@ -506,7 +564,22 @@ func (q *taskQueue) runTask(t *Task, j *jobState, ctx context.Context) { j.append(line) } } + case "support-bundle": + j.append("Building support bundle...") + archive, err = buildSupportBundle(q.opts.ExportDir) + case "install": + if strings.TrimSpace(t.params.Device) == "" { + err = fmt.Errorf("device is required") + break + } + installLogPath := platform.InstallLogPath(t.params.Device) + j.append("Install log: " + installLogPath) + err = streamCmdJob(j, installCommand(ctx, t.params.Device, installLogPath)) case "install-to-ram": + if a == nil { + err = fmt.Errorf("app not configured") + break + } err = a.RunInstallToRAM(ctx, j.append) default: j.append("ERROR: unknown target: " + t.Target) diff --git a/audit/internal/webui/tasks_test.go b/audit/internal/webui/tasks_test.go index 0514043..61502cf 100644 --- a/audit/internal/webui/tasks_test.go +++ b/audit/internal/webui/tasks_test.go @@ -3,7 +3,9 @@ package webui import ( "context" "os" + "os/exec" "path/filepath" + "strings" "testing" "time" @@ -113,8 +115,6 @@ func TestTaskDisplayNameUsesNvidiaStressLoader(t *testing.T) { } func TestRunTaskHonorsCancel(t *testing.T) { - t.Parallel() - blocked := make(chan struct{}) released := make(chan struct{}) aRun := func(_ any, ctx context.Context, _ string, _ int, _ func(string)) (string, error) { @@ -173,8 +173,6 @@ func TestRunTaskHonorsCancel(t *testing.T) { } func TestRunTaskUsesBurnProfileDurationForCPU(t *testing.T) { - t.Parallel() - var gotDuration int q := &taskQueue{ opts: &HandlerOptions{App: &app.App{}}, @@ -202,3 +200,82 @@ func TestRunTaskUsesBurnProfileDurationForCPU(t *testing.T) { t.Fatalf("duration=%d want %d", gotDuration, 5*60) } } + +func TestRunTaskBuildsSupportBundleWithoutApp(t *testing.T) { + dir := t.TempDir() + q := &taskQueue{ + opts: &HandlerOptions{ExportDir: dir}, + } + tk := &Task{ + ID: "support-bundle-1", + Name: "Support Bundle", + Target: "support-bundle", + Status: TaskRunning, + CreatedAt: time.Now(), + } + j := &jobState{} + + var gotExportDir string + orig := buildSupportBundle + buildSupportBundle = func(exportDir string) (string, error) { + gotExportDir = exportDir + return filepath.Join(exportDir, "bundle.tar.gz"), nil + } + defer func() { buildSupportBundle = orig }() + + q.runTask(tk, j, context.Background()) + + if gotExportDir != dir { + t.Fatalf("exportDir=%q want %q", gotExportDir, dir) + } + if j.err != "" { + t.Fatalf("unexpected error: %q", j.err) + } + if !strings.Contains(strings.Join(j.lines, "\n"), "Archive: "+filepath.Join(dir, "bundle.tar.gz")) { + t.Fatalf("lines=%v", j.lines) + } +} + +func TestRunTaskInstallUsesSharedCommandStreaming(t *testing.T) { + q := &taskQueue{ + opts: &HandlerOptions{}, + } + tk := &Task{ + ID: "install-1", + Name: "Install to Disk", + Target: "install", + Status: TaskRunning, + CreatedAt: time.Now(), + params: taskParams{Device: "/dev/sda"}, + } + j := &jobState{} + + var gotDevice string + var gotLogPath string + orig := installCommand + installCommand = func(ctx context.Context, device string, logPath string) *exec.Cmd { + gotDevice = device + gotLogPath = logPath + return exec.CommandContext(ctx, "sh", "-c", "printf 'line1\nline2\n'") + } + defer func() { installCommand = orig }() + + q.runTask(tk, j, context.Background()) + + if gotDevice != "/dev/sda" { + t.Fatalf("device=%q want /dev/sda", gotDevice) + } + if gotLogPath == "" { + t.Fatal("expected install log path") + } + logs := strings.Join(j.lines, "\n") + if !strings.Contains(logs, "Install log: ") { + t.Fatalf("missing install log line: %v", j.lines) + } + if !strings.Contains(logs, "line1") || !strings.Contains(logs, "line2") { + t.Fatalf("missing streamed output: %v", j.lines) + } + if j.err != "" { + t.Fatalf("unexpected error: %q", j.err) + } +}