#![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, Mutex};
use foundation_core::valtron::valtron_test;
use foundation_deployment_docker::DockerClient;
use foundation_deployment_docker::generated::version::SystemVersion;
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_{tag}_{pid}_{nanos}.sock"));
let _ = std::fs::remove_file(&path);
path
}
fn parse_version(v: &str) -> (u32, u32) {
let (major, minor) = v.split_once('.').expect("major.minor");
(major.parse().expect("major"), minor.parse().expect("minor"))
}
fn route(method: &str, path: &str, api_version: Option<&str>) -> (u16, String) {
let version = api_version.unwrap_or("1.53");
let (requested, p) = match path.strip_prefix("/v") {
Some(rest) => match rest.split_once('/') {
Some((v, r)) => (Some(v), r),
None => (None, path.trim_start_matches('/')),
},
None => (None, path.trim_start_matches('/')),
};
if let Some(requested) = requested {
if parse_version(requested) > parse_version(version) {
return (
400,
format!(
"{{\"message\":\"client version {requested} is too new. \
Maximum supported API version is {version}\"}}"
),
);
}
}
match (method, p) {
("GET", "version") => (
200,
format!(
r#"{{"ApiVersion":"{version}","MinAPIVersion":"1.24","Os":"linux","Arch":"amd64","Version":"27.0.0"}}"#
),
),
("GET", "_ping") => (200, "OK".to_string()),
("GET", "info") => (200, r#"{"Containers":0}"#.to_string()),
_ => (404, format!("{{\"message\":\"no route for {method} {p}\"}}")),
}
}
fn mock_daemon(socket_path: PathBuf, stop: Arc<AtomicBool>, api_version: Option<&'static str>) {
mock_daemon_recording(socket_path, stop, api_version, Arc::new(Mutex::new(Vec::new())));
}
fn mock_daemon_recording(
socket_path: PathBuf,
stop: Arc<AtomicBool>,
api_version: Option<&'static str>,
seen: Arc<Mutex<Vec<String>>>,
) {
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());
seen.lock().expect("request log lock").push(path.to_string());
let (status, body) = route(method, path, api_version);
let reason = match status {
204 => "No Content",
400 => "Bad Request",
404 => "Not Found",
_ => "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_version_parses_pascalcase_json() {
let socket = temp_socket_path("version");
let stop = Arc::new(AtomicBool::new(false));
mock_daemon(socket.clone(), stop.clone(), None);
let client = DockerClient::connect_unix(&socket);
let v = client.version().await.expect("version() should succeed");
assert_eq!(v.api_version.as_deref(), Some("1.53"));
assert_eq!(v.os.as_deref(), Some("linux"));
assert_eq!(v.arch.as_deref(), Some("amd64"));
assert_eq!(v.version.as_deref(), Some("27.0.0"));
mock_socket::stop(&socket, &stop);
}
#[valtron_test]
async fn docker_version_twice_sequential() {
let socket = temp_socket_path("version2x");
let stop = Arc::new(AtomicBool::new(false));
mock_daemon(socket.clone(), stop.clone(), None);
let client = DockerClient::connect_unix(&socket);
let v1 = client.version().await.expect("first version()");
assert_eq!(v1.api_version.as_deref(), Some("1.53"));
let v2 = client.version().await.expect("second version()");
assert_eq!(v2.api_version.as_deref(), Some("1.53"));
mock_socket::stop(&socket, &stop);
}
#[valtron_test]
async fn docker_negotiate_version_lowers_to_older_server() {
let socket = temp_socket_path("negotiate_down");
let stop = Arc::new(AtomicBool::new(false));
mock_daemon(socket.clone(), stop.clone(), Some("1.41"));
let client = DockerClient::connect_unix(&socket);
assert_eq!(client.api_version(), "1.53", "starts at client default");
client
.negotiate_version()
.await
.expect("negotiate_version should succeed");
assert_eq!(
client.api_version(),
"1.41",
"lowered to server cap when server is older"
);
mock_socket::stop(&socket, &stop);
}
#[valtron_test]
async fn docker_negotiate_version_stays_when_server_is_newer() {
let socket = temp_socket_path("negotiate_up");
let stop = Arc::new(AtomicBool::new(false));
mock_daemon(socket.clone(), stop.clone(), Some("1.99"));
let client = DockerClient::connect_unix(&socket);
client
.negotiate_version()
.await
.expect("negotiate_version should succeed");
assert_eq!(
client.api_version(),
"1.53",
"stays at client default when server is newer"
);
mock_socket::stop(&socket, &stop);
}
#[valtron_test]
async fn first_request_negotiates_with_an_older_daemon() {
let socket = temp_socket_path("negotiate_lazy");
let stop = Arc::new(AtomicBool::new(false));
let seen = Arc::new(Mutex::new(Vec::new()));
mock_daemon_recording(socket.clone(), stop.clone(), Some("1.47"), seen.clone());
let client = DockerClient::connect_unix(&socket);
let info = client.system_info().await.expect("info against a 1.47 daemon");
assert_eq!(info["Containers"], 0);
assert_eq!(client.api_version(), "1.47");
let clone = client.clone();
clone.system_ping().await.expect("ping with the negotiated version");
let paths = seen.lock().expect("request log lock").clone();
assert_eq!(paths, vec!["/version", "/v1.47/info", "/v1.47/_ping"], "requests sent");
mock_socket::stop(&socket, &stop);
}
#[valtron_test]
async fn a_too_new_version_is_an_api_error_not_unreachable() {
let socket = temp_socket_path("too_new");
let stop = Arc::new(AtomicBool::new(false));
mock_daemon(socket.clone(), stop.clone(), Some("1.47"));
let client = DockerClient::connect_unix(&socket);
let url = format!("{}/_ping", client.base_url());
let builder = foundation_netio::PreparedRequestBuilder::get(&url).expect("request");
let response = client.http().send_async(builder.build()).await.expect("daemon answers");
let status: usize = response.get_status().into();
assert_eq!(status, 400, "an older daemon rejects /v1.53/");
let err = foundation_deployment_docker::DockerError::from(
foundation_deployment_docker::generated::shared::ApiError::HttpStatus {
code: 400,
headers: response.get_headers_ref().clone(),
body: None,
},
);
assert!(!err.is_unreachable(), "{err}");
mock_socket::stop(&socket, &stop);
}
#[valtron_test]
async fn negotiation_reports_a_daemon_with_no_common_version() {
let socket = temp_socket_path("no_overlap");
let stop = Arc::new(AtomicBool::new(false));
mock_daemon(socket.clone(), stop.clone(), Some("1.60"));
let client = DockerClient::connect_unix(&socket);
let v = client.version().await.expect("version");
assert_eq!(v.min_api_version.as_deref(), Some("1.24"));
let err = foundation_deployment_docker::client::negotiate_api_version("1.20", v.api_version.as_deref(), v.min_api_version.as_deref())
.expect_err("1.20 is older than the daemon's minimum");
assert!(
matches!(err, foundation_deployment_docker::DockerError::UnsupportedApiVersion { .. }),
"{err:?}"
);
assert!(!err.is_unreachable());
mock_socket::stop(&socket, &stop);
}
#[valtron_test]
async fn negotiation_with_no_daemon_is_unreachable_and_not_cached() {
let socket = temp_socket_path("absent");
let client = DockerClient::connect_unix(&socket);
let err = client.negotiate_version().await.expect_err("no daemon there");
assert!(err.is_unreachable(), "{err:?}");
assert_eq!(client.api_version(), "1.53");
let stop = Arc::new(AtomicBool::new(false));
mock_daemon(socket.clone(), stop.clone(), Some("1.45"));
client.negotiate_version().await.expect("daemon is up now");
assert_eq!(client.api_version(), "1.45");
mock_socket::stop(&socket, &stop);
}
#[test]
fn system_version_deserializes_pascalcase_json_via_per_field_rename() {
let json = r#"{"ApiVersion":"1.53","Os":"linux","Arch":"amd64","Version":"27.0.0"}"#;
let v: SystemVersion = serde_json::from_str(json).expect("deserialize PascalCase JSON");
assert_eq!(v.api_version.as_deref(), Some("1.53"));
assert_eq!(v.os.as_deref(), Some("linux"));
assert_eq!(v.arch.as_deref(), Some("amd64"));
assert_eq!(v.version.as_deref(), Some("27.0.0"));
}