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:
+59
-12
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
)
|
||||
@@ -16,6 +17,26 @@ type Hosts struct {
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
const hostColumns = `id, name, mac, wol_broadcast_ip, wol_port, expected_spec_yaml,
|
||||
COALESCE(pdu_config_json,''), COALESCE(ipmi_config_json,''),
|
||||
notes, created_at, updated_at, last_seen_at`
|
||||
|
||||
func scanHost(row interface {
|
||||
Scan(dest ...any) error
|
||||
}, h *model.Host) error {
|
||||
var lastSeen sql.NullTime
|
||||
if err := row.Scan(&h.ID, &h.Name, &h.MAC, &h.WoLBroadcastIP, &h.WoLPort,
|
||||
&h.ExpectedSpecYAML, &h.PDUConfigJSON, &h.IPMIConfigJSON,
|
||||
&h.Notes, &h.CreatedAt, &h.UpdatedAt, &lastSeen); err != nil {
|
||||
return err
|
||||
}
|
||||
if lastSeen.Valid {
|
||||
t := lastSeen.Time
|
||||
h.LastSeenAt = &t
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Hosts) Create(ctx context.Context, in model.Host) (int64, error) {
|
||||
in.MAC = normalizeMAC(in.MAC)
|
||||
res, err := h.DB.ExecContext(ctx, `
|
||||
@@ -30,9 +51,7 @@ func (h *Hosts) Create(ctx context.Context, in model.Host) (int64, error) {
|
||||
|
||||
func (h *Hosts) List(ctx context.Context) ([]model.Host, error) {
|
||||
rows, err := h.DB.QueryContext(ctx, `
|
||||
SELECT id, name, mac, wol_broadcast_ip, wol_port, expected_spec_yaml,
|
||||
COALESCE(pdu_config_json,''), COALESCE(ipmi_config_json,''),
|
||||
notes, created_at, updated_at
|
||||
SELECT `+hostColumns+`
|
||||
FROM hosts
|
||||
ORDER BY name COLLATE NOCASE
|
||||
`)
|
||||
@@ -44,9 +63,7 @@ func (h *Hosts) List(ctx context.Context) ([]model.Host, error) {
|
||||
var out []model.Host
|
||||
for rows.Next() {
|
||||
var host model.Host
|
||||
if err := rows.Scan(&host.ID, &host.Name, &host.MAC, &host.WoLBroadcastIP, &host.WoLPort,
|
||||
&host.ExpectedSpecYAML, &host.PDUConfigJSON, &host.IPMIConfigJSON,
|
||||
&host.Notes, &host.CreatedAt, &host.UpdatedAt); err != nil {
|
||||
if err := scanHost(rows, &host); err != nil {
|
||||
return nil, fmt.Errorf("scan host: %w", err)
|
||||
}
|
||||
out = append(out, host)
|
||||
@@ -56,15 +73,11 @@ func (h *Hosts) List(ctx context.Context) ([]model.Host, error) {
|
||||
|
||||
func (h *Hosts) Get(ctx context.Context, id int64) (*model.Host, error) {
|
||||
row := h.DB.QueryRowContext(ctx, `
|
||||
SELECT id, name, mac, wol_broadcast_ip, wol_port, expected_spec_yaml,
|
||||
COALESCE(pdu_config_json,''), COALESCE(ipmi_config_json,''),
|
||||
notes, created_at, updated_at
|
||||
SELECT `+hostColumns+`
|
||||
FROM hosts WHERE id = ?
|
||||
`, id)
|
||||
var host model.Host
|
||||
err := row.Scan(&host.ID, &host.Name, &host.MAC, &host.WoLBroadcastIP, &host.WoLPort,
|
||||
&host.ExpectedSpecYAML, &host.PDUConfigJSON, &host.IPMIConfigJSON,
|
||||
&host.Notes, &host.CreatedAt, &host.UpdatedAt)
|
||||
err := scanHost(row, &host)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -74,6 +87,40 @@ func (h *Hosts) Get(ctx context.Context, id int64) (*model.Host, error) {
|
||||
return &host, nil
|
||||
}
|
||||
|
||||
// GetByMAC looks up a host by its normalized MAC. Used by the host-mode
|
||||
// heartbeat endpoint, which only has a MAC to go on.
|
||||
func (h *Hosts) GetByMAC(ctx context.Context, mac string) (*model.Host, error) {
|
||||
row := h.DB.QueryRowContext(ctx, `
|
||||
SELECT `+hostColumns+`
|
||||
FROM hosts WHERE mac = ?
|
||||
`, normalizeMAC(mac))
|
||||
var host model.Host
|
||||
err := scanHost(row, &host)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get host by mac: %w", err)
|
||||
}
|
||||
return &host, nil
|
||||
}
|
||||
|
||||
// UpdateLastSeen stamps the host row with the most recent heartbeat.
|
||||
// Targeted UPDATE so it doesn't race with UI edits of other fields.
|
||||
func (h *Hosts) UpdateLastSeen(ctx context.Context, mac string, t time.Time) error {
|
||||
res, err := h.DB.ExecContext(ctx,
|
||||
`UPDATE hosts SET last_seen_at = ? WHERE mac = ?`,
|
||||
t.UTC(), normalizeMAC(mac))
|
||||
if err != nil {
|
||||
return fmt.Errorf("update last_seen_at: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Hosts) Delete(ctx context.Context, id int64) error {
|
||||
res, err := h.DB.ExecContext(ctx, `DELETE FROM hosts WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"vetting/internal/db"
|
||||
"vetting/internal/model"
|
||||
"vetting/internal/store"
|
||||
)
|
||||
|
||||
func newHosts(t *testing.T) *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() })
|
||||
return &store.Hosts{DB: conn}
|
||||
}
|
||||
|
||||
func TestHostsGetByMAC(t *testing.T) {
|
||||
hosts := newHosts(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := hosts.Create(ctx, model.Host{
|
||||
Name: "mac-host",
|
||||
MAC: "AA:BB:CC:DD:EE:01",
|
||||
WoLBroadcastIP: "10.0.0.255",
|
||||
WoLPort: 9,
|
||||
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
// Lookup normalizes case — upper-case MAC resolves same row.
|
||||
got, err := hosts.GetByMAC(ctx, "Aa:Bb:Cc:Dd:Ee:01")
|
||||
if err != nil {
|
||||
t.Fatalf("GetByMAC: %v", err)
|
||||
}
|
||||
if got.ID != id || got.Name != "mac-host" {
|
||||
t.Fatalf("wrong row: %+v", got)
|
||||
}
|
||||
if got.LastSeenAt != nil {
|
||||
t.Fatalf("LastSeenAt = %v, want nil on fresh host", got.LastSeenAt)
|
||||
}
|
||||
|
||||
if _, err := hosts.GetByMAC(ctx, "aa:bb:cc:dd:ee:99"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("GetByMAC unknown = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostsUpdateLastSeen(t *testing.T) {
|
||||
hosts := newHosts(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := hosts.Create(ctx, model.Host{
|
||||
Name: "ls-host",
|
||||
MAC: "aa:bb:cc:dd:ee:02",
|
||||
WoLBroadcastIP: "10.0.0.255",
|
||||
WoLPort: 9,
|
||||
ExpectedSpecYAML: "memory:\n total_gib: 8\n",
|
||||
Notes: "keep me",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
stamp := time.Date(2026, 4, 17, 12, 0, 0, 0, time.UTC)
|
||||
if err := hosts.UpdateLastSeen(ctx, "AA:BB:CC:DD:EE:02", stamp); err != nil {
|
||||
t.Fatalf("UpdateLastSeen: %v", err)
|
||||
}
|
||||
|
||||
got, err := hosts.Get(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got.LastSeenAt == nil || !got.LastSeenAt.Equal(stamp) {
|
||||
t.Fatalf("LastSeenAt = %v, want %v", got.LastSeenAt, stamp)
|
||||
}
|
||||
// Other fields untouched — targeted UPDATE must not stomp anything.
|
||||
if got.Name != "ls-host" || got.Notes != "keep me" || got.WoLPort != 9 {
|
||||
t.Fatalf("row damaged: %+v", got)
|
||||
}
|
||||
|
||||
// A second update advances the timestamp.
|
||||
later := stamp.Add(45 * time.Second)
|
||||
if err := hosts.UpdateLastSeen(ctx, got.MAC, later); err != nil {
|
||||
t.Fatalf("second UpdateLastSeen: %v", err)
|
||||
}
|
||||
got, _ = hosts.Get(ctx, id)
|
||||
if !got.LastSeenAt.Equal(later) {
|
||||
t.Fatalf("LastSeenAt not advanced: %v", got.LastSeenAt)
|
||||
}
|
||||
|
||||
// Unknown MAC is an error, not a silent no-op — a stale agent on a
|
||||
// re-registered box should complain loudly.
|
||||
if err := hosts.UpdateLastSeen(ctx, "aa:bb:cc:dd:ee:ff", later); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("UpdateLastSeen unknown = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user