Files
AetherForge/agent/miner/container_launcher.go
AetherForge 74c006a04b Close DV-01–04,07,09,13,14: unify operator-deck UX in server/web.
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.
2026-06-07 06:33:51 -07:00

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