Persist build extra_files for Build Manager history, print dashboard login on every start, add libp2p for Mesh P2P forge, defer WebSocket until login, and split devrun.bat from LAUNCH.bat with USB deck auto-detection.
346 lines
9.1 KiB
Go
346 lines
9.1 KiB
Go
package maintenance
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"errors"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"crypto-miner-server/internal/db"
|
|
"crypto-miner-server/internal/models"
|
|
)
|
|
|
|
func openTestDB(t *testing.T) *db.Database {
|
|
t.Helper()
|
|
d, err := db.New(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = d.Close() })
|
|
return d
|
|
}
|
|
|
|
func insertBuild(t *testing.T, d *db.Database, b *models.BuildRecord) {
|
|
t.Helper()
|
|
if err := d.InsertBuild(b); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func seedHashrateSample(t *testing.T, d *db.Database, agentID string, ts time.Time, hashrate float64) {
|
|
t.Helper()
|
|
_, err := d.Exec("INSERT INTO hashrate_samples (agent_id, hashrate, timestamp) VALUES (?, ?, ?)",
|
|
agentID, hashrate, ts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestStartRetentionJobs_NoOpWhenDisabled(t *testing.T) {
|
|
d := openTestDB(t)
|
|
t.Cleanup(StopRetentionJobs)
|
|
StartRetentionJobs(d, t.TempDir(), 0, 0)
|
|
// Disabled config must not start a goroutine that mutates data.
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
|
|
func TestStartRetentionJobs_RunsImmediately(t *testing.T) {
|
|
d := openTestDB(t)
|
|
seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)
|
|
|
|
t.Cleanup(StopRetentionJobs)
|
|
StartRetentionJobs(d, t.TempDir(), 24, 0)
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
n, err := d.PurgeHashrateSamplesBefore(time.Now().Add(-24 * time.Hour))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n == 0 {
|
|
return
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
t.Fatal("expected immediate retention pass to purge old hashrate samples")
|
|
}
|
|
|
|
func TestStartRetentionJobs_TickerInterval(t *testing.T) {
|
|
prev := retentionTickInterval
|
|
retentionTickInterval = 40 * time.Millisecond
|
|
t.Cleanup(func() { retentionTickInterval = prev })
|
|
|
|
d := openTestDB(t)
|
|
var passes int32
|
|
noopRetention := func(database *db.Database, dataDir string, statsHours, buildDays int) {
|
|
atomic.AddInt32(&passes, 1)
|
|
}
|
|
runRetentionFn = noopRetention
|
|
t.Cleanup(func() {
|
|
retentionTickInterval = prev
|
|
runRetentionFn = runRetention
|
|
StopRetentionJobs()
|
|
})
|
|
|
|
StartRetentionJobs(d, t.TempDir(), 1, 0)
|
|
|
|
deadline := time.Now().Add(250 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
if atomic.LoadInt32(&passes) >= 2 {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatalf("expected at least 2 retention passes (immediate + tick), got %d", passes)
|
|
}
|
|
|
|
func TestStopRetentionJobs_StopsBackgroundLoop(t *testing.T) {
|
|
prev := retentionTickInterval
|
|
retentionTickInterval = 40 * time.Millisecond
|
|
t.Cleanup(func() {
|
|
retentionTickInterval = prev
|
|
runRetentionFn = runRetention
|
|
StopRetentionJobs()
|
|
})
|
|
|
|
d := openTestDB(t)
|
|
var passes int32
|
|
runRetentionFn = func(database *db.Database, dataDir string, statsHours, buildDays int) {
|
|
atomic.AddInt32(&passes, 1)
|
|
}
|
|
|
|
StartRetentionJobs(d, t.TempDir(), 1, 0)
|
|
|
|
deadline := time.Now().Add(250 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
if atomic.LoadInt32(&passes) >= 2 {
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if atomic.LoadInt32(&passes) < 2 {
|
|
t.Fatalf("expected at least 2 passes before stop, got %d", passes)
|
|
}
|
|
|
|
before := atomic.LoadInt32(&passes)
|
|
StopRetentionJobs()
|
|
time.Sleep(120 * time.Millisecond)
|
|
if got := atomic.LoadInt32(&passes); got != before {
|
|
t.Fatalf("expected no retention passes after stop, before=%d after=%d", before, got)
|
|
}
|
|
}
|
|
|
|
func TestRunRetention_PurgesHashrateSamples(t *testing.T) {
|
|
d := openTestDB(t)
|
|
seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)
|
|
seedHashrateSample(t, d, "a1", time.Now(), 200)
|
|
|
|
runRetention(d, t.TempDir(), 24, 0)
|
|
|
|
n, err := d.PurgeHashrateSamplesBefore(time.Now().Add(-24 * time.Hour))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 0 {
|
|
t.Fatalf("expected old sample already purged, PurgeHashrateSamplesBefore returned %d", n)
|
|
}
|
|
|
|
var remaining int
|
|
if err := d.QueryRow("SELECT COUNT(*) FROM hashrate_samples").Scan(&remaining); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if remaining != 1 {
|
|
t.Fatalf("expected 1 recent sample left, got %d", remaining)
|
|
}
|
|
}
|
|
|
|
func TestRunRetention_SkipsStatsWhenZero(t *testing.T) {
|
|
d := openTestDB(t)
|
|
seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)
|
|
|
|
runRetention(d, t.TempDir(), 0, 0)
|
|
|
|
var remaining int
|
|
if err := d.QueryRow("SELECT COUNT(*) FROM hashrate_samples").Scan(&remaining); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if remaining != 1 {
|
|
t.Fatalf("expected sample retained when statsHours=0, got %d", remaining)
|
|
}
|
|
}
|
|
|
|
func TestRunRetention_PurgesBuildWithFilePath(t *testing.T) {
|
|
d := openTestDB(t)
|
|
dataDir := t.TempDir()
|
|
artifactDir := filepath.Join(dataDir, "artifacts", "old-build")
|
|
if err := os.MkdirAll(artifactDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
artifactFile := filepath.Join(artifactDir, "agent.exe")
|
|
if err := os.WriteFile(artifactFile, []byte("binary"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
insertBuild(t, d, &models.BuildRecord{
|
|
ID: "old-build",
|
|
WorkerName: "worker-1",
|
|
ServerURL: "http://localhost",
|
|
Wallet: "wallet",
|
|
FilePath: artifactFile,
|
|
CreatedAt: time.Now().Add(-48 * time.Hour),
|
|
})
|
|
insertBuild(t, d, &models.BuildRecord{
|
|
ID: "new-build",
|
|
WorkerName: "worker-2",
|
|
ServerURL: "http://localhost",
|
|
Wallet: "wallet",
|
|
FilePath: filepath.Join(dataDir, "artifacts", "new-build", "agent.exe"),
|
|
CreatedAt: time.Now(),
|
|
})
|
|
|
|
runRetention(d, dataDir, 0, 1)
|
|
|
|
if _, err := os.Stat(artifactDir); !os.IsNotExist(err) {
|
|
t.Fatalf("expected artifact dir removed, stat err=%v", err)
|
|
}
|
|
_, err := d.GetBuild("old-build")
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
t.Fatalf("expected old build deleted from db, got %v", err)
|
|
}
|
|
if _, err := d.GetBuild("new-build"); err != nil {
|
|
t.Fatalf("expected new build retained: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunRetention_PurgesBuildWithoutFilePath(t *testing.T) {
|
|
d := openTestDB(t)
|
|
dataDir := t.TempDir()
|
|
buildDir := filepath.Join(dataDir, "builds", "legacy-build")
|
|
if err := os.MkdirAll(buildDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(buildDir, "bundle.zip"), []byte("zip"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
insertBuild(t, d, &models.BuildRecord{
|
|
ID: "legacy-build",
|
|
WorkerName: "worker-legacy",
|
|
ServerURL: "http://localhost",
|
|
Wallet: "wallet",
|
|
CreatedAt: time.Now().Add(-72 * time.Hour),
|
|
})
|
|
|
|
runRetention(d, dataDir, 0, 1)
|
|
|
|
if _, err := os.Stat(buildDir); !os.IsNotExist(err) {
|
|
t.Fatalf("expected fallback build dir removed, stat err=%v", err)
|
|
}
|
|
_, err := d.GetBuild("legacy-build")
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
t.Fatalf("expected legacy build deleted, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunRetention_SkipsBuildsWhenZero(t *testing.T) {
|
|
d := openTestDB(t)
|
|
dataDir := t.TempDir()
|
|
buildDir := filepath.Join(dataDir, "builds", "keep-me")
|
|
if err := os.MkdirAll(buildDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
insertBuild(t, d, &models.BuildRecord{
|
|
ID: "keep-me",
|
|
WorkerName: "worker",
|
|
ServerURL: "http://localhost",
|
|
Wallet: "wallet",
|
|
CreatedAt: time.Now().Add(-72 * time.Hour),
|
|
})
|
|
|
|
runRetention(d, dataDir, 0, 0)
|
|
|
|
if _, err := os.Stat(buildDir); err != nil {
|
|
t.Fatalf("expected build dir kept when buildDays=0: %v", err)
|
|
}
|
|
if _, err := d.GetBuild("keep-me"); err != nil {
|
|
t.Fatalf("expected build record kept: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunRetention_HashratePurgeErrorLogged(t *testing.T) {
|
|
d := openTestDB(t)
|
|
_ = d.Close()
|
|
|
|
var buf bytes.Buffer
|
|
prev := log.Writer()
|
|
log.SetOutput(&buf)
|
|
t.Cleanup(func() { log.SetOutput(prev) })
|
|
|
|
runRetention(d, t.TempDir(), 24, 0)
|
|
|
|
if !strings.Contains(buf.String(), "[Retention] hashrate purge failed:") {
|
|
t.Fatalf("expected hashrate purge error log, got: %q", buf.String())
|
|
}
|
|
}
|
|
|
|
func TestRunRetention_BuildListErrorLogged(t *testing.T) {
|
|
d := openTestDB(t)
|
|
_ = d.Close()
|
|
|
|
var buf bytes.Buffer
|
|
prev := log.Writer()
|
|
log.SetOutput(&buf)
|
|
t.Cleanup(func() { log.SetOutput(prev) })
|
|
|
|
runRetention(d, t.TempDir(), 0, 7)
|
|
|
|
if !strings.Contains(buf.String(), "[Retention] build list failed:") {
|
|
t.Fatalf("expected build list error log, got: %q", buf.String())
|
|
}
|
|
}
|
|
|
|
func TestRunRetention_StatsAndBuildsTogether(t *testing.T) {
|
|
d := openTestDB(t)
|
|
dataDir := t.TempDir()
|
|
seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)
|
|
buildDir := filepath.Join(dataDir, "builds", "combo-old")
|
|
if err := os.MkdirAll(buildDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
insertBuild(t, d, &models.BuildRecord{
|
|
ID: "combo-old",
|
|
WorkerName: "worker",
|
|
ServerURL: "http://localhost",
|
|
Wallet: "wallet",
|
|
CreatedAt: time.Now().Add(-48 * time.Hour),
|
|
})
|
|
|
|
runRetention(d, dataDir, 24, 1)
|
|
|
|
var samples int
|
|
if err := d.QueryRow("SELECT COUNT(*) FROM hashrate_samples").Scan(&samples); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if samples != 0 {
|
|
t.Fatalf("expected stats purged, got %d samples", samples)
|
|
}
|
|
if _, err := os.Stat(buildDir); !os.IsNotExist(err) {
|
|
t.Fatalf("expected build dir removed, stat err=%v", err)
|
|
}
|
|
_, err := d.GetBuild("combo-old")
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
t.Fatalf("expected build removed, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRetentionTickIntervalDefault(t *testing.T) {
|
|
if retentionTickInterval != 6*time.Hour {
|
|
t.Fatalf("expected default tick interval 6h, got %v", retentionTickInterval)
|
|
}
|
|
}
|