Files
Vantz Stockwell d13f2cb8b1
Some checks failed
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Failing after 35s
CI / integration (push) Has been skipped
Build Host Agent (Rust) / build (push) Successful in 1m45s
feat(host-agent): Phase 2 — Dune docker-compose adapter via Supervisor trait
Introduce a Supervisor trait (async-trait) so the agent manages games with
different models behind one wire contract. ProcessSupervisor (spawned process:
rust/conan/soulmask) and the new DockerComposeSupervisor (dune) both impl it;
Agent.supervisors is now HashMap<String, Arc<dyn Supervisor>> and instancecmd
dispatch is game-agnostic — start/stop/restart/status identical across games,
selected by a per-game factory in main. InstanceState moved to the shared
supervisor module.

DockerComposeSupervisor drives docker-compose up-d / stop / restart against
the instance's compose project, with -f/-p/single-service support and a
configurable compose binary. New [instance.docker_compose] config block.
First cut = lifecycle + cached state; container crash-detection + restart
adoption deferred to Phase 3b (reconcilable with a compose ps probe).

Trait choice (dyn over enum) per Commander: scales to future planes (kubectl,
AMP/podman, SSH) as new struct+impl, no central match.

56 tests green (6 new docker-compose mock-binary tests + 5 refactored process
tests), zero warnings. Live verification pending a real Dune stack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 21:33:00 -04:00

157 lines
5.9 KiB
Rust

//! DockerComposeSupervisor tests. A fake `docker` script records the exact
//! arguments it was invoked with and returns a controllable exit code, so we
//! assert the compose invocations + state transitions with no real Docker
//! daemon — the same mock-the-external-binary approach the steamcmd tests use.
#![cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use corrosion_host_agent::config::InstanceConfig;
use corrosion_host_agent::docker_compose::{DockerComposeConfig, DockerComposeSupervisor};
use corrosion_host_agent::supervisor::{InstanceState, Supervisor};
/// Write a fake `docker` executable that appends its args (space-joined) to
/// `args_log` and exits with the integer in `exit_file` (0 if absent).
fn fake_docker(dir: &Path, args_log: &Path, exit_file: &Path) -> PathBuf {
let script = dir.join("fakedocker");
let body = format!(
"#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nexit \"$(cat '{}' 2>/dev/null || echo 0)\"\n",
args_log.display(),
exit_file.display(),
);
std::fs::write(&script, body).unwrap();
let mut perms = std::fs::metadata(&script).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&script, perms).unwrap();
script
}
fn dune_instance(command: Vec<String>, service: Option<String>) -> InstanceConfig {
InstanceConfig {
id: "dune-main".to_string(),
game: "dune".to_string(),
root: PathBuf::from("/tmp"),
label: None,
executable: None,
args: vec![],
working_dir: None,
rcon: None,
steamcmd: None,
docker_compose: Some(DockerComposeConfig {
file: Some(PathBuf::from("docker-compose.yml")),
project: Some("duneproj".to_string()),
service,
command: Some(command),
}),
}
}
#[tokio::test]
async fn start_runs_compose_up_detached_and_sets_running() {
let dir = tempfile::tempdir().unwrap();
let args_log = dir.path().join("args.log");
let exit_file = dir.path().join("exit");
let docker = fake_docker(dir.path(), &args_log, &exit_file);
let sup = DockerComposeSupervisor::new(&dune_instance(
vec![docker.to_string_lossy().into_owned()],
None,
));
assert_eq!(sup.state(), InstanceState::Stopped);
sup.clone().start().await.expect("compose up should succeed");
assert_eq!(sup.state(), InstanceState::Running);
let logged = std::fs::read_to_string(&args_log).unwrap();
assert!(logged.contains("up -d"), "expected `up -d`; got: {logged}");
assert!(logged.contains("-p duneproj"), "expected project flag; got: {logged}");
assert!(logged.contains("-f docker-compose.yml"), "expected file flag; got: {logged}");
}
#[tokio::test]
async fn stop_runs_compose_stop_and_sets_stopped() {
let dir = tempfile::tempdir().unwrap();
let args_log = dir.path().join("args.log");
let exit_file = dir.path().join("exit");
let docker = fake_docker(dir.path(), &args_log, &exit_file);
let sup = DockerComposeSupervisor::new(&dune_instance(
vec![docker.to_string_lossy().into_owned()],
None,
));
sup.clone().start().await.expect("up");
sup.clone().stop().await.expect("compose stop should succeed");
assert_eq!(sup.state(), InstanceState::Stopped);
assert_eq!(sup.uptime_seconds().await, 0);
let logged = std::fs::read_to_string(&args_log).unwrap();
assert!(logged.lines().any(|l| l.contains("stop")), "expected a `stop` call; got: {logged}");
}
#[tokio::test]
async fn restart_runs_compose_restart() {
let dir = tempfile::tempdir().unwrap();
let args_log = dir.path().join("args.log");
let exit_file = dir.path().join("exit");
let docker = fake_docker(dir.path(), &args_log, &exit_file);
let sup = DockerComposeSupervisor::new(&dune_instance(
vec![docker.to_string_lossy().into_owned()],
None,
));
sup.clone().restart().await.expect("compose restart should succeed");
assert_eq!(sup.state(), InstanceState::Running);
let logged = std::fs::read_to_string(&args_log).unwrap();
assert!(logged.contains("restart"), "expected `restart`; got: {logged}");
}
#[tokio::test]
async fn single_service_is_targeted() {
let dir = tempfile::tempdir().unwrap();
let args_log = dir.path().join("args.log");
let exit_file = dir.path().join("exit");
let docker = fake_docker(dir.path(), &args_log, &exit_file);
let sup = DockerComposeSupervisor::new(&dune_instance(
vec![docker.to_string_lossy().into_owned()],
Some("gameserver".to_string()),
));
sup.clone().start().await.expect("up");
let logged = std::fs::read_to_string(&args_log).unwrap();
assert!(
logged.contains("up -d gameserver"),
"service must be appended after `up -d`; got: {logged}"
);
}
#[tokio::test]
async fn compose_failure_errors_and_reverts_state() {
let dir = tempfile::tempdir().unwrap();
let args_log = dir.path().join("args.log");
let exit_file = dir.path().join("exit");
std::fs::write(&exit_file, "1").unwrap(); // make the fake docker fail
let docker = fake_docker(dir.path(), &args_log, &exit_file);
let sup = DockerComposeSupervisor::new(&dune_instance(
vec![docker.to_string_lossy().into_owned()],
None,
));
let err = sup.clone().start().await.expect_err("nonzero compose exit must fail");
assert!(err.to_string().contains("compose up failed"), "got: {err}");
assert_eq!(sup.state(), InstanceState::Stopped, "failed start must revert to Stopped");
}
#[tokio::test]
async fn missing_docker_binary_errors_cleanly() {
let sup = DockerComposeSupervisor::new(&dune_instance(
vec!["/nonexistent/docker-xyz".to_string()],
None,
));
let err = sup.clone().start().await.expect_err("missing docker must fail");
assert!(err.to_string().contains("docker"), "error should mention docker: {err}");
assert_eq!(sup.state(), InstanceState::Stopped);
}