Add host-mode heartbeat: vetting-agent host + last-seen badge
CI / Lint + build + test (push) Has been cancelled

vetting-agent gains a `host` subcommand that runs as a systemd service
installed by the quick-register one-liner, POSTing every 30s to
/api/v1/hosts/{mac}/heartbeat so the dashboard tile shows "online" or
"Nm ago" without waiting on WoL. Ships dormant client code for the
Phase 2 reboot_for_vetting command so the server can flip it on later
without a binary redeploy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 23:34:15 -04:00
parent d24207427f
commit a0c0fb114f
28 changed files with 1106 additions and 165 deletions
+102
View File
@@ -0,0 +1,102 @@
package api_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/go-chi/chi/v5"
"vetting/internal/api"
"vetting/internal/db"
"vetting/internal/model"
"vetting/internal/store"
)
// setupHeartbeat wires just enough of UI to exercise the heartbeat
// handler. Runner is left nil — the handler no-ops the SSE publish in
// that case, which matches "tests don't assert on SSE" (covered by
// integration-style runner tests).
func setupHeartbeat(t *testing.T) (*api.UI, *store.Hosts) {
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}
return &api.UI{Hosts: hosts}, hosts
}
func heartbeatReq(mac string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/api/v1/hosts/"+mac+"/heartbeat", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("mac", mac)
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
}
func TestUIHeartbeat_Success(t *testing.T) {
ui, hosts := setupHeartbeat(t)
id, err := hosts.Create(context.Background(), model.Host{
Name: "hb-host",
MAC: "aa:bb:cc:dd:ee:10",
WoLBroadcastIP: "10.0.0.255",
WoLPort: 9,
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
})
if err != nil {
t.Fatalf("create: %v", err)
}
before := time.Now().UTC().Add(-time.Second)
rr := httptest.NewRecorder()
ui.Heartbeat(rr, heartbeatReq("aa:bb:cc:dd:ee:10"))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp["ok"] != true {
t.Fatalf("response = %v, want ok:true", resp)
}
got, err := hosts.Get(context.Background(), id)
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.LastSeenAt == nil {
t.Fatalf("LastSeenAt not stamped")
}
if got.LastSeenAt.Before(before) {
t.Fatalf("LastSeenAt = %v, want >= %v", got.LastSeenAt, before)
}
}
func TestUIHeartbeat_UnknownMAC(t *testing.T) {
ui, _ := setupHeartbeat(t)
rr := httptest.NewRecorder()
ui.Heartbeat(rr, heartbeatReq("aa:bb:cc:dd:ee:ff"))
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rr.Code)
}
var resp map[string]string
_ = json.NewDecoder(rr.Body).Decode(&resp)
if resp["error"] == "" {
t.Fatalf("missing error body")
}
}
func TestUIHeartbeat_BadMAC(t *testing.T) {
ui, _ := setupHeartbeat(t)
rr := httptest.NewRecorder()
ui.Heartbeat(rr, heartbeatReq("not-a-mac"))
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code)
}
}