4524ab8dc0
Non-destructive pre-declares "don't touch the disks" on Start: the Storage stage skips wipe-probe, badblocks -w, and write-mode fio, and reports a read-only summary. Runs a new non_destructive column; threaded through Claim → agent tests.Deps → Storage stage. Cancel halts an in-flight run. The orchestrator transitions to a new StateCancelled via TriggerOperatorCancelled (valid from any active state); the agent's next heartbeat returns cmd=cancel_stage, which fires a stored CancelFunc on the per-stage context. Stage subprocesses spawned with exec.CommandContext die with the context, the agent posts a cancelled outcome, then powers the host off. Destructive stages mid-run may leave the host in an intermediate state — the UI confirm dialog warns the operator; recovery is manual for now. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
192 lines
5.6 KiB
Go
192 lines
5.6 KiB
Go
package api_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"vetting/internal/api"
|
|
"vetting/internal/db"
|
|
"vetting/internal/events"
|
|
"vetting/internal/model"
|
|
"vetting/internal/orchestrator"
|
|
"vetting/internal/store"
|
|
)
|
|
|
|
func setupDetail(t *testing.T) (*api.UI, *store.Hosts, *store.Runs) {
|
|
t.Helper()
|
|
conn, err := db.Open(filepath.Join(t.TempDir(), "vetting.db"))
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = conn.Close() })
|
|
hosts := &store.Hosts{DB: conn}
|
|
runs := &store.Runs{DB: conn}
|
|
stages := &store.Stages{DB: conn}
|
|
diffs := &store.SpecDiffs{DB: conn}
|
|
arts := &store.Artifacts{DB: conn}
|
|
hub := events.NewHub()
|
|
runner := &orchestrator.Runner{Runs: runs, Hosts: hosts, Stages: stages, EventHub: hub}
|
|
tiles := &api.TileEnricher{Runs: runs, Artifacts: arts, SpecDiffs: diffs}
|
|
ui := &api.UI{
|
|
Hosts: hosts,
|
|
Runs: runs,
|
|
Stages: stages,
|
|
SpecDiffs: diffs,
|
|
Artifacts: arts,
|
|
EventHub: hub,
|
|
Runner: runner,
|
|
Tiles: tiles,
|
|
}
|
|
return ui, hosts, runs
|
|
}
|
|
|
|
func detailReq(id int64) *http.Request {
|
|
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/hosts/%d", id), nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", fmt.Sprintf("%d", id))
|
|
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
}
|
|
|
|
func TestHostDetail_OK(t *testing.T) {
|
|
ui, hosts, runs := setupDetail(t)
|
|
ctx := context.Background()
|
|
id, err := hosts.Create(ctx, model.Host{
|
|
Name: "detail-host",
|
|
MAC: "aa:bb:cc:dd:ee:30",
|
|
WoLBroadcastIP: "10.0.0.255",
|
|
WoLPort: 9,
|
|
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create host: %v", err)
|
|
}
|
|
runID, err := runs.Create(ctx, id, "deadbeef", false)
|
|
if err != nil {
|
|
t.Fatalf("create run: %v", err)
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
ui.HostDetail(rr, detailReq(id))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
|
|
}
|
|
body := rr.Body.String()
|
|
if !strings.Contains(body, "detail-host") {
|
|
t.Fatalf("body missing host name: %s", body)
|
|
}
|
|
wantPipelineID := fmt.Sprintf(`id="pipeline-%d"`, runID)
|
|
if !strings.Contains(body, wantPipelineID) {
|
|
t.Fatalf("body missing %s", wantPipelineID)
|
|
}
|
|
wantLogID := fmt.Sprintf(`id="log-%d"`, runID)
|
|
if !strings.Contains(body, wantLogID) {
|
|
t.Fatalf("body missing %s", wantLogID)
|
|
}
|
|
}
|
|
|
|
func TestHostDetail_NeverRun(t *testing.T) {
|
|
ui, hosts, _ := setupDetail(t)
|
|
id, err := hosts.Create(context.Background(), model.Host{
|
|
Name: "never-run",
|
|
MAC: "aa:bb:cc:dd:ee:31",
|
|
WoLBroadcastIP: "10.0.0.255",
|
|
WoLPort: 9,
|
|
ExpectedSpecYAML: "memory:\n total_gib: 8\n",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create host: %v", err)
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
ui.HostDetail(rr, detailReq(id))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
|
|
}
|
|
body := rr.Body.String()
|
|
if !strings.Contains(body, "Start vetting") {
|
|
t.Fatalf("never-run page missing Start vetting: %s", body)
|
|
}
|
|
// Ghost pipeline: all nodes rendered but none are running/failed/passed.
|
|
if strings.Contains(body, "stage-dot-running") || strings.Contains(body, "stage-dot-failed") {
|
|
t.Fatalf("ghost pipeline should have no running/failed dots: %s", body)
|
|
}
|
|
if !strings.Contains(body, "stage-dot-pending") {
|
|
t.Fatalf("expected pending stage dots in ghost pipeline: %s", body)
|
|
}
|
|
}
|
|
|
|
// TestHostDetail_LogTabsRendered: when a run exists, the detail page
|
|
// emits the log-tabs scaffold with one radio per stage + an "All" tab
|
|
// checked by default. CSS sibling selectors drive visibility — no JS.
|
|
func TestHostDetail_LogTabsRendered(t *testing.T) {
|
|
ui, hosts, runs := setupDetail(t)
|
|
ctx := context.Background()
|
|
id, err := hosts.Create(ctx, model.Host{
|
|
Name: "tabs-host",
|
|
MAC: "aa:bb:cc:dd:ee:40",
|
|
WoLBroadcastIP: "10.0.0.255",
|
|
WoLPort: 9,
|
|
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create host: %v", err)
|
|
}
|
|
runID, err := runs.Create(ctx, id, "cafef00d", false)
|
|
if err != nil {
|
|
t.Fatalf("create run: %v", err)
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
ui.HostDetail(rr, detailReq(id))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", rr.Code)
|
|
}
|
|
body := rr.Body.String()
|
|
|
|
// All tab: the default-checked radio, plus its pane.
|
|
wantAllID := fmt.Sprintf(`id="log-tab-%d-all"`, runID)
|
|
if !strings.Contains(body, wantAllID) {
|
|
t.Fatalf("body missing All tab radio %s", wantAllID)
|
|
}
|
|
// Per-stage tabs: every entry in DefaultStageOrder must have its own
|
|
// radio + pane so tabs switch purely via sibling CSS.
|
|
for _, s := range store.DefaultStageOrder {
|
|
wantRadio := fmt.Sprintf(`id="log-tab-%d-%s"`, runID, s)
|
|
if !strings.Contains(body, wantRadio) {
|
|
t.Fatalf("body missing stage tab radio %s", wantRadio)
|
|
}
|
|
wantPane := fmt.Sprintf(`id="log-%d-%s"`, runID, s)
|
|
if !strings.Contains(body, wantPane) {
|
|
t.Fatalf("body missing stage pane %s", wantPane)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHostDetail_UnknownID(t *testing.T) {
|
|
ui, _, _ := setupDetail(t)
|
|
rr := httptest.NewRecorder()
|
|
ui.HostDetail(rr, detailReq(9999))
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestHostDetail_BadID(t *testing.T) {
|
|
ui, _, _ := setupDetail(t)
|
|
req := httptest.NewRequest(http.MethodGet, "/hosts/not-a-number", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", "not-a-number")
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
rr := httptest.NewRecorder()
|
|
ui.HostDetail(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", rr.Code)
|
|
}
|
|
}
|