#![cfg(all(unix, feature = "docker"))]
mod mock_socket;
use std::io::Write;
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use foundation_core::valtron::valtron_test;
use foundation_deployment_docker::client::containers_lifecycle::ContainerLogsOptions;
use foundation_deployment_docker::{DockerClient, DockerError, DockerLimits};
use foundation_deployment_docker::generated::top::ContainerTopResponse;
fn temp_socket_path(tag: &str) -> PathBuf {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let path = std::env::temp_dir().join(format!("ewe_docker_lc_{tag}_{pid}_{nanos}.sock"));
let _ = std::fs::remove_file(&path);
path
}
fn route(method: &str, path: &str) -> (u16, String) {
let path_no_query = path.split('?').next().unwrap_or(path);
let p = path_no_query.split_once("/v1.").map_or(path_no_query, |(_, rest)| {
rest.split_once('/').map_or(rest, |(_, r)| r)
});
match (method, p) {
("GET", "/version") => (200, r#"{"ApiVersion":"1.53","MinAPIVersion":"1.24"}"#.to_string()),
("POST", p) if p.starts_with("containers/") && p.ends_with("/restart") => {
(204, String::new())
}
("POST", p) if p.starts_with("containers/") && p.contains("/rename") => {
(204, String::new())
}
("GET", p) if p.starts_with("containers/") && p.ends_with("/logs") => {
(200, "\u{1}\0\0\0\0\0\0\u{5}hello".to_string())
}
("GET", p) if p.starts_with("containers/") && p.ends_with("/top") => {
let body = r#"{"Processes":[["root","1","0.0","0.0","123456","?","Ss","12:34","sleep 30"]],"Titles":["USER","PID","%CPU","%MEM","VSZ","RSS","TTY","STAT","START","COMMAND"]}"#;
(200, body.to_string())
}
("GET", "containers/json") => {
let body = r#"[{"Id":"abc123","Names":["/test_container"],"Image":"alpine:latest","ImageID":"sha256:abc","Command":"sleep 30","Created":1234567890,"Ports":[],"Labels":{},"State":"running","Status":"Up 1 hour","HostConfig":{"NetworkMode":"default"},"NetworkSettings":{"Networks":{"bridge":{"IPAddress":"172.17.0.2"}}},"Mounts":[]}]"#;
(200, body.to_string())
}
_ => (404, format!("{{\"message\":\"no route for {method} {p}\"}}")),
}
}
fn mock_daemon(socket_path: PathBuf, stop: Arc<AtomicBool>) {
mock_socket::serve(&socket_path, stop, move |mut stream| {
let Some(req) = mock_socket::read_request(&mut stream) else {
return;
};
let (method, path) = (req.method.as_str(), req.target.as_str());
let (status, body) = route(method, path);
let reason = if status == 204 { "No Content" } else { "OK" };
let response = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
});
}
#[valtron_test]
async fn docker_restart_container() {
let socket = temp_socket_path("restart");
let stop = Arc::new(AtomicBool::new(false));
mock_daemon(socket.clone(), stop.clone());
let client = DockerClient::connect_unix(&socket);
client
.restart_container("abc123", Some("SIGHUP"), Some(10))
.await
.expect("restart_container should succeed");
mock_socket::stop(&socket, &stop);
}
#[valtron_test]
async fn docker_restart_container_defaults() {
let socket = temp_socket_path("restart_def");
let stop = Arc::new(AtomicBool::new(false));
mock_daemon(socket.clone(), stop.clone());
let client = DockerClient::connect_unix(&socket);
client
.restart_container("abc123", None, None)
.await
.expect("restart_container with defaults should succeed");
mock_socket::stop(&socket, &stop);
}
#[valtron_test]
async fn docker_rename_container() {
let socket = temp_socket_path("rename");
let stop = Arc::new(AtomicBool::new(false));
mock_daemon(socket.clone(), stop.clone());
let client = DockerClient::connect_unix(&socket);
client
.rename_container("abc123", "new_name")
.await
.expect("rename_container should succeed");
mock_socket::stop(&socket, &stop);
}
#[valtron_test]
async fn docker_container_top() {
let socket = temp_socket_path("top");
let stop = Arc::new(AtomicBool::new(false));
mock_daemon(socket.clone(), stop.clone());
let client = DockerClient::connect_unix(&socket);
let top = client
.container_top("abc123", Some("aux"))
.await
.expect("container_top should succeed");
let titles = top.titles.expect("should have Titles");
assert_eq!(titles.len(), 10);
assert_eq!(titles[0], "USER");
assert_eq!(titles[9], "COMMAND");
let processes = top.processes.expect("should have Processes");
assert_eq!(processes.len(), 1);
assert_eq!(processes[0].len(), 9);
assert_eq!(processes[0][8], "sleep 30");
mock_socket::stop(&socket, &stop);
}
#[valtron_test]
async fn docker_container_top_no_ps_args() {
let socket = temp_socket_path("top_nops");
let stop = Arc::new(AtomicBool::new(false));
mock_daemon(socket.clone(), stop.clone());
let client = DockerClient::connect_unix(&socket);
let top = client
.container_top("abc123", None)
.await
.expect("container_top without ps_args should succeed");
assert!(top.titles.is_some());
mock_socket::stop(&socket, &stop);
}
#[valtron_test]
async fn docker_container_list() {
let socket = temp_socket_path("list");
let stop = Arc::new(AtomicBool::new(false));
mock_daemon(socket.clone(), stop.clone());
let client = DockerClient::connect_unix(&socket);
let containers = client
.container_list(true, None, false, None)
.await
.expect("container_list should succeed");
let arr = containers.as_array().expect("should be an array");
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["Id"].as_str(), Some("abc123"));
assert_eq!(arr[0]["Names"][0].as_str(), Some("/test_container"));
mock_socket::stop(&socket, &stop);
}
#[valtron_test]
async fn docker_container_list_with_filters() {
let socket = temp_socket_path("list_filt");
let stop = Arc::new(AtomicBool::new(false));
mock_daemon(socket.clone(), stop.clone());
let client = DockerClient::connect_unix(&socket);
let containers = client
.container_list(false, Some(5), true, Some(r#"{"status":["running"]}"#))
.await
.expect("container_list with filters should succeed");
assert!(containers.is_array());
mock_socket::stop(&socket, &stop);
}
#[test]
fn container_top_response_deserializes_pascalcase_json() {
let json = r#"{"Processes":[["root","1"]],"Titles":["USER","PID"]}"#;
let resp: ContainerTopResponse =
serde_json::from_str(json).expect("deserialize ContainerTopResponse");
assert_eq!(resp.titles.as_deref(), Some(&["USER".to_string(), "PID".to_string()][..]));
let procs = resp.processes.expect("should have Processes");
assert_eq!(procs.len(), 1);
assert_eq!(procs[0][0], "root");
}
#[valtron_test]
async fn docker_container_logs_demultiplexes_frames() {
let socket = temp_socket_path("logs");
let stop = Arc::new(AtomicBool::new(false));
mock_daemon(socket.clone(), stop.clone());
let client = DockerClient::connect_unix(&socket);
let (mut out, mut err) = (String::new(), String::new());
client
.container_logs("abc123", &ContainerLogsOptions::default())
.await
.expect("container_logs should succeed")
.read_into(&mut out, &mut err)
.await
.expect("frames");
assert_eq!((out.as_str(), err.as_str()), ("hello", ""));
mock_socket::stop(&socket, &stop);
}
#[valtron_test]
async fn docker_container_logs_use_the_clients_frame_cap() {
let socket = temp_socket_path("logs_cap");
let stop = Arc::new(AtomicBool::new(false));
mock_daemon(socket.clone(), stop.clone());
let client = DockerClient::connect_unix(&socket)
.with_limits(DockerLimits::default().with_max_log_frame(4));
let mut frames = client
.container_logs("abc123", &ContainerLogsOptions::default())
.await
.expect("the status is fine; the frame is not");
match frames.next().await {
Some(Err(DockerError::JsonParse(m))) => assert!(m.contains("larger than 4 bytes"), "{m}"),
other => panic!("expected JsonParse, got {other:?}"),
}
mock_socket::stop(&socket, &stop);
}