package store import ( "context" "database/sql" "errors" "fmt" "strings" "time" "vetting/internal/model" ) type Hosts struct { DB *sql.DB } 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, ` INSERT INTO hosts(name, mac, wol_broadcast_ip, wol_port, expected_spec_yaml, pdu_config_json, ipmi_config_json, notes) VALUES(?,?,?,?,?,?,?,?) `, in.Name, in.MAC, in.WoLBroadcastIP, in.WoLPort, in.ExpectedSpecYAML, nullIfEmpty(in.PDUConfigJSON), nullIfEmpty(in.IPMIConfigJSON), in.Notes) if err != nil { return 0, fmt.Errorf("insert host: %w", err) } return res.LastInsertId() } func (h *Hosts) List(ctx context.Context) ([]model.Host, error) { rows, err := h.DB.QueryContext(ctx, ` SELECT `+hostColumns+` FROM hosts ORDER BY name COLLATE NOCASE `) if err != nil { return nil, fmt.Errorf("list hosts: %w", err) } defer rows.Close() var out []model.Host for rows.Next() { var host model.Host if err := scanHost(rows, &host); err != nil { return nil, fmt.Errorf("scan host: %w", err) } out = append(out, host) } return out, rows.Err() } func (h *Hosts) Get(ctx context.Context, id int64) (*model.Host, error) { row := h.DB.QueryRowContext(ctx, ` SELECT `+hostColumns+` FROM hosts WHERE id = ? `, id) 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: %w", err) } 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 { return fmt.Errorf("delete host: %w", err) } n, _ := res.RowsAffected() if n == 0 { return ErrNotFound } return nil } func normalizeMAC(m string) string { return strings.ToLower(strings.TrimSpace(m)) } func nullIfEmpty(s string) any { if s == "" { return nil } return s }