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