Add host-mode heartbeat: vetting-agent host + last-seen badge
CI / Lint + build + test (push) Has been cancelled
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ type TileEnricher struct {
|
||||
// fall back to a tile without the extra fields rather than breaking
|
||||
// the whole dashboard.
|
||||
func (e *TileEnricher) Build(ctx context.Context, host model.Host, latest *model.Run) templates.TileData {
|
||||
t := templates.TileData{Host: host, Latest: latest}
|
||||
t := templates.TileData{Host: host, Latest: latest, LastSeenAt: host.LastSeenAt}
|
||||
if latest == nil {
|
||||
return t
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -240,6 +241,37 @@ func (u *UI) CreateHostJSON(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// Heartbeat is called every ~30s by a host-mode vetting-agent running
|
||||
// as a systemd service on the registered host. LAN-trusted, no auth —
|
||||
// same threat model as the browser UI and quick-register. Phase 1
|
||||
// just stamps last_seen_at and flips the dashboard tile to "online".
|
||||
func (u *UI) Heartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
mac := strings.ToLower(strings.TrimSpace(chi.URLParam(r, "mac")))
|
||||
if !macRe.MatchString(mac) {
|
||||
writeJSONError(w, http.StatusBadRequest,
|
||||
"MAC address must be in the form aa:bb:cc:dd:ee:ff")
|
||||
return
|
||||
}
|
||||
host, err := u.Hosts.GetByMAC(r.Context(), mac)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeJSONError(w, http.StatusNotFound, "unknown host")
|
||||
return
|
||||
}
|
||||
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := u.Hosts.UpdateLastSeen(r.Context(), mac, time.Now().UTC()); err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if u.Runner != nil {
|
||||
u.Runner.PublishTileUpdate(r.Context(), host.ID)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func writeJSONError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
Reference in New Issue
Block a user