use std::collections::{BTreeMap, HashMap};
use std::path::Path;
use std::sync::Mutex;
use std::time::Duration;
use rightsize::backend::{Capabilities, FollowHandle, SandboxBackend, SandboxHandle};
use rightsize::error::{Result, RightsizeError};
use rightsize::model::{ContainerSpec, ExecResult};
use crate::client::DockerClient;
use crate::frames::{BodyReader, LineAssembler, StreamType, demux_into, read_frame};
use crate::json::{
ConnectNetworkBody, CreateContainerBody, CreateExecBody, CreateNetworkBody, EmptyObject,
EndpointConfig, ExecInspect, HostConfig, IdResponse, LabelFilter, ListedEntry, NameFilter,
PortBinding as JsonPortBinding, StartExecBody,
};
const STOP_TIMEOUT_SECS: u64 = 10;
const RUN_ID_LABEL_KEY: &str = "dev.rightsize.run_id";
const REUSE_LABEL_KEY: &str = "dev.rightsize.reuse";
struct Handle {
id: String,
spec: ContainerSpec,
}
impl SandboxHandle for Handle {
fn id(&self) -> &str {
&self.id
}
fn spec(&self) -> &ContainerSpec {
&self.spec
}
}
pub struct DockerBackend {
client: DockerClient,
network_ids: Mutex<HashMap<String, String>>,
}
impl DockerBackend {
pub(crate) fn new(client: DockerClient) -> Self {
DockerBackend {
client,
network_ids: Mutex::new(HashMap::new()),
}
}
pub fn connecting_to_env() -> Self {
Self::new(DockerClient::from_env())
}
#[cfg(test)]
pub(crate) fn socket_path_for_test(&self) -> std::path::PathBuf {
self.client.socket_path().to_path_buf()
}
async fn pull_if_missing(&self, image: &str) -> Result<()> {
let inspect_path = format!("/images/{}/json", encode_path_segment(image));
let inspect = self.client.request("GET", &inspect_path, None).await?;
if inspect.status == 200 {
return Ok(());
}
let (repo, tag) = split_repo_tag(image);
let pull_path = format!(
"/images/create?fromImage={}&tag={}",
encode_query_value(&repo),
encode_query_value(&tag)
);
let resp = self.client.request("POST", &pull_path, None).await?;
if resp.status >= 400 {
return Err(RightsizeError::Backend(format!(
"docker could not pull image '{image}' (HTTP {}): {}",
resp.status,
String::from_utf8_lossy(&resp.body)
)));
}
Ok(())
}
async fn connect_network(
&self,
container_id: &str,
network_id: &str,
aliases: &[String],
) -> Result<()> {
let body = ConnectNetworkBody {
container: container_id.to_string(),
endpoint_config: EndpointConfig {
aliases: aliases.to_vec(),
},
};
let body = serde_json::to_string(&body)
.expect("ConnectNetworkBody has no non-serializable fields");
let path = format!("/networks/{network_id}/connect");
let resp = self.client.request("POST", &path, Some(&body)).await?;
if resp.status >= 400 {
return Err(RightsizeError::Backend(format!(
"docker could not connect container {container_id} to network {network_id} \
(HTTP {}): {}",
resp.status,
String::from_utf8_lossy(&resp.body)
)));
}
Ok(())
}
async fn ensure_network_get_id(&self, network_id: &str) -> Result<String> {
if let Some(id) = self
.network_ids
.lock()
.expect("network_ids mutex poisoned")
.get(network_id)
{
return Ok(id.clone());
}
let filters = NameFilter {
name: vec![network_id.to_string()],
};
let filters =
serde_json::to_string(&filters).expect("NameFilter has no non-serializable fields");
let list_path = format!("/networks?filters={}", encode_query_value(&filters));
let list = self.client.request("GET", &list_path, None).await?;
if list.status == 200 {
let entries: Vec<ListedEntry> = serde_json::from_slice(&list.body).unwrap_or_default();
if let Some(entry) = entries.into_iter().next() {
self.network_ids
.lock()
.expect("network_ids mutex poisoned")
.insert(network_id.to_string(), entry.id.clone());
return Ok(entry.id);
}
}
let create_body = CreateNetworkBody {
name: network_id.to_string(),
};
let create_body = serde_json::to_string(&create_body)
.expect("CreateNetworkBody has no non-serializable fields");
let created = self
.client
.request("POST", "/networks/create", Some(&create_body))
.await?;
if created.status >= 400 {
return Err(RightsizeError::Backend(format!(
"docker could not create network '{network_id}' (HTTP {}): {}",
created.status,
String::from_utf8_lossy(&created.body)
)));
}
let id = serde_json::from_slice::<IdResponse>(&created.body)
.map_err(|e| {
RightsizeError::Backend(format!(
"docker's network-create response for '{network_id}' had no Id field: {e} \
(body: {})",
String::from_utf8_lossy(&created.body)
))
})?
.id;
self.network_ids
.lock()
.expect("network_ids mutex poisoned")
.insert(network_id.to_string(), id.clone());
Ok(id)
}
async fn lookup_network_id_by_name(&self, name: &str) -> Option<String> {
let filters = NameFilter {
name: vec![name.to_string()],
};
let filters = serde_json::to_string(&filters).ok()?;
let list_path = format!("/networks?filters={}", encode_query_value(&filters));
let list = self.client.request("GET", &list_path, None).await.ok()?;
if list.status != 200 {
return None;
}
let entries: Vec<ListedEntry> = serde_json::from_slice(&list.body).ok()?;
entries.into_iter().next().map(|e| e.id)
}
}
fn build_create_body(spec: &ContainerSpec) -> CreateContainerBody {
let env = spec.env.iter().map(|(k, v)| format!("{k}={v}")).collect();
let exposed_ports = spec
.ports
.iter()
.map(|p| (format!("{}/tcp", p.guest_port), EmptyObject {}))
.collect();
let port_bindings = spec
.ports
.iter()
.map(|p| {
(
format!("{}/tcp", p.guest_port),
vec![JsonPortBinding {
host_ip: "127.0.0.1".to_string(),
host_port: p.host_port.to_string(),
}],
)
})
.collect();
let binds = spec
.mounts
.iter()
.map(|m| {
let mode = if m.read_only { "ro" } else { "rw" };
format!("{}:{}:{}", m.host_path.display(), m.guest_path, mode)
})
.collect();
let labels = if spec.keep_alive {
BTreeMap::from([(REUSE_LABEL_KEY.to_string(), reuse_label_value(spec))])
} else {
BTreeMap::from([(RUN_ID_LABEL_KEY.to_string(), spec.run_id.clone())])
};
CreateContainerBody {
image: spec.image.clone(),
env,
cmd: spec.command.clone(),
exposed_ports,
labels,
host_config: HostConfig {
port_bindings,
binds,
extra_hosts: vec!["host.docker.internal:host-gateway".to_string()],
memory: spec.memory_limit_mb.map(|mb| mb * 1024 * 1024),
},
}
}
fn reuse_label_value(spec: &ContainerSpec) -> String {
if let Some(hex) = spec.name.strip_prefix("rz-reuse-") {
if hex.len() == 12 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
return hex.to_string();
}
}
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in spec.name.as_bytes() {
hash ^= *byte as u64;
hash = hash.wrapping_mul(0x0000_0100_0000_01B3);
}
format!("{:012x}", hash & 0xFFFF_FFFF_FFFF)
}
fn is_port_bind_conflict_message(message: &str) -> bool {
let m = message.to_lowercase();
m.contains("already in use") || m.contains("already allocated")
}
fn encode_path_segment(s: &str) -> String {
s.to_string()
}
fn encode_query_value(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
fn split_repo_tag(image: &str) -> (String, String) {
if image.contains('@') {
return (image.to_string(), String::new());
}
let slash_idx = image.rfind('/').map(|i| i + 1).unwrap_or(0);
match image[slash_idx..].rfind(':') {
Some(rel_colon) => {
let colon = slash_idx + rel_colon;
(image[..colon].to_string(), image[colon + 1..].to_string())
}
None => (image.to_string(), "latest".to_string()),
}
}
#[async_trait::async_trait]
impl SandboxBackend for DockerBackend {
fn name(&self) -> &str {
"docker"
}
fn supports_native_networks(&self) -> bool {
true
}
fn capabilities(&self) -> Capabilities {
Capabilities {
hardware_isolated: false,
checkpoint: true,
checkpoint_restarts_workload: false,
}
}
async fn create(&self, spec: ContainerSpec) -> Result<Box<dyn SandboxHandle>> {
self.pull_if_missing(&spec.image).await?;
let body = build_create_body(&spec);
let body = serde_json::to_string(&body)
.expect("CreateContainerBody has no non-serializable fields");
let path = format!("/containers/create?name={}", encode_query_value(&spec.name));
let resp = self.client.request("POST", &path, Some(&body)).await?;
if resp.status == 409 {
let message = String::from_utf8_lossy(&resp.body).into_owned();
return Err(RightsizeError::NameConflict {
message: format!(
"docker container name '{}' is already in use (HTTP 409): {message}",
spec.name
),
source: None,
});
}
if resp.status >= 400 {
let message = String::from_utf8_lossy(&resp.body).into_owned();
return Err(RightsizeError::Backend(format!(
"docker could not create container '{}' (HTTP {}): {message}",
spec.name, resp.status
)));
}
let id = serde_json::from_slice::<IdResponse>(&resp.body)
.map_err(|e| {
RightsizeError::Backend(format!(
"docker's container-create response for '{}' had no Id field: {e} (body: {})",
spec.name,
String::from_utf8_lossy(&resp.body)
))
})?
.id;
if let Some(network_id) = &spec.network_id {
let daemon_network_id = self.ensure_network_get_id(network_id).await?;
self.connect_network(&id, &daemon_network_id, &spec.aliases)
.await?;
}
Ok(Box::new(Handle { id, spec }))
}
async fn start(&self, handle: &dyn SandboxHandle) -> Result<()> {
let path = format!("/containers/{}/start", handle.id());
let resp = self.client.request("POST", &path, None).await?;
if resp.status == 204 || resp.status == 304 {
return Ok(()); }
let message = String::from_utf8_lossy(&resp.body).into_owned();
if resp.status == 500 && is_port_bind_conflict_message(&message) {
return Err(RightsizeError::PortBindConflict {
message: format!(
"docker could not bind a host port for {}: {message}",
handle.id()
),
source: None,
});
}
Err(RightsizeError::Backend(format!(
"docker could not start container {} (HTTP {}): {message}",
handle.id(),
resp.status
)))
}
async fn stop(&self, handle: &dyn SandboxHandle) -> Result<()> {
let path = format!("/containers/{}/stop?t={STOP_TIMEOUT_SECS}", handle.id());
let _ = self.client.request("POST", &path, None).await; Ok(())
}
async fn remove(&self, handle: &dyn SandboxHandle) -> Result<()> {
let path = format!("/containers/{}?force=true", handle.id());
let _ = self.client.request("DELETE", &path, None).await; Ok(())
}
async fn exec(&self, handle: &dyn SandboxHandle, cmd: &[String]) -> Result<ExecResult> {
let create_body = CreateExecBody {
attach_stdout: true,
attach_stderr: true,
cmd: cmd.to_vec(),
};
let create_body = serde_json::to_string(&create_body)
.expect("CreateExecBody has no non-serializable fields");
let create_path = format!("/containers/{}/exec", handle.id());
let created = self
.client
.request("POST", &create_path, Some(&create_body))
.await?;
if created.status >= 400 {
return Err(RightsizeError::Backend(format!(
"docker could not create an exec for container {} (HTTP {}): {}",
handle.id(),
created.status,
String::from_utf8_lossy(&created.body)
)));
}
let exec_id = serde_json::from_slice::<IdResponse>(&created.body)
.map_err(|e| {
RightsizeError::Backend(format!(
"docker's exec-create response for container {} had no Id field: {e} \
(body: {})",
handle.id(),
String::from_utf8_lossy(&created.body)
))
})?
.id;
let start_path = format!("/exec/{exec_id}/start");
let start_body = serde_json::to_string(&StartExecBody { detach: false })
.expect("StartExecBody has no non-serializable fields");
let (headers, mut stream) = self
.client
.request_stream("POST", &start_path, Some(&start_body))
.await?;
if headers.status >= 400 {
return Err(RightsizeError::Backend(format!(
"docker could not start exec {exec_id} for container {} (HTTP {})",
handle.id(),
headers.status
)));
}
let mut body = BodyReader::new(&mut stream, &headers);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
demux_into(
&mut body,
|b| stdout.extend_from_slice(b),
|b| stderr.extend_from_slice(b),
)
.await?;
let inspect_path = format!("/exec/{exec_id}/json");
let inspected = self.client.request("GET", &inspect_path, None).await?;
let exit_code = serde_json::from_slice::<ExecInspect>(&inspected.body)
.ok()
.and_then(|r| r.exit_code)
.unwrap_or(-1);
Ok(ExecResult {
exit_code: exit_code as i32,
stdout: String::from_utf8_lossy(&stdout).into_owned(),
stderr: String::from_utf8_lossy(&stderr).into_owned(),
})
}
async fn logs(&self, handle: &dyn SandboxHandle) -> Result<String> {
let path = format!(
"/containers/{}/logs?stdout=1&stderr=1&tail=1000",
handle.id()
);
let (headers, mut stream) = self.client.request_stream("GET", &path, None).await?;
if headers.status >= 400 {
return Err(RightsizeError::Backend(format!(
"docker could not fetch logs for container {} (HTTP {})",
handle.id(),
headers.status
)));
}
let mut body = BodyReader::new(&mut stream, &headers);
let mut assembler = LineAssembler::new();
let mut out = String::new();
while let Some(frame) = read_frame(&mut body).await? {
if !matches!(frame.stream, StreamType::Stdout | StreamType::Stderr) {
continue;
}
let text = String::from_utf8_lossy(&frame.payload).into_owned();
for line in assembler.feed(&text) {
out.push_str(&line);
out.push('\n');
}
}
if let Some(tail) = assembler.flush() {
out.push_str(&tail);
out.push('\n');
}
Ok(out)
}
async fn follow_logs(
&self,
handle: &dyn SandboxHandle,
consumer: Box<dyn Fn(String) + Send + Sync>,
) -> Result<FollowHandle> {
let path = format!(
"/containers/{}/logs?stdout=1&stderr=1&follow=1&tail=all",
handle.id()
);
let (headers, mut stream) = self.client.request_stream("GET", &path, None).await?;
if headers.status >= 400 {
return Err(RightsizeError::Backend(format!(
"docker could not follow logs for container {} (HTTP {})",
handle.id(),
headers.status
)));
}
let close_requested = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let task_close = close_requested.clone();
let task = tokio::spawn(async move {
let mut body = BodyReader::new(&mut stream, &headers);
let mut assembler = LineAssembler::new();
loop {
if task_close.load(std::sync::atomic::Ordering::SeqCst) {
return;
}
let frame = match read_frame(&mut body).await {
Ok(Some(f)) => f,
Ok(None) => break, Err(_) => break, };
if !matches!(frame.stream, StreamType::Stdout | StreamType::Stderr) {
continue;
}
let text = String::from_utf8_lossy(&frame.payload).into_owned();
for line in assembler.feed(&text) {
if task_close.load(std::sync::atomic::Ordering::SeqCst) {
return;
}
consumer(line);
}
}
if task_close.load(std::sync::atomic::Ordering::SeqCst) {
return; }
if let Some(tail) = assembler.flush() {
consumer(tail);
}
});
Ok(FollowHandle::from_task(close_requested, task))
}
async fn ensure_network(&self, network_id: &str) -> Result<()> {
self.ensure_network_get_id(network_id).await?;
Ok(())
}
async fn remove_network(&self, network_id: &str) -> Result<()> {
let cached = self
.network_ids
.lock()
.expect("network_ids mutex poisoned")
.remove(network_id);
let daemon_id = match cached {
Some(id) => Some(id),
None => self.lookup_network_id_by_name(network_id).await,
};
if let Some(id) = daemon_id {
let path = format!("/networks/{id}");
let _ = self.client.request("DELETE", &path, None).await; }
Ok(())
}
async fn close(&self) -> Result<()> {
let run_id = rightsize::RunId::value();
let filters = LabelFilter {
label: vec![format!("{RUN_ID_LABEL_KEY}={run_id}")],
};
let filters =
serde_json::to_string(&filters).expect("LabelFilter has no non-serializable fields");
let path = format!(
"/containers/json?all=true&filters={}",
encode_query_value(&filters)
);
let listed = self.client.request("GET", &path, None).await?;
if listed.status != 200 {
return Ok(()); }
let entries: Vec<ListedEntry> = serde_json::from_slice(&listed.body).unwrap_or_default();
for entry in entries {
let remove_path = format!("/containers/{}?force=true", entry.id);
let _ = self.client.request("DELETE", &remove_path, None).await;
}
Ok(())
}
fn cleanup_sync(&self, container_id: &str) {
let _ = blocking_force_remove(self.client.socket_path(), container_id, STOP_TIMEOUT_SECS);
}
fn remove_by_name(&self, name: &str) {
let socket = self.client.socket_path();
let Some(id) = blocking_find_container_id_by_name(socket, name) else {
return; };
let _ = blocking_force_remove(socket, &id, STOP_TIMEOUT_SECS);
}
async fn find_running(&self, spec: &ContainerSpec) -> Result<Option<Box<dyn SandboxHandle>>> {
let filters = format!(r#"{{"name":["^/{}$"],"status":["running"]}}"#, spec.name);
let path = format!("/containers/json?filters={}", encode_query_value(&filters));
let resp = self.client.request("GET", &path, None).await?;
if resp.status != 200 {
return Ok(None);
}
let entries: Vec<ListedEntry> = serde_json::from_slice(&resp.body).unwrap_or_default();
Ok(entries.into_iter().next().map(|entry| {
Box::new(Handle {
id: entry.id,
spec: spec.clone(),
}) as Box<dyn SandboxHandle>
}))
}
async fn create_checkpoint(&self, handle: &dyn SandboxHandle, nonce: &str) -> Result<String> {
let checkpoint_ref = format!("rightsize/checkpoint:{nonce}");
let (repo, tag) = split_repo_tag(&checkpoint_ref);
let path = format!(
"/commit?container={}&repo={}&tag={}",
encode_query_value(handle.id()),
encode_query_value(&repo),
encode_query_value(&tag)
);
let resp = self.client.request("POST", &path, None).await?;
if resp.status >= 400 {
return Err(RightsizeError::Backend(format!(
"docker could not commit container {} to image '{checkpoint_ref}' (HTTP {}): {}",
handle.id(),
resp.status,
String::from_utf8_lossy(&resp.body)
)));
}
Ok(checkpoint_ref)
}
async fn remove_checkpoint(&self, checkpoint_ref: &str) -> Result<()> {
let path = format!("/images/{}?force=true", encode_path_segment(checkpoint_ref));
let resp = self.client.request("DELETE", &path, None).await?;
if resp.status >= 400 && resp.status != 404 {
return Err(RightsizeError::Backend(format!(
"docker could not remove checkpoint image '{checkpoint_ref}' (HTTP {}): {}",
resp.status,
String::from_utf8_lossy(&resp.body)
)));
}
Ok(())
}
async fn has_checkpoint(&self, checkpoint_ref: &str) -> Result<bool> {
let path = format!("/images/{}/json", encode_path_segment(checkpoint_ref));
let resp = self.client.request("GET", &path, None).await?;
match resp.status {
200 => Ok(true),
404 => Ok(false),
status => Err(RightsizeError::Backend(format!(
"docker could not inspect checkpoint image '{checkpoint_ref}' (HTTP {status}): {}",
String::from_utf8_lossy(&resp.body)
))),
}
}
async fn copy_to_container(
&self,
handle: &dyn SandboxHandle,
host_path: &Path,
container_path: &str,
) -> Result<()> {
let args = docker_cp_in_args(handle.id(), host_path, container_path);
let result = run_docker_cli(&args).await?;
if result.exit_code != 0 {
return Err(RightsizeError::Backend(format!(
"docker cp into container {} failed (exit {}): {}",
handle.id(),
result.exit_code,
result.stderr.trim()
)));
}
Ok(())
}
async fn copy_from_container(
&self,
handle: &dyn SandboxHandle,
container_path: &str,
host_path: &Path,
) -> Result<()> {
let args = docker_cp_out_args(handle.id(), container_path, host_path);
let result = run_docker_cli(&args).await?;
if result.exit_code != 0 {
return Err(RightsizeError::Backend(format!(
"docker cp out of container {} failed (exit {}): {}",
handle.id(),
result.exit_code,
result.stderr.trim()
)));
}
Ok(())
}
fn watchdog_kill_command(&self) -> Vec<String> {
vec!["docker".to_string(), "rm".to_string(), "-f".to_string()]
}
fn watchdog_network_kill_command(&self) -> Vec<String> {
vec![
"docker".to_string(),
"network".to_string(),
"rm".to_string(),
]
}
}
fn docker_cp_in_args(container_id: &str, host_path: &Path, container_path: &str) -> Vec<String> {
vec![
"cp".to_string(),
host_path.display().to_string(),
format!("{container_id}:{container_path}"),
]
}
fn docker_cp_out_args(container_id: &str, container_path: &str, host_path: &Path) -> Vec<String> {
vec![
"cp".to_string(),
format!("{container_id}:{container_path}"),
host_path.display().to_string(),
]
}
async fn run_docker_cli(args: &[String]) -> Result<ExecResult> {
let output = tokio::process::Command::new("docker")
.args(args)
.stdin(std::process::Stdio::null())
.output()
.await
.map_err(|e| {
RightsizeError::Backend(format!("failed to spawn docker {}: {e}", args.join(" ")))
})?;
Ok(ExecResult {
exit_code: output.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
}
fn blocking_force_remove(
socket_path: &std::path::Path,
container_id: &str,
stop_timeout_secs: u64,
) -> std::io::Result<()> {
let _ = blocking_request(
socket_path,
"POST",
&format!("/containers/{container_id}/stop?t={stop_timeout_secs}"),
);
blocking_request(
socket_path,
"DELETE",
&format!("/containers/{container_id}?force=true"),
)
.map(|_| ())
}
fn blocking_find_container_id_by_name(socket_path: &std::path::Path, name: &str) -> Option<String> {
let filters = format!(r#"{{"name":["^/{name}$"]}}"#);
let path = format!(
"/containers/json?all=true&filters={}",
encode_query_value(&filters)
);
let body = blocking_get_body(socket_path, &path).ok()?;
let entries: Vec<ListedEntry> = serde_json::from_slice(&body).ok()?;
entries.into_iter().next().map(|e| e.id)
}
fn blocking_get_body(socket_path: &std::path::Path, path: &str) -> std::io::Result<Vec<u8>> {
use std::io::{BufRead, BufReader, Read, Write};
use std::os::unix::net::UnixStream;
let mut stream = UnixStream::connect(socket_path)?;
stream.set_read_timeout(Some(Duration::from_secs(stop_timeout_budget())))?;
let request = format!("GET {path} HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n");
stream.write_all(request.as_bytes())?;
let mut reader = BufReader::new(stream);
let mut status_line = String::new();
reader.read_line(&mut status_line)?;
let mut content_length: Option<usize> = None;
let mut chunked = false;
loop {
let mut line = String::new();
let n = reader.read_line(&mut line)?;
if n == 0 || line == "\r\n" || line == "\n" {
break;
}
if let Some((name, value)) = line.split_once(':') {
let name = name.trim();
let value = value.trim();
if name.eq_ignore_ascii_case("content-length") {
content_length = value.parse::<usize>().ok();
} else if name.eq_ignore_ascii_case("transfer-encoding") {
chunked = value.to_ascii_lowercase().contains("chunked");
}
}
}
if chunked {
return blocking_read_chunked_body(&mut reader);
}
let mut body = Vec::new();
match content_length {
Some(len) => {
body.resize(len, 0);
reader.read_exact(&mut body)?;
}
None => {
reader.read_to_end(&mut body)?;
}
}
Ok(body)
}
fn blocking_read_chunked_body(
reader: &mut std::io::BufReader<std::os::unix::net::UnixStream>,
) -> std::io::Result<Vec<u8>> {
use std::io::{BufRead, Read};
let mut out = Vec::new();
loop {
let mut size_line = String::new();
reader.read_line(&mut size_line)?;
let size_str = size_line.trim().split(';').next().unwrap_or("").trim();
let size = usize::from_str_radix(size_str, 16).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"could not parse a chunk size from the Docker daemon's response: \
{size_line:?}"
),
)
})?;
if size == 0 {
loop {
let mut trailer = String::new();
let n = reader.read_line(&mut trailer)?;
if n == 0 || trailer == "\r\n" || trailer == "\n" {
break;
}
}
break;
}
let mut chunk = vec![0u8; size];
reader.read_exact(&mut chunk)?;
out.extend_from_slice(&chunk);
let mut crlf = [0u8; 2];
reader.read_exact(&mut crlf)?;
}
Ok(out)
}
fn blocking_request(
socket_path: &std::path::Path,
method: &str,
path: &str,
) -> std::io::Result<()> {
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
let mut stream = UnixStream::connect(socket_path)?;
stream.set_read_timeout(Some(Duration::from_secs(stop_timeout_budget())))?;
let request = format!("{method} {path} HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n");
stream.write_all(request.as_bytes())?;
let mut discard = Vec::new();
let _ = stream.read_to_end(&mut discard);
Ok(())
}
fn stop_timeout_budget() -> u64 {
STOP_TIMEOUT_SECS + 20
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn capabilities_report_no_hardware_isolation_but_checkpoint_support() {
let backend = DockerBackend::connecting_to_env();
let caps = backend.capabilities();
assert!(!caps.hardware_isolated, "containers share the host kernel");
assert!(caps.checkpoint);
assert!(
!caps.checkpoint_restarts_workload,
"an image commit leaves the running container undisturbed"
);
}
mod blocking_tempdir_shim {
use std::path::{Path, PathBuf};
pub(super) struct TempDir(PathBuf);
impl TempDir {
pub(super) fn new() -> Self {
static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let unique = format!("rzdbb-{:x}-{:x}", std::process::id() as u16, seq);
let path = Path::new("/tmp").join(unique);
std::fs::create_dir_all(&path).expect("create temp dir");
TempDir(path)
}
pub(super) fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
}
#[test]
fn blocking_get_body_dechunks_a_transfer_encoding_chunked_response() {
use std::io::{Read, Write};
use std::os::unix::net::UnixListener;
let dir = blocking_tempdir_shim::TempDir::new();
let sock_path = dir.path().join("docker.sock");
let listener = UnixListener::bind(&sock_path).expect("bind fixture socket");
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept fixture connection");
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf); stream
.write_all(
b"HTTP/1.1 200 OK\r\n\
Content-Type: application/json\r\n\
Transfer-Encoding: chunked\r\n\
\r\n\
5\r\nhello\r\n\
6\r\n world\r\n\
0\r\n\r\n",
)
.expect("write fixture response");
});
let body =
blocking_get_body(&sock_path, "/containers/json").expect("dechunking must succeed");
assert_eq!(body, b"hello world");
server.join().expect("fixture server thread must not panic");
}
#[test]
fn blocking_get_body_honors_content_length_when_present() {
use std::io::{Read, Write};
use std::os::unix::net::UnixListener;
let dir = blocking_tempdir_shim::TempDir::new();
let sock_path = dir.path().join("docker.sock");
let listener = UnixListener::bind(&sock_path).expect("bind fixture socket");
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept fixture connection");
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nhello world!!")
.expect("write fixture response");
});
let body = blocking_get_body(&sock_path, "/containers/json")
.expect("Content-Length read must succeed");
assert_eq!(body, b"hello world!!");
server.join().expect("fixture server thread must not panic");
}
#[test]
fn is_port_bind_conflict_message_matches_known_phrasings() {
assert!(is_port_bind_conflict_message(
"driver failed programming external connectivity: address already in use"
));
assert!(is_port_bind_conflict_message(
"Bind for 0.0.0.0:6379 failed: port is already allocated"
));
assert!(is_port_bind_conflict_message(
"ALREADY ALLOCATED (case-insensitive)"
));
}
#[test]
fn is_port_bind_conflict_message_negative_cases_do_not_match() {
assert!(!is_port_bind_conflict_message("no such image"));
assert!(!is_port_bind_conflict_message("container already stopped"));
assert!(!is_port_bind_conflict_message(""));
}
#[test]
fn split_repo_tag_defaults_to_latest_when_untagged() {
assert_eq!(
split_repo_tag("redis"),
("redis".to_string(), "latest".to_string())
);
assert_eq!(
split_repo_tag("redis:8.6-alpine"),
("redis".to_string(), "8.6-alpine".to_string())
);
}
#[test]
fn split_repo_tag_handles_a_registry_host_with_a_port() {
assert_eq!(
split_repo_tag("localhost:5000/redis"),
("localhost:5000/redis".to_string(), "latest".to_string())
);
assert_eq!(
split_repo_tag("localhost:5000/redis:8.6-alpine"),
("localhost:5000/redis".to_string(), "8.6-alpine".to_string())
);
}
#[test]
fn split_repo_tag_passes_through_a_digest_reference_with_no_tag() {
assert_eq!(
split_repo_tag("redis@sha256:abcdef"),
("redis@sha256:abcdef".to_string(), String::new())
);
}
fn serialize(spec: &ContainerSpec) -> String {
serde_json::to_string(&build_create_body(spec)).unwrap()
}
#[test]
fn build_create_body_includes_port_bindings_on_loopback() {
let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
spec.ports.push(rightsize::model::PortBinding {
host_port: 32768,
guest_port: 6379,
});
let body = serialize(&spec);
assert!(body.contains("\"HostIp\":\"127.0.0.1\""));
assert!(body.contains("\"HostPort\":\"32768\""));
assert!(body.contains("\"6379/tcp\""));
}
#[test]
fn build_create_body_includes_the_run_id_label_and_extra_host() {
let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
let body = serialize(&spec);
assert!(body.contains("\"dev.rightsize.run_id\":\"deadbeef\""));
assert!(body.contains("host.docker.internal:host-gateway"));
}
#[test]
fn build_create_body_honors_mount_read_only_flag() {
let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
spec.mounts
.push(rightsize::model::FileMount::new("/host/a", "/guest/a"));
spec.mounts
.push(rightsize::model::FileMount::new("/host/b", "/guest/b").read_write());
let body = serialize(&spec);
assert!(body.contains("/host/a:/guest/a:ro"));
assert!(body.contains("/host/b:/guest/b:rw"));
}
#[test]
fn build_create_body_omits_memory_when_unset_and_includes_it_when_set() {
let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
assert!(!serialize(&spec).contains("Memory"));
let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
spec.memory_limit_mb = Some(512);
assert!(serialize(&spec).contains("\"Memory\":536870912"));
}
#[test]
fn build_create_body_omits_cmd_when_command_is_none_and_includes_it_when_some() {
let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
assert!(!serialize(&spec).contains("\"Cmd\""));
let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
spec.command = Some(vec![
"redis-server".to_string(),
"--port".to_string(),
"6379".to_string(),
]);
let body = serialize(&spec);
assert!(body.contains("\"Cmd\":[\"redis-server\",\"--port\",\"6379\"]"));
}
#[test]
fn docker_backend_dials_a_unix_socket_never_a_tcp_host() {
let backend = DockerBackend::new(DockerClient::from_docker_host(None));
let path = backend.socket_path_for_test();
assert!(
path.is_absolute(),
"expected an absolute unix socket path, got {path:?}"
);
let path_str = path.to_string_lossy();
assert!(
!path_str.contains(':'),
"a unix socket path must not look like a host:port TCP address — got {path_str}"
);
let backend_env =
DockerBackend::new(DockerClient::from_docker_host(Some("tcp://127.0.0.1:2375")));
let fallback_path = backend_env.socket_path_for_test();
assert!(
!fallback_path.to_string_lossy().contains("2375"),
"a tcp:// DOCKER_HOST must not leak a TCP port into this backend's transport — \
got {fallback_path:?}"
);
}
#[test]
fn keep_alive_spec_gets_the_reuse_label_instead_of_the_run_id_label() {
let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
spec.keep_alive = true;
let body = serialize(&spec);
assert!(body.contains("\"dev.rightsize.reuse\""), "{body}");
assert!(!body.contains("\"dev.rightsize.run_id\""), "{body}");
}
#[test]
fn non_keep_alive_spec_still_gets_the_run_id_label_only() {
let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
let body = serialize(&spec);
assert!(
body.contains("\"dev.rightsize.run_id\":\"deadbeef\""),
"{body}"
);
assert!(!body.contains("\"dev.rightsize.reuse\""), "{body}");
}
#[test]
fn watchdog_kill_command_is_a_plain_docker_rm_dash_f() {
let backend = DockerBackend::new(DockerClient::from_docker_host(None));
assert_eq!(
backend.watchdog_kill_command(),
vec!["docker".to_string(), "rm".to_string(), "-f".to_string()]
);
}
#[test]
fn watchdog_network_kill_command_is_docker_network_rm() {
let backend = DockerBackend::new(DockerClient::from_docker_host(None));
assert_eq!(
backend.watchdog_network_kill_command(),
vec![
"docker".to_string(),
"network".to_string(),
"rm".to_string()
]
);
}
#[test]
fn reuse_label_value_extracts_the_hash_straight_from_an_rz_reuse_name() {
let spec = ContainerSpec::new("rz-reuse-799aad5a3338", "redis:7-alpine", "deadbeef");
assert_eq!(reuse_label_value(&spec), "799aad5a3338");
}
#[test]
fn reuse_label_value_is_twelve_hex_chars_and_deterministic() {
let spec_a = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
let spec_b = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
let spec_c = ContainerSpec::new("rz-x-1", "redis:8.6-alpine", "deadbeef");
let a = reuse_label_value(&spec_a);
let b = reuse_label_value(&spec_b);
let c = reuse_label_value(&spec_c);
assert_eq!(a.len(), 12);
assert!(a.chars().all(|ch| ch.is_ascii_hexdigit()));
assert_eq!(a, b, "same spec.name must hash the same every time");
assert_ne!(a, c, "a different spec.name must hash differently");
}
use std::sync::Arc;
use tokio::net::UnixListener;
mod tempdir_shim {
use std::path::{Path, PathBuf};
pub(super) struct TempDir(PathBuf);
impl TempDir {
pub(super) fn new() -> Self {
static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let unique = format!("rzdb-{:x}-{:x}", std::process::id() as u16, seq);
let path = Path::new("/tmp").join(unique);
std::fs::create_dir_all(&path).expect("create temp dir");
TempDir(path)
}
pub(super) fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
}
async fn multi_request_fixture(
responses: Vec<Vec<u8>>,
) -> (DockerClient, tempdir_shim::TempDir, Arc<Mutex<Vec<String>>>) {
let dir = tempdir_shim::TempDir::new();
let sock_path = dir.path().join("docker.sock");
let listener = UnixListener::bind(&sock_path).expect("bind fixture socket");
let received: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let received_clone = received.clone();
tokio::spawn(async move {
for response in responses {
if let Ok((mut conn, _)) = listener.accept().await {
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
let mut buf = vec![0u8; 4096];
let n = tokio::time::timeout(
std::time::Duration::from_millis(200),
conn.read(&mut buf),
)
.await
.ok()
.and_then(|r| r.ok())
.unwrap_or(0);
let request_line = String::from_utf8_lossy(&buf[..n])
.lines()
.next()
.unwrap_or_default()
.to_string();
received_clone.lock().unwrap().push(request_line);
let _ = conn.write_all(&response).await;
let _ = conn.shutdown().await;
}
}
});
(DockerClient::at_socket(sock_path), dir, received)
}
fn network_list_response(id: &str) -> Vec<u8> {
let payload = format!(r#"[{{"Id":"{id}"}}]"#);
format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
payload.len(),
payload
)
.into_bytes()
}
fn container_list_response(id: &str) -> Vec<u8> {
network_list_response(id)
}
fn empty_list_response() -> Vec<u8> {
let payload = "[]";
format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
payload.len(),
payload
)
.into_bytes()
}
#[tokio::test]
async fn find_running_returns_a_handle_when_the_daemon_lists_a_running_match() {
let (client, _dir, received) =
multi_request_fixture(vec![container_list_response("daemon-c-1")]).await;
let backend = DockerBackend::new(client);
let spec = ContainerSpec::new("rz-reuse-799aad5a3338", "redis:7-alpine", "deadbeef");
let found = backend
.find_running(&spec)
.await
.unwrap()
.expect("must find the running container");
assert_eq!(found.id(), "daemon-c-1");
let requests = received.lock().unwrap().clone();
assert_eq!(requests.len(), 1);
assert!(
requests[0].starts_with("GET /containers/json?filters="),
"{requests:?}"
);
}
#[tokio::test]
async fn find_running_returns_none_when_nothing_matches() {
let (client, _dir, _received) = multi_request_fixture(vec![empty_list_response()]).await;
let backend = DockerBackend::new(client);
let spec = ContainerSpec::new("rz-reuse-799aad5a3338", "redis:7-alpine", "deadbeef");
assert!(backend.find_running(&spec).await.unwrap().is_none());
}
#[tokio::test]
async fn create_maps_a_409_conflict_to_the_typed_name_conflict_error() {
let (client, _dir, _received) = multi_request_fixture(vec![
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
b"HTTP/1.1 409 Conflict\r\nContent-Length: 44\r\n\r\n{\"message\":\"Conflict. Name already in use.\"}".to_vec(),
])
.await;
let backend = DockerBackend::new(client);
let spec = ContainerSpec::new("rz-reuse-799aad5a3338", "redis:7-alpine", "deadbeef");
let err = match backend.create(spec).await {
Ok(_) => panic!("a 409 must surface as an error"),
Err(e) => e,
};
assert!(
matches!(err, rightsize::error::RightsizeError::NameConflict { .. }),
"{err}"
);
}
#[tokio::test]
async fn remove_network_falls_back_to_a_live_lookup_when_the_in_memory_cache_never_saw_it() {
let (client, _dir, received) = multi_request_fixture(vec![
network_list_response("daemon-net-9"),
b"HTTP/1.1 204 No Content\r\n\r\n".to_vec(),
])
.await;
let backend = DockerBackend::new(client);
backend.remove_network("rz-net-deadrun").await.unwrap();
let requests = received.lock().unwrap().clone();
assert_eq!(
requests.len(),
2,
"a cache miss must fall back to a live GET lookup, then DELETE the id it \
finds — got: {requests:?}"
);
assert!(
requests[0].starts_with("GET /networks?filters="),
"{requests:?}"
);
assert!(
requests[1].starts_with("DELETE /networks/daemon-net-9 "),
"the DELETE must target the id the live lookup resolved, not the \
rightsize-side network name: {requests:?}"
);
}
#[tokio::test]
async fn remove_network_uses_the_cached_id_without_a_lookup_when_this_instance_created_it() {
let (client, _dir, received) =
multi_request_fixture(vec![b"HTTP/1.1 204 No Content\r\n\r\n".to_vec()]).await;
let backend = DockerBackend::new(client);
backend
.network_ids
.lock()
.unwrap()
.insert("rz-net-ownrun".to_string(), "daemon-net-owned".to_string());
backend.remove_network("rz-net-ownrun").await.unwrap();
let requests = received.lock().unwrap().clone();
assert_eq!(requests.len(), 1, "{requests:?}");
assert!(
requests[0].starts_with("DELETE /networks/daemon-net-owned "),
"{requests:?}"
);
}
#[tokio::test]
async fn create_checkpoint_posts_commit_with_the_split_repo_and_tag_and_returns_the_ref() {
let (client, _dir, received) = multi_request_fixture(vec![
b"HTTP/1.1 201 Created\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
])
.await;
let backend = DockerBackend::new(client);
let handle = Handle {
id: "daemon-c-checkpoint".to_string(),
spec: ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef"),
};
let checkpoint_ref = backend
.create_checkpoint(&handle, "abc123def456")
.await
.expect("commit must succeed on a 201");
assert_eq!(checkpoint_ref, "rightsize/checkpoint:abc123def456");
let requests = received.lock().unwrap().clone();
assert_eq!(requests.len(), 1, "{requests:?}");
assert!(
requests[0].starts_with("POST /commit?container=daemon-c-checkpoint"),
"{requests:?}"
);
assert!(
requests[0].contains("repo=rightsize%2Fcheckpoint"),
"{requests:?}"
);
assert!(requests[0].contains("tag=abc123def456"), "{requests:?}");
}
#[tokio::test]
async fn create_checkpoint_surfaces_a_daemon_error_as_a_backend_error() {
let (client, _dir, _received) = multi_request_fixture(vec![
b"HTTP/1.1 404 Not Found\r\nContent-Length: 23\r\n\r\n{\"message\":\"no such\"}"
.to_vec(),
])
.await;
let backend = DockerBackend::new(client);
let handle = Handle {
id: "missing-container".to_string(),
spec: ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef"),
};
let err = backend
.create_checkpoint(&handle, "abc123def456")
.await
.expect_err("a 404 must surface as an error");
assert!(
matches!(err, rightsize::error::RightsizeError::Backend(_)),
"{err}"
);
}
#[tokio::test]
async fn remove_checkpoint_deletes_the_image_and_treats_not_found_as_success() {
let (client, _dir, received) = multi_request_fixture(vec![
b"HTTP/1.1 404 Not Found\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
])
.await;
let backend = DockerBackend::new(client);
backend
.remove_checkpoint("rightsize/checkpoint:abc123def456")
.await
.expect("a 404 (already gone) must be treated as success");
let requests = received.lock().unwrap().clone();
assert_eq!(requests.len(), 1, "{requests:?}");
assert!(
requests[0].starts_with("DELETE /images/rightsize/checkpoint:abc123def456"),
"{requests:?}"
);
}
#[tokio::test]
async fn has_checkpoint_returns_true_on_a_200() {
let (client, _dir, received) = multi_request_fixture(vec![
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
])
.await;
let backend = DockerBackend::new(client);
let exists = backend
.has_checkpoint("rightsize/checkpoint:abc123def456")
.await
.expect("a 200 must resolve to Ok(true)");
assert!(exists);
let requests = received.lock().unwrap().clone();
assert_eq!(requests.len(), 1, "{requests:?}");
assert!(
requests[0].starts_with("GET /images/rightsize/checkpoint:abc123def456/json"),
"{requests:?}"
);
}
#[tokio::test]
async fn has_checkpoint_returns_false_on_a_404() {
let (client, _dir, _received) = multi_request_fixture(vec![
b"HTTP/1.1 404 Not Found\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
])
.await;
let backend = DockerBackend::new(client);
let exists = backend
.has_checkpoint("rightsize/checkpoint:gone")
.await
.expect("a 404 must resolve to Ok(false), not an error");
assert!(!exists);
}
#[tokio::test]
async fn has_checkpoint_surfaces_any_other_status_as_an_error() {
let (client, _dir, _received) = multi_request_fixture(vec![
b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 15\r\n\r\n{\"message\":\"x\"}"
.to_vec(),
])
.await;
let backend = DockerBackend::new(client);
let err = backend
.has_checkpoint("rightsize/checkpoint:abc123def456")
.await
.expect_err("a probe failure must surface, never resolve to Ok(false)");
assert!(
matches!(err, rightsize::error::RightsizeError::Backend(_)),
"{err}"
);
}
#[test]
fn docker_cp_in_args_puts_the_host_path_first_and_container_id_colon_path_second() {
assert_eq!(
docker_cp_in_args("daemon-id-1", Path::new("/host/src.txt"), "/guest/dst.txt"),
vec!["cp", "/host/src.txt", "daemon-id-1:/guest/dst.txt"]
);
}
#[test]
fn docker_cp_out_args_puts_the_container_id_colon_path_first_and_host_path_second() {
assert_eq!(
docker_cp_out_args("daemon-id-1", "/guest/src.txt", Path::new("/host/dst.txt")),
vec!["cp", "daemon-id-1:/guest/src.txt", "/host/dst.txt"]
);
}
}