#![cfg(unix)]
use serde_json::Value;
use std::io::{Read, Write};
use std::os::unix::net::UnixListener;
use std::path::Path;
use std::process::Command;
use std::sync::{Arc, Mutex};
fn serve_daemon(socket: &Path, used_slots: u32, refused: &'static str) -> Arc<Mutex<Vec<String>>> {
let listener = UnixListener::bind(socket).expect("bind scripted daemon socket");
let requests = Arc::new(Mutex::new(Vec::new()));
let seen = Arc::clone(&requests);
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { return };
let mut request = String::new();
let _ = stream.read_to_string(&mut request);
let line = request.lines().next().unwrap_or_default().to_owned();
seen.lock().unwrap().push(line.clone());
let body = if line.starts_with("GET /status") {
status_body(used_slots)
} else if line.contains(&format!("/workers/{refused}/drain")) {
r#"{"status":"error","message":"unknown worker"}"#.to_owned()
} else if line.starts_with("POST /workers/") && line.ends_with("/drain") {
r#"{"status":"ok"}"#.to_owned()
} else {
r#"{"status":"error","message":"unexpected request"}"#.to_owned()
};
let _ = write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{body}"
);
}
});
requests
}
fn status_body(used_slots: u32) -> String {
let worker = |id: &str| {
serde_json::json!({
"id": id, "host": "127.0.0.1", "user": "u", "status": "draining",
"circuit_state": "closed", "used_slots": used_slots, "total_slots": 4,
"speed_score": 1.0, "last_error": null
})
};
serde_json::json!({
"daemon": {
"pid": 1, "uptime_secs": 1, "version": "test", "socket_path": "/dev/null",
"started_at": "2026-01-01T00:00:00Z", "workers_total": 2, "workers_healthy": 2,
"slots_total": 8, "slots_available": 8
},
"workers": [worker("w1"), worker("w2")],
"active_builds": [],
"recent_builds": [],
"issues": [],
"stats": {
"total_builds": 0, "success_count": 0, "failure_count": 0,
"remote_count": 0, "local_count": 0, "avg_duration_ms": 0
}
})
.to_string()
}
const TWO_WORKERS: &str = "[[workers]]\nid = \"w1\"\nhost = \"127.0.0.1\"\nuser = \"u\"\nidentity_file = \"~/.ssh/none\"\ntotal_slots = 4\n\n\
[[workers]]\nid = \"w2\"\nhost = \"127.0.0.2\"\nuser = \"u\"\nidentity_file = \"~/.ssh/none\"\ntotal_slots = 4\n";
fn fleet_drain(dir: &Path, socket: &Path, args: &[&str]) -> (Option<i32>, Value) {
fleet_drain_with(dir, socket, TWO_WORKERS, args)
}
fn fleet_drain_with(
dir: &Path,
socket: &Path,
workers_toml: &str,
args: &[&str],
) -> (Option<i32>, Value) {
let config = dir.join("config");
std::fs::create_dir_all(&config).unwrap();
std::fs::write(config.join("workers.toml"), workers_toml).unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_rch"))
.args(["--json", "fleet", "drain"])
.args(args)
.current_dir(dir)
.env_clear()
.env("PATH", std::env::var_os("PATH").unwrap_or_default())
.env("HOME", dir)
.env("XDG_CONFIG_HOME", dir.join("xdg"))
.env("XDG_DATA_HOME", dir.join("data"))
.env("XDG_STATE_HOME", dir.join("state"))
.env("XDG_RUNTIME_DIR", dir.join("run"))
.env("RCH_CONFIG_DIR", &config)
.env("RCH_SOCKET_PATH", socket)
.output()
.expect("run rch fleet drain");
let stdout = String::from_utf8_lossy(&output.stdout);
let json = serde_json::from_str(stdout.trim()).unwrap_or_else(|error| {
panic!(
"fleet drain printed no JSON ({error}): stdout={stdout} stderr={}",
String::from_utf8_lossy(&output.stderr)
)
});
(output.status.code(), json)
}
#[test]
fn drain_asks_the_daemon_and_confirms_the_worker_idle() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("rchd.sock");
let requests = serve_daemon(&socket, 0, "none");
let (code, json) = fleet_drain(dir.path(), &socket, &["w1", "--yes", "--timeout", "5"]);
assert_eq!(code, Some(0), "{json}");
assert_eq!(json["success"], true, "{json}");
assert_eq!(json["data"]["workers_drained"], serde_json::json!(["w1"]));
assert_eq!(json["data"]["idle_confirmed"], true, "{json}");
let requests = requests.lock().unwrap();
assert!(
requests.iter().any(|line| line == "POST /workers/w1/drain"),
"the daemon was never asked to drain w1: {requests:?}"
);
assert!(
!requests.iter().any(|line| line.contains("/workers/w2/")),
"an untargeted worker was touched: {requests:?}"
);
}
#[test]
fn drain_reports_a_worker_still_busy_at_the_timeout() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("rchd.sock");
let _requests = serve_daemon(&socket, 2, "none");
let (code, json) = fleet_drain(dir.path(), &socket, &["w1", "--yes", "--timeout", "0"]);
assert_eq!(code, Some(0), "{json}");
assert_eq!(json["data"]["idle_confirmed"], false, "{json}");
assert_eq!(
json["data"]["still_busy"],
serde_json::json!([{"worker_id": "w1", "used_slots": 2}]),
"{json}"
);
}
#[test]
fn a_refused_drain_fails_the_command() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("rchd.sock");
let _requests = serve_daemon(&socket, 0, "w2");
let (code, json) = fleet_drain(dir.path(), &socket, &["--all", "--yes", "--timeout", "5"]);
assert_eq!(code, Some(1), "a refused drain must not exit 0: {json}");
assert_eq!(json["success"], false, "{json}");
let rendered = json.to_string();
assert!(
rendered.contains("w2"),
"the refused worker is not named: {json}"
);
}
#[test]
fn drain_all_with_no_workers_configured_succeeds_without_touching_the_daemon() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("rchd.sock");
let requests = serve_daemon(&socket, 0, "none");
let (code, json) = fleet_drain_with(dir.path(), &socket, "", &["--all", "--yes"]);
assert_eq!(code, Some(0), "{json}");
assert_eq!(json["success"], true, "{json}");
let requests = requests.lock().unwrap();
assert!(
!requests.iter().any(|line| line.contains("/drain")),
"no worker is configured, yet a drain was sent: {requests:?}"
);
}