Files
drjones 3678b199d0
Some checks failed
Test / test (push) Has been cancelled
Initial commit: AetherForge Linux (forge-mesh) v0.1.0-dev
2026-07-04 09:31:23 +00:00

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)
}