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
+59 -12
View File
@@ -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 {