//! 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, service: Option) -> 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); }