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 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 ). 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>
157 lines
5.9 KiB
Rust
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);
|
|
}
|