259 lines
6.6 KiB
Go
259 lines
6.6 KiB
Go
package miner
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
|
|
"crypto-miner-agent/config"
|
|
)
|
|
|
|
const defaultMinerImage = "aetherforge/agent-worker:latest"
|
|
|
|
// 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
|
|
}
|
|
|
|
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",
|
|
}
|
|
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"
|
|
}
|