use foundation_deployment::providers::common::{ApiClient, BodyLimits};
use foundation_netio::{DynNetClient, HttpClientBuilder, PreparedRequestBuilder};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use crate::error::DockerError;
use crate::generated::auth::AuthResponse;
use crate::generated::containers::{container_delete_request, ContainerDeleteArgs};
use crate::generated::create::{container_create_request, ContainerCreateArgs, ContainerCreateResponse};
use crate::generated::json::{container_inspect_request, ContainerInspectArgs, ContainerInspectResponse};
use crate::generated::pause::{container_pause_request, ContainerPauseArgs};
use crate::generated::shared::{ApiError, IDResponse};
use crate::generated::start::{container_start_request, ContainerStartArgs};
use crate::generated::stop::{container_stop_request, ContainerStopArgs};
use crate::generated::unpause::{container_unpause_request, ContainerUnpauseArgs};
use crate::generated::version::{system_version_request, SystemVersion, SystemVersionArgs};
use crate::generated::wait::{container_wait_request, ContainerWaitArgs, ContainerWaitResponse};
pub const DEFAULT_DOCKER_SOCKET: &str = "/var/run/docker.sock";
pub const DEFAULT_API_VERSION: &str = "1.53";
pub(crate) type NoMod = fn(&mut PreparedRequestBuilder);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DockerLimits {
pub max_error_body: usize,
pub max_json_line: usize,
pub max_log_frame: usize,
}
impl Default for DockerLimits {
fn default() -> Self {
let body = BodyLimits::default();
Self {
max_error_body: body.max_error_body,
max_json_line: body.max_json_line,
max_log_frame: 16 * 1024 * 1024,
}
}
}
impl DockerLimits {
#[must_use]
pub fn with_max_error_body(mut self, bytes: usize) -> Self {
self.max_error_body = bytes;
self
}
#[must_use]
pub fn with_max_json_line(mut self, bytes: usize) -> Self {
self.max_json_line = bytes;
self
}
#[must_use]
pub fn with_max_log_frame(mut self, bytes: usize) -> Self {
self.max_log_frame = bytes;
self
}
#[must_use]
pub fn body_limits(&self) -> BodyLimits {
BodyLimits::default()
.with_max_error_body(self.max_error_body)
.with_max_json_line(self.max_json_line)
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct ContainerCreateBody {
#[serde(flatten)]
pub config: crate::generated::json::ContainerConfig,
#[serde(
rename = "HostConfig",
skip_serializing_if = "Option::is_none"
)]
pub host_config: Option<ContainerHostConfig>,
#[serde(
rename = "NetworkingConfig",
skip_serializing_if = "Option::is_none"
)]
pub networking_config: Option<NetworkingConfig>,
}
impl ContainerCreateBody {
#[must_use]
pub fn new(image: impl Into<String>) -> Self {
let mut config = crate::generated::json::ContainerConfig::default();
config.image = Some(image.into());
Self { config, host_config: None, networking_config: None }
}
#[must_use]
pub fn with_cmd(mut self, cmd: Vec<String>) -> Self {
self.config.cmd = Some(cmd);
self
}
#[must_use]
pub fn with_env(mut self, env: Vec<(String, String)>) -> Self {
self.config.env = Some(env.into_iter().map(|(k, v)| format!("{k}={v}")).collect());
self
}
#[must_use]
pub fn with_exposed_ports(mut self, ports: serde_json::Value) -> Self {
self.config.exposed_ports = Some(ports);
self
}
#[must_use]
pub fn with_labels(mut self, labels: serde_json::Value) -> Self {
self.config.labels = Some(labels);
self
}
#[must_use]
pub fn with_host_config(mut self, hc: ContainerHostConfig) -> Self {
self.host_config = Some(hc);
self
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct ContainerHostConfig {
#[serde(flatten)]
pub base: crate::generated::json::HostConfig,
#[serde(flatten)]
pub resources: crate::generated::json::Resources,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NetworkingConfig {
#[serde(rename = "EndpointsConfig")]
pub endpoints_config: std::collections::HashMap<String, crate::generated::connect::EndpointSettings>,
}
pub mod containers_lifecycle;
pub mod networks;
pub mod volumes;
pub mod images;
pub mod build_context;
pub mod body;
pub mod exec;
pub mod system;
#[cfg(feature = "ssh")]
pub mod ssh;
#[derive(Clone)]
pub struct DockerClient {
http: DynNetClient,
socket_path: PathBuf,
negotiated_version: Arc<OnceLock<String>>,
remote_host: Option<String>,
tls: bool,
limits: DockerLimits,
}
#[derive(Clone)]
pub struct DockerTls {
pub ca_pem: Option<Vec<u8>>,
pub cert_pem: Vec<u8>,
pub key_pem: Vec<u8>,
pub verify: bool,
}
impl DockerTls {
pub fn from_cert_dir(dir: impl AsRef<Path>, verify: bool) -> Result<Self, DockerError> {
let dir = dir.as_ref();
let read = |name: &str| {
std::fs::read(dir.join(name))
.map_err(|e| DockerError::Unavailable(format!("read {name}: {e}")))
};
Ok(Self {
ca_pem: std::fs::read(dir.join("ca.pem")).ok(),
cert_pem: read("cert.pem")?,
key_pem: read("key.pem")?,
verify,
})
}
}
impl std::fmt::Debug for DockerClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DockerClient")
.field("socket_path", &self.socket_path)
.field("api_version", &self.api_version())
.field("remote_host", &self.remote_host)
.field("limits", &self.limits)
.finish_non_exhaustive()
}
}
impl DockerClient {
#[must_use]
pub fn connect_unix(socket_path: impl Into<PathBuf>) -> Self {
let socket_path = socket_path.into();
let http = HttpClientBuilder::new()
.unix_socket(&socket_path)
.read_timeout(std::time::Duration::from_secs(120))
.build();
Self {
http,
socket_path,
negotiated_version: Arc::new(OnceLock::new()),
remote_host: None,
tls: false,
limits: DockerLimits::default(),
}
}
#[must_use]
pub fn connect_tcp(host: &str) -> Self {
let socket_path = PathBuf::from(format!("tcp://{host}"));
let http = HttpClientBuilder::new()
.read_timeout(std::time::Duration::from_secs(120))
.build();
Self {
http,
socket_path,
negotiated_version: Arc::new(OnceLock::new()),
remote_host: Some(host.to_string()),
tls: false,
limits: DockerLimits::default(),
}
}
pub fn connect_tls(host: &str, tls: DockerTls) -> Result<Self, DockerError> {
use foundation_netio::netcap::ssl::SSLConnector;
let connector = SSLConnector::from_client_mutual_pem(
tls.ca_pem.as_deref(),
&tls.cert_pem,
&tls.key_pem,
tls.verify,
)
.map_err(|e| DockerError::Unavailable(format!("build TLS connector: {e}")))?;
let http = HttpClientBuilder::new()
.with_tls_connector(connector)
.read_timeout(std::time::Duration::from_secs(120))
.build();
Ok(Self {
http,
socket_path: PathBuf::from(format!("tcp://{host}")),
negotiated_version: Arc::new(OnceLock::new()),
remote_host: Some(host.to_string()),
tls: true,
limits: DockerLimits::default(),
})
}
#[cfg(feature = "ssh")]
pub fn connect_ssh(url: &str) -> Result<Self, DockerError> {
use foundation_sshkit::Host;
let spec = url.strip_prefix("ssh://").unwrap_or(url);
let host = Host::resolve(spec);
let connector = std::sync::Arc::new(ssh::SshConnector::new(host));
let http = HttpClientBuilder::new()
.with_connector(connector)
.read_timeout(std::time::Duration::from_secs(120))
.build();
Ok(Self {
http,
socket_path: PathBuf::from(url),
negotiated_version: Arc::new(OnceLock::new()),
remote_host: None,
tls: false,
limits: DockerLimits::default(),
})
}
#[must_use]
pub fn with_remote_host(mut self, host: &str) -> Self {
self.remote_host = Some(host.to_string());
self
}
pub fn connect_with_defaults() -> Result<Self, DockerError> {
let host = std::env::var("DOCKER_HOST").unwrap_or_default();
if host.is_empty() {
return Ok(Self::connect_unix(DEFAULT_DOCKER_SOCKET));
}
if let Some(path) = host.strip_prefix("unix://") {
return Ok(Self::connect_unix(path));
}
if let Some(addr) = host.strip_prefix("tcp://") {
let verify = std::env::var("DOCKER_TLS_VERIFY").is_ok_and(|v| v != "0" && !v.is_empty());
let cert_dir = std::env::var("DOCKER_CERT_PATH").ok();
if verify || cert_dir.is_some() {
let dir = cert_dir.ok_or_else(|| {
DockerError::Unavailable(
"DOCKER_TLS_VERIFY set but DOCKER_CERT_PATH is missing".to_string(),
)
})?;
let tls = DockerTls::from_cert_dir(&dir, verify)?;
return Self::connect_tls(addr, tls);
}
return Ok(Self::connect_tcp(addr));
}
if host.starts_with("ssh://") {
#[cfg(feature = "ssh")]
{
return Self::connect_ssh(&host);
}
#[cfg(not(feature = "ssh"))]
{
return Err(DockerError::Unavailable(format!(
"DOCKER_HOST is {host}, but this build has the `ssh` feature off — \
enable foundation_deployment_docker/ssh (note it cannot be combined \
with `buildkit`: libssh2/OpenSSL vs BoringSSL)"
)));
}
}
Err(DockerError::Unavailable(format!(
"unrecognized DOCKER_HOST transport: {host}"
)))
}
#[must_use]
pub fn with_limits(mut self, limits: DockerLimits) -> Self {
self.limits = limits;
self
}
#[must_use]
pub fn limits(&self) -> DockerLimits {
self.limits
}
#[must_use]
pub fn http(&self) -> DynNetClient {
self.http.clone()
}
#[must_use]
pub fn api(&self) -> ApiClient {
ApiClient::new(self.http.clone()).with_limits(self.limits.body_limits())
}
#[must_use]
pub fn api_version(&self) -> &str {
self.negotiated_version
.get()
.map_or(DEFAULT_API_VERSION, String::as_str)
}
#[must_use]
pub fn unversioned_base_url(&self) -> String {
let host = self.remote_host.as_deref().unwrap_or("localhost");
let scheme = if self.tls { "https" } else { "http" };
format!("{scheme}://{host}")
}
#[must_use]
pub fn base_url(&self) -> String {
format!("{}/v{}", self.unversioned_base_url(), self.api_version())
}
pub async fn api_url(&self) -> Result<String, DockerError> {
self.negotiate_version().await?;
Ok(self.base_url())
}
#[must_use]
pub fn socket_path(&self) -> &std::path::Path {
&self.socket_path
}
pub async fn version(&self) -> Result<SystemVersion, DockerError> {
let response = system_version_request(
self.api(),
&SystemVersionArgs::default(),
&self.unversioned_base_url(),
None::<NoMod>,
)
.await?;
Ok(response.body)
}
pub async fn negotiate_version(&self) -> Result<(), DockerError> {
if self.negotiated_version.get().is_some() {
return Ok(());
}
let version = self.version().await?;
let agreed = negotiate_api_version(
DEFAULT_API_VERSION,
version.api_version.as_deref(),
version.min_api_version.as_deref(),
)?;
if let Err(ours) = self.negotiated_version.set(agreed) {
debug_assert_eq!(Some(&ours), self.negotiated_version.get());
}
Ok(())
}
pub async fn create_container(
&self,
config: &impl Serialize,
name: Option<&str>,
) -> Result<ContainerCreateResponse, DockerError> {
let json = serde_json::to_value(config)
.map_err(|e| DockerError::JsonParse(format!("serialize container config: {e}")))?;
let args = ContainerCreateArgs {
name: name.map(str::to_string),
platform: None,
body: generated_body("container config", json)?,
};
let response =
container_create_request(self.api(), &args, &self.api_url().await?, None::<NoMod>).await?;
Ok(response.body)
}
pub async fn start_container(&self, id: &str) -> Result<(), DockerError> {
let args = ContainerStartArgs {
id: id.to_string(),
detach_keys: None,
};
container_start_request(self.api(), &args, &self.api_url().await?, None::<NoMod>).await?;
Ok(())
}
pub async fn wait_container(
&self,
id: &str,
condition: Option<&str>,
) -> Result<ContainerWaitResponse, DockerError> {
let args = ContainerWaitArgs {
id: id.to_string(),
condition: condition.map(str::to_string),
};
let response = container_wait_request(self.api(), &args, &self.api_url().await?, None::<NoMod>).await?;
Ok(response.body)
}
pub async fn stop_container(
&self,
id: &str,
timeout_secs: Option<u32>,
) -> Result<(), DockerError> {
let args = ContainerStopArgs {
id: id.to_string(),
signal: None,
t: timeout_secs.map(|t| t.to_string()),
};
match container_stop_request(self.api(), &args, &self.api_url().await?, None::<NoMod>).await {
Ok(_) => Ok(()),
Err(ApiError::HttpStatus { code: 304, .. }) => Ok(()),
Err(e) => Err(e.into()),
}
}
pub async fn remove_container(&self, id: &str, force: bool) -> Result<(), DockerError> {
let args = ContainerDeleteArgs {
id: id.to_string(),
v: None,
force: Some(force.to_string()),
link: None,
};
container_delete_request(self.api(), &args, &self.api_url().await?, None::<NoMod>).await?;
Ok(())
}
pub async fn inspect_container(
&self,
id: &str,
) -> Result<ContainerInspectResponse, DockerError> {
let args = ContainerInspectArgs {
id: id.to_string(),
size: None,
};
let response = container_inspect_request(self.api(), &args, &self.api_url().await?, None::<NoMod>).await?;
Ok(response.body)
}
pub async fn pause_container(&self, id: &str) -> Result<(), DockerError> {
let args = ContainerPauseArgs {
id: id.to_string(),
};
container_pause_request(self.api(), &args, &self.api_url().await?, None::<NoMod>).await?;
Ok(())
}
pub async fn unpause_container(&self, id: &str) -> Result<(), DockerError> {
let args = ContainerUnpauseArgs {
id: id.to_string(),
};
container_unpause_request(self.api(), &args, &self.api_url().await?, None::<NoMod>).await?;
Ok(())
}
pub async fn exec_create(&self, id: &str, cmd: &[&str]) -> Result<IDResponse, DockerError> {
exec::exec_create(self, id, cmd).await
}
pub async fn exec_start(&self, id: &str, detach: bool) -> Result<body::LogStream, DockerError> {
exec::exec_start(self, id, detach).await
}
pub async fn exec_inspect(&self, id: &str) -> Result<serde_json::Value, DockerError> {
exec::exec_inspect(self, id).await
}
pub async fn exec_resize(&self, id: &str, h: u32, w: u32) -> Result<(), DockerError> {
exec::exec_resize(self, id, h, w).await
}
pub async fn system_info(&self) -> Result<serde_json::Value, DockerError> {
system::system_info(self).await
}
pub async fn system_ping(&self) -> Result<(), DockerError> {
system::system_ping(self).await
}
pub async fn system_events(
&self,
since: Option<&str>,
until: Option<&str>,
filters: Option<&str>,
) -> Result<body::EventStream, DockerError> {
system::system_events(self, since, until, filters).await
}
pub async fn system_data_usage(&self, types: Option<&str>) -> Result<serde_json::Value, DockerError> {
system::system_data_usage(self, types).await
}
pub async fn system_auth(&self, username: &str, password: &str) -> Result<AuthResponse, DockerError> {
system::system_auth(self, username, password).await
}
}
pub(crate) fn generated_body<T: serde::de::DeserializeOwned>(
what: &str,
value: serde_json::Value,
) -> Result<T, DockerError> {
serde_json::from_value(value)
.map_err(|e| DockerError::JsonParse(format!("{what} does not fit the API's request type: {e}")))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
struct ApiVersion {
major: u32,
minor: u32,
}
impl ApiVersion {
fn parse(s: &str) -> Result<Self, DockerError> {
let invalid = || DockerError::JsonParse(format!("invalid Docker API version {s:?}"));
let (major, minor) = s.trim().split_once('.').ok_or_else(invalid)?;
Ok(Self {
major: major.parse().map_err(|_| invalid())?,
minor: minor.parse().map_err(|_| invalid())?,
})
}
}
pub fn negotiate_api_version(
client_max: &str,
server_max: Option<&str>,
server_min: Option<&str>,
) -> Result<String, DockerError> {
let server_max_str = server_max.ok_or_else(|| {
DockerError::JsonParse("the daemon's GET /version reported no ApiVersion".to_string())
})?;
let client = ApiVersion::parse(client_max)?;
let server = ApiVersion::parse(server_max_str)?;
let (chosen, chosen_str) = if server < client {
(server, server_max_str)
} else {
(client, client_max)
};
if let Some(min_str) = server_min {
if chosen < ApiVersion::parse(min_str)? {
return Err(DockerError::UnsupportedApiVersion {
client_max: client_max.to_string(),
server_min: min_str.to_string(),
server_max: server_max_str.to_string(),
});
}
}
Ok(chosen_str.trim().to_string())
}