220 lines
5.9 KiB
Go
220 lines
5.9 KiB
Go
package fleet
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"forge-mesh/internal/api/types"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Store persists fleet host and mining profile state.
|
|
type Store struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func NewStore(db *sql.DB) *Store {
|
|
return &Store{db: db}
|
|
}
|
|
|
|
func (s *Store) UpsertHeartbeat(hb types.HeartbeatPayload) (*types.Host, error) {
|
|
hostID := hb.HostID
|
|
if hostID == "" {
|
|
hostID = uuid.NewString()
|
|
}
|
|
|
|
hps := hb.EffectiveHashrate()
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
|
|
_, err := s.db.Exec(`
|
|
INSERT INTO hosts (
|
|
id, hostname, fingerprint, status, hashrate, hashrate_hps,
|
|
current_tier, tier_type, tier_state, last_seen_at, updated_at
|
|
)
|
|
VALUES (?, ?, ?, 'online', ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
hostname = excluded.hostname,
|
|
fingerprint = COALESCE(NULLIF(excluded.fingerprint, ''), hosts.fingerprint),
|
|
status = 'online',
|
|
hashrate = excluded.hashrate,
|
|
hashrate_hps = excluded.hashrate_hps,
|
|
current_tier = excluded.current_tier,
|
|
tier_type = excluded.tier_type,
|
|
tier_state = excluded.tier_state,
|
|
last_seen_at = excluded.last_seen_at,
|
|
updated_at = excluded.updated_at
|
|
`, hostID, hb.Hostname, hb.Fingerprint, hps, hps,
|
|
hb.CurrentTier, hb.TierType, hb.TierState, now, now)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("upsert host: %w", err)
|
|
}
|
|
|
|
return s.GetHost(hostID)
|
|
}
|
|
|
|
func (s *Store) GetHost(id string) (*types.Host, error) {
|
|
row := s.db.QueryRow(`
|
|
SELECT id, hostname, fingerprint, phenotype, status, hashrate, hashrate_hps,
|
|
current_tier, tier_type, tier_state, clearance_level,
|
|
mining_profile_id, last_seen_at, created_at, updated_at
|
|
FROM hosts WHERE id = ?
|
|
`, id)
|
|
|
|
return scanHost(row)
|
|
}
|
|
|
|
func (s *Store) ListHosts() ([]types.Host, error) {
|
|
rows, err := s.db.Query(`
|
|
SELECT id, hostname, fingerprint, phenotype, status, hashrate, hashrate_hps,
|
|
current_tier, tier_type, tier_state, clearance_level,
|
|
mining_profile_id, last_seen_at, created_at, updated_at
|
|
FROM hosts ORDER BY updated_at DESC
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list hosts: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var hosts []types.Host
|
|
for rows.Next() {
|
|
h, err := scanHost(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
hosts = append(hosts, *h)
|
|
}
|
|
return hosts, rows.Err()
|
|
}
|
|
|
|
func scanHost(scanner interface {
|
|
Scan(dest ...any) error
|
|
}) (*types.Host, error) {
|
|
var h types.Host
|
|
var fp, pheno, tierType, tierState, mpID, lastSeen sql.NullString
|
|
var createdAt, updatedAt string
|
|
|
|
err := scanner.Scan(
|
|
&h.ID, &h.Hostname, &fp, &pheno, &h.Status, &h.Hashrate, &h.HashrateHps,
|
|
&h.CurrentTier, &tierType, &tierState, &h.ClearanceLevel,
|
|
&mpID, &lastSeen, &createdAt, &updatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan host: %w", err)
|
|
}
|
|
|
|
if fp.Valid {
|
|
h.Fingerprint = fp.String
|
|
}
|
|
if pheno.Valid {
|
|
h.Phenotype = pheno.String
|
|
}
|
|
if tierType.Valid {
|
|
h.TierType = tierType.String
|
|
}
|
|
if tierState.Valid {
|
|
h.TierState = tierState.String
|
|
}
|
|
if mpID.Valid {
|
|
h.MiningProfileID = &mpID.String
|
|
}
|
|
if lastSeen.Valid {
|
|
t, _ := time.Parse(time.RFC3339, lastSeen.String)
|
|
h.LastSeenAt = &t
|
|
}
|
|
h.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
|
h.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
|
return &h, nil
|
|
}
|
|
|
|
func (s *Store) MarkOffline(id string) error {
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
_, err := s.db.Exec(`UPDATE hosts SET status = 'offline', updated_at = ? WHERE id = ?`, now, id)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) SaveMiningProfile(ctx context.Context, profile *types.MiningProfile) error {
|
|
tiersJSON, err := json.Marshal(profile.Tiers)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal tiers: %w", err)
|
|
}
|
|
|
|
policy := 0
|
|
if profile.PolicyFromServer {
|
|
policy = 1
|
|
}
|
|
|
|
if profile.CreatedAt.IsZero() {
|
|
profile.CreatedAt = time.Now().UTC()
|
|
}
|
|
profile.UpdatedAt = time.Now().UTC()
|
|
|
|
_, err = s.db.ExecContext(ctx, `
|
|
INSERT INTO mining_profiles (id, name, wallet_address, tiers_json, policy_from_server, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
name = excluded.name,
|
|
wallet_address = excluded.wallet_address,
|
|
tiers_json = excluded.tiers_json,
|
|
policy_from_server = excluded.policy_from_server,
|
|
updated_at = excluded.updated_at
|
|
`, profile.ID, profile.Name, profile.WalletAddress, string(tiersJSON), policy,
|
|
profile.CreatedAt.UTC().Format(time.RFC3339), profile.UpdatedAt.UTC().Format(time.RFC3339))
|
|
return err
|
|
}
|
|
|
|
func (s *Store) GetMiningProfile(ctx context.Context, id string) (*types.MiningProfile, error) {
|
|
row := s.db.QueryRowContext(ctx, `
|
|
SELECT id, name, wallet_address, tiers_json, policy_from_server, created_at, updated_at
|
|
FROM mining_profiles WHERE id = ?
|
|
`, id)
|
|
|
|
var p types.MiningProfile
|
|
var tiersJSON, created, updated sql.NullString
|
|
var policy int
|
|
|
|
if err := row.Scan(&p.ID, &p.Name, &p.WalletAddress, &tiersJSON, &policy, &created, &updated); err != nil {
|
|
return nil, fmt.Errorf("get mining profile: %w", err)
|
|
}
|
|
if tiersJSON.Valid {
|
|
_ = json.Unmarshal([]byte(tiersJSON.String), &p.Tiers)
|
|
}
|
|
p.PolicyFromServer = policy == 1
|
|
if created.Valid {
|
|
p.CreatedAt, _ = time.Parse(time.RFC3339, created.String)
|
|
}
|
|
if updated.Valid {
|
|
p.UpdatedAt, _ = time.Parse(time.RFC3339, updated.String)
|
|
}
|
|
return &p, nil
|
|
}
|
|
|
|
func (s *Store) AssignMiningProfile(ctx context.Context, hostID, profileID string) error {
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
res, err := s.db.ExecContext(ctx, `
|
|
UPDATE hosts SET mining_profile_id = ?, updated_at = ? WHERE id = ?
|
|
`, profileID, now, hostID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return sql.ErrNoRows
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) GetHostMiningProfile(ctx context.Context, hostID string) (*types.MiningProfile, error) {
|
|
host, err := s.GetHost(hostID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if host.MiningProfileID == nil || *host.MiningProfileID == "" {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
return s.GetMiningProfile(ctx, *host.MiningProfileID)
|
|
}
|