Standardize neon cyan on #00e8f5, wire SacredPageHeader across Builds/Path Tracer/LOTL, dedupe Pages.css, align Path Tracer chrome, drop dead wealth-deck CSS, add prefers-color-scheme shell + HelpTip, sidebar version from package.json build inject, and Vitest CSS regression guards.
305 lines
8.0 KiB
Go
305 lines
8.0 KiB
Go
package miner
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
|
|
"crypto-miner-agent/config"
|
|
)
|
|
|
|
const defaultMinerImage = "aetherforge/agent-worker:latest"
|
|
|
|
// ContainerStatsPath is where the worker writes hashrate for host relay (docker exec cat).
|
|
const ContainerStatsPath = "/tmp/miner-stats.json"
|
|
|
|
// WorkerImageBuildHint is shown in Forge when container/auto execution is selected.
|
|
const WorkerImageBuildHint = "Build worker image: docker build -f docker/Dockerfile.agent -t aetherforge/agent-worker:latest . (override with AETHERFORGE_MINER_IMAGE on host)"
|
|
|
|
// DefaultWorkerImage returns the OCI reference used when AETHERFORGE_MINER_IMAGE is unset.
|
|
func DefaultWorkerImage() string {
|
|
return defaultMinerImage
|
|
}
|
|
|
|
// containerExecCommand is exec.Command; tests override via SetContainerExecCommand.
|
|
var containerExecCommand = exec.Command
|
|
|
|
// SetContainerExecCommand restores the default when fn is nil.
|
|
func SetContainerExecCommand(fn func(name string, args ...string) *exec.Cmd) {
|
|
if fn == nil {
|
|
containerExecCommand = exec.Command
|
|
return
|
|
}
|
|
containerExecCommand = fn
|
|
}
|
|
|
|
// ContainerLauncher supervises an OCI workload that runs CPU mining isolated from the host agent.
|
|
type ContainerLauncher struct {
|
|
cfg config.RuntimeConfig
|
|
runtime ContainerRuntimeInfo
|
|
image string
|
|
name string
|
|
tarPath string // non-empty → docker load from tar, never registry pull
|
|
readOnly bool // docker_load tier uses --read-only rootfs
|
|
|
|
mu sync.Mutex
|
|
running bool
|
|
cmd *exec.Cmd
|
|
}
|
|
|
|
// NewContainerLauncher builds a launcher when a container runtime is available.
|
|
func NewContainerLauncher(cfg config.RuntimeConfig, runtime ContainerRuntimeInfo) (*ContainerLauncher, error) {
|
|
return newContainerLauncher(cfg, runtime, "", false)
|
|
}
|
|
|
|
// NewContainerLauncherFromTar builds a docker_load tier launcher (local tar, no registry pull).
|
|
func NewContainerLauncherFromTar(cfg config.RuntimeConfig, runtime ContainerRuntimeInfo, tarPath string) (*ContainerLauncher, error) {
|
|
tarPath = strings.TrimSpace(tarPath)
|
|
if tarPath == "" {
|
|
return nil, ErrNoImageTar
|
|
}
|
|
return newContainerLauncher(cfg, runtime, tarPath, true)
|
|
}
|
|
|
|
func newContainerLauncher(cfg config.RuntimeConfig, runtime ContainerRuntimeInfo, tarPath string, readOnly bool) (*ContainerLauncher, error) {
|
|
if !runtime.Available || runtime.CLI == "" {
|
|
return nil, fmt.Errorf("no container runtime (docker/podman not in PATH)")
|
|
}
|
|
image := strings.TrimSpace(os.Getenv("AETHERFORGE_MINER_IMAGE"))
|
|
if image == "" {
|
|
image = defaultMinerImage
|
|
}
|
|
name := containerName(cfg)
|
|
return &ContainerLauncher{
|
|
cfg: cfg,
|
|
runtime: runtime,
|
|
image: image,
|
|
name: name,
|
|
tarPath: tarPath,
|
|
readOnly: readOnly,
|
|
}, nil
|
|
}
|
|
|
|
func containerName(cfg config.RuntimeConfig) string {
|
|
suffix := strings.TrimSpace(cfg.BuildID)
|
|
if suffix == "" {
|
|
suffix = "worker"
|
|
}
|
|
suffix = strings.Map(func(ch rune) rune {
|
|
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' {
|
|
return ch
|
|
}
|
|
return '-'
|
|
}, suffix)
|
|
return "aetherforge-miner-" + suffix
|
|
}
|
|
|
|
// Start launches the miner container (idempotent while already running).
|
|
func (l *ContainerLauncher) Start() error {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
if l.running {
|
|
return nil
|
|
}
|
|
|
|
if l.tarPath != "" {
|
|
loaded, err := l.loadImageFromTar()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if loaded != "" {
|
|
l.image = loaded
|
|
}
|
|
}
|
|
|
|
args := l.buildRunArgs()
|
|
cmd := containerExecCommand(l.runtime.CLI, args...)
|
|
cmd.Stdout = nil
|
|
cmd.Stderr = nil
|
|
if err := cmd.Start(); err != nil {
|
|
return fmt.Errorf("%s run failed: %w", l.runtime.CLI, err)
|
|
}
|
|
l.cmd = cmd
|
|
l.running = true
|
|
mode := "registry"
|
|
if l.tarPath != "" {
|
|
mode = "docker_load"
|
|
}
|
|
log.Printf("[container] started %s (%s) image=%s mode=%s wallet=%s", l.name, l.runtime.CLI, l.image, mode, l.cfg.Wallet)
|
|
go l.waitExit()
|
|
return nil
|
|
}
|
|
|
|
func (l *ContainerLauncher) waitExit() {
|
|
if l.cmd == nil {
|
|
return
|
|
}
|
|
err := l.cmd.Wait()
|
|
l.mu.Lock()
|
|
l.running = false
|
|
l.cmd = nil
|
|
l.mu.Unlock()
|
|
if err != nil {
|
|
log.Printf("[container] miner container exited: %v — host will fall back to in-process mining if configured", err)
|
|
} else {
|
|
log.Printf("[container] miner container stopped")
|
|
}
|
|
}
|
|
|
|
// Stop removes the running container.
|
|
func (l *ContainerLauncher) Stop() {
|
|
l.mu.Lock()
|
|
running := l.running
|
|
l.mu.Unlock()
|
|
if !running {
|
|
return
|
|
}
|
|
_ = containerExecCommand(l.runtime.CLI, "rm", "-f", l.name).Run()
|
|
l.mu.Lock()
|
|
if l.cmd != nil && l.cmd.Process != nil {
|
|
_ = l.cmd.Process.Kill()
|
|
}
|
|
l.running = false
|
|
l.cmd = nil
|
|
l.mu.Unlock()
|
|
}
|
|
|
|
// Image returns the OCI image reference used for the miner workload.
|
|
func (l *ContainerLauncher) Image() string {
|
|
return l.image
|
|
}
|
|
|
|
// Running reports whether the launcher believes the container is active.
|
|
func (l *ContainerLauncher) Running() bool {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
return l.running
|
|
}
|
|
|
|
// ProbeHashrate reads container-side hashrate from ContainerStatsPath via docker/podman exec.
|
|
func (l *ContainerLauncher) ProbeHashrate() float64 {
|
|
l.mu.Lock()
|
|
if !l.running || l.runtime.CLI == "" {
|
|
l.mu.Unlock()
|
|
return 0
|
|
}
|
|
name := l.name
|
|
cli := l.runtime.CLI
|
|
l.mu.Unlock()
|
|
|
|
cmd := containerExecCommand(cli, "exec", name, "cat", ContainerStatsPath)
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return ParseContainerStatsJSON(out)
|
|
}
|
|
|
|
// ParseContainerStatsJSON extracts hashrate_hps from worker stats JSON (tests + exec probe).
|
|
func ParseContainerStatsJSON(data []byte) float64 {
|
|
var snap struct {
|
|
HashrateHPS float64 `json:"hashrate_hps"`
|
|
}
|
|
if err := json.Unmarshal(bytes.TrimSpace(data), &snap); err != nil {
|
|
return 0
|
|
}
|
|
if snap.HashrateHPS < 0 {
|
|
return 0
|
|
}
|
|
return snap.HashrateHPS
|
|
}
|
|
|
|
func (l *ContainerLauncher) loadImageFromTar() (string, error) {
|
|
cmd := containerExecCommand(l.runtime.CLI, "load", "-i", l.tarPath)
|
|
var buf bytes.Buffer
|
|
cmd.Stdout = &buf
|
|
cmd.Stderr = &buf
|
|
if err := cmd.Run(); err != nil {
|
|
return "", fmt.Errorf("%s load failed: %w (%s)", l.runtime.CLI, err, strings.TrimSpace(buf.String()))
|
|
}
|
|
for _, line := range strings.Split(buf.String(), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if after, ok := strings.CutPrefix(line, "Loaded image:"); ok {
|
|
return strings.TrimSpace(after), nil
|
|
}
|
|
if after, ok := strings.CutPrefix(line, "Loaded image ID:"); ok {
|
|
return strings.TrimSpace(after), nil
|
|
}
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
func (l *ContainerLauncher) buildRunArgs() []string {
|
|
args := []string{
|
|
"run", "--rm", "-d",
|
|
"--name", l.name,
|
|
}
|
|
|
|
if l.tarPath != "" {
|
|
args = append(args, "--pull=never")
|
|
}
|
|
|
|
if l.readOnly {
|
|
args = append(args, "--read-only", "--tmpfs", "/tmp")
|
|
}
|
|
|
|
if runtime.GOOS == "linux" {
|
|
args = append(args, "--network", "host")
|
|
}
|
|
if l.cfg.GPUEnabled {
|
|
args = append(args, "--gpus", "all")
|
|
}
|
|
|
|
env := l.containerEnv()
|
|
for _, e := range env {
|
|
args = append(args, "-e", e)
|
|
}
|
|
|
|
args = append(args, l.image)
|
|
return args
|
|
}
|
|
|
|
func (l *ContainerLauncher) containerEnv() []string {
|
|
threads := l.cfg.EffectiveThreads()
|
|
pairs := map[string]string{
|
|
"AETHERFORGE_SERVER_URL": l.cfg.ServerURL,
|
|
"AETHERFORGE_WALLET": l.cfg.Wallet,
|
|
"AETHERFORGE_WORKER": l.cfg.WorkerName,
|
|
"AETHERFORGE_POOL_HOST": l.cfg.PoolHost,
|
|
"AETHERFORGE_POOL_PORT": fmt.Sprintf("%d", l.cfg.PoolPort),
|
|
"AETHERFORGE_POOL_TLS": boolEnv(l.cfg.PoolTLS),
|
|
"AETHERFORGE_POOL_PASS": l.cfg.PoolPass,
|
|
"AETHERFORGE_THREADS": fmt.Sprintf("%d", threads),
|
|
"AETHERFORGE_MINER_EXECUTION": ExecutionInProcess,
|
|
"AETHERFORGE_FLEET_SECRET": l.cfg.FleetSecret,
|
|
"MINER_LOG_FILE": "/tmp/miner.log",
|
|
"MINER_STATS_FILE": ContainerStatsPath,
|
|
}
|
|
if l.cfg.RVNWallet != "" {
|
|
pairs["AETHERFORGE_RVN_WALLET"] = l.cfg.RVNWallet
|
|
pairs["AETHERFORGE_RVN_POOL_HOST"] = l.cfg.RVNPoolHost
|
|
pairs["AETHERFORGE_RVN_POOL_PORT"] = fmt.Sprintf("%d", l.cfg.RVNPoolPort)
|
|
pairs["AETHERFORGE_GPU_ENABLED"] = boolEnv(l.cfg.GPUEnabled)
|
|
}
|
|
out := make([]string, 0, len(pairs))
|
|
for k, v := range pairs {
|
|
if v != "" {
|
|
out = append(out, k+"="+v)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func boolEnv(v bool) string {
|
|
if v {
|
|
return "1"
|
|
}
|
|
return "0"
|
|
}
|