use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use crate::backend::{SandboxBackend, SandboxHandle};
use crate::backends;
use crate::cleanup::{self, CleanupJob};
use crate::error::{Result, RightsizeError};
use crate::free_ports::FreePorts;
use crate::model::{ContainerSpec, ExecResult, FileMount};
use crate::mountable_file::MountableFile;
use crate::network::{Network, NetworkMember};
use crate::run_id::RunId;
use crate::wait::{Wait, WaitStrategy, WaitTarget};
const PORT_BIND_ATTEMPTS: usize = 5;
static NAME_COUNTER: AtomicU64 = AtomicU64::new(0);
static FREE_PORTS: std::sync::OnceLock<FreePorts> = std::sync::OnceLock::new();
fn free_ports() -> &'static FreePorts {
FREE_PORTS.get_or_init(FreePorts::new)
}
type SpecCustomizer = dyn Fn(ContainerSpec, &dyn Fn(u16) -> u16) -> ContainerSpec + Send + Sync;
type PostStartHook = dyn Fn(&ContainerGuard) -> crate::BoxFuture<'_, Result<()>> + Send + Sync;
pub struct Container {
image: String,
env: Vec<(String, String)>,
exposed_ports: Vec<u16>,
command: Option<Vec<String>>,
network: Option<Arc<Network>>,
aliases: Vec<String>,
mounts: Vec<FileMount>,
wait_strategy: Box<dyn WaitStrategy>,
memory_limit_mb: Option<u64>,
backend_override: Option<Arc<dyn SandboxBackend>>,
spec_customizer: Option<Arc<SpecCustomizer>>,
post_start: Option<Arc<PostStartHook>>,
}
impl Container {
pub fn new(image: &str) -> Container {
Container {
image: image.to_string(),
env: Vec::new(),
exposed_ports: Vec::new(),
command: None,
network: None,
aliases: Vec::new(),
mounts: Vec::new(),
wait_strategy: Wait::for_listening_port(),
memory_limit_mb: None,
backend_override: None,
spec_customizer: None,
post_start: None,
}
}
pub fn with_env(mut self, k: &str, v: &str) -> Self {
self.env.push((k.to_string(), v.to_string()));
self
}
pub fn remove_env(mut self, key: &str) -> Self {
self.env.retain(|(k, _)| k != key);
self
}
pub fn env(&self) -> &[(String, String)] {
&self.env
}
pub fn with_exposed_ports(mut self, ports: &[u16]) -> Self {
self.exposed_ports.extend_from_slice(ports);
self
}
pub fn with_command(mut self, cmd: &[&str]) -> Self {
self.command = Some(cmd.iter().map(|s| s.to_string()).collect());
self
}
pub fn with_network(mut self, net: &Arc<Network>) -> Self {
self.network = Some(net.clone());
self
}
pub fn with_network_aliases(mut self, names: &[&str]) -> Self {
self.aliases.extend(names.iter().map(|s| s.to_string()));
self
}
pub fn with_copy_file_to_container(mut self, file: MountableFile, guest_path: &str) -> Self {
self.mounts.push(FileMount::new(file.path(), guest_path));
self
}
pub fn waiting_for(mut self, strategy: impl WaitStrategy + 'static) -> Self {
self.wait_strategy = Box::new(strategy);
self
}
pub fn with_memory_limit(mut self, megabytes: u64) -> Self {
self.memory_limit_mb = Some(megabytes);
self
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn with_backend(mut self, b: Arc<dyn SandboxBackend>) -> Self {
self.backend_override = Some(b);
self
}
pub fn with_spec_customizer(
mut self,
f: impl Fn(ContainerSpec, &dyn Fn(u16) -> u16) -> ContainerSpec + Send + Sync + 'static,
) -> Self {
self.spec_customizer = Some(Arc::new(f));
self
}
pub fn with_post_start(
mut self,
f: impl for<'a> Fn(&'a ContainerGuard) -> crate::BoxFuture<'a, Result<()>>
+ Send
+ Sync
+ 'static,
) -> Self {
self.post_start = Some(Arc::new(f));
self
}
fn active_backend(&self) -> Arc<dyn SandboxBackend> {
match &self.backend_override {
Some(b) => b.clone(),
None => backends::active(),
}
}
fn describe(id: &str, image: &str) -> String {
format!("container(image={image}, id={id})")
}
pub async fn start(self) -> Result<ContainerGuard> {
let backend = self.active_backend();
if let Some(net) = &self.network {
backend.ensure_network(net.id()).await?;
}
let (handle, mapped_ports) = create_started_container(
&backend,
&self.image,
&self.env,
&self.command,
&self.exposed_ports,
&self.mounts,
self.network.as_deref(),
&self.aliases,
self.memory_limit_mb,
self.spec_customizer.as_deref(),
)
.await?;
let name = handle.id().to_string();
let guard = ContainerGuard {
handle: Some(handle),
backend: backend.clone(),
mapped_ports: Mutex::new(mapped_ports),
network: self.network.clone(),
image: self.image.clone(),
exposed_ports: self.exposed_ports.clone(),
name,
};
if let Err(e) = link_register_and_wait(
&guard,
&backend,
self.network.as_deref(),
&self.aliases,
self.wait_strategy.as_ref(),
)
.await
{
let _ = guard.stop().await;
return Err(e);
}
if let Some(post_start) = &self.post_start {
if let Err(e) = post_start(&guard).await {
let _ = guard.stop().await;
return Err(e);
}
}
Ok(guard)
}
}
#[allow(clippy::too_many_arguments)]
async fn create_started_container(
backend: &Arc<dyn SandboxBackend>,
image: &str,
env: &[(String, String)],
command: &Option<Vec<String>>,
exposed_ports: &[u16],
mounts: &[FileMount],
network: Option<&Network>,
aliases: &[String],
memory_limit_mb: Option<u64>,
spec_customizer: Option<&SpecCustomizer>,
) -> Result<(Box<dyn SandboxHandle>, Vec<(u16, u16)>)> {
let mut last_conflict: Option<RightsizeError> = None;
for _ in 0..PORT_BIND_ATTEMPTS {
let mapped_ports = allocate_ports(exposed_ports)?;
let seq = NAME_COUNTER.fetch_add(1, Ordering::SeqCst);
let name = format!("rz-{}-{seq}", RunId::value());
let mut spec = ContainerSpec {
name: name.clone(),
image: image.to_string(),
env: env.to_vec(),
command: command.clone(),
ports: mapped_ports
.iter()
.map(|&(guest_port, host_port)| crate::model::PortBinding {
host_port,
guest_port,
})
.collect(),
mounts: mounts.to_vec(),
network_id: network.map(|n| n.id().to_string()),
aliases: aliases.to_vec(),
run_id: RunId::value().to_string(),
memory_limit_mb,
};
if let Some(customizer) = spec_customizer {
let lookup: std::collections::HashMap<u16, u16> =
mapped_ports.iter().copied().collect();
let mapped_fn = move |guest: u16| -> u16 {
*lookup
.get(&guest)
.expect("customizer looked up an unexposed port")
};
spec = customizer(spec, &mapped_fn);
}
spec.env = dedup_env_last_wins(spec.env);
let handle = backend.create(spec).await?;
match backend.start(handle.as_ref()).await {
Ok(()) => return Ok((handle, mapped_ports)),
Err(e) => {
let _ = backend.stop(handle.as_ref()).await;
let _ = backend.remove(handle.as_ref()).await;
release_ports(&mapped_ports);
if is_port_bind_conflict(&e) {
last_conflict = Some(e);
continue;
}
return Err(e);
}
}
}
Err(RightsizeError::Backend(format!(
"Could not bind free host ports for {} after {PORT_BIND_ATTEMPTS} attempts — another \
process kept grabbing the allocated ports first; if this persists, check for a port \
scanner/leaked process racing the allocator on this host{}",
Container::describe("<unstarted>", image),
last_conflict
.map(|c| format!(" (last conflict: {c})"))
.unwrap_or_default(),
)))
}
fn dedup_env_last_wins(env: Vec<(String, String)>) -> Vec<(String, String)> {
let mut order: Vec<String> = Vec::new();
let mut values: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for (k, v) in env {
if !values.contains_key(&k) {
order.push(k.clone());
}
values.insert(k, v);
}
order
.into_iter()
.map(|k| {
let v = values.remove(&k).expect("key was just recorded in order");
(k, v)
})
.collect()
}
fn allocate_ports(exposed_ports: &[u16]) -> Result<Vec<(u16, u16)>> {
let mut mapped = Vec::with_capacity(exposed_ports.len());
for &guest_port in exposed_ports {
match free_ports().allocate() {
Ok(host_port) => mapped.push((guest_port, host_port)),
Err(e) => {
release_ports(&mapped);
return Err(e);
}
}
}
Ok(mapped)
}
fn release_ports(mapped_ports: &[(u16, u16)]) {
for &(_, host_port) in mapped_ports {
free_ports().release(host_port);
}
}
pub(crate) fn is_port_bind_conflict(e: &RightsizeError) -> bool {
let mut current: Option<&RightsizeError> = Some(e);
while let Some(err) = current {
if matches!(err, RightsizeError::PortBindConflict { .. }) {
return true;
}
let msg = err.to_string().to_lowercase();
if msg.contains("address already in use") || msg.contains("already allocated") {
return true;
}
current = match err {
RightsizeError::PortBindConflict {
source: Some(s), ..
} => Some(s.as_ref()),
_ => None,
};
}
false
}
async fn link_register_and_wait(
guard: &ContainerGuard,
backend: &Arc<dyn SandboxBackend>,
network: Option<&Network>,
aliases: &[String],
wait_strategy: &dyn WaitStrategy,
) -> Result<()> {
if let Some(net) = network {
let links = net.links_for_new_member();
backend
.install_network_links(guard.handle_ref(), &links)
.await?;
}
if let Some(net) = network {
net.register(guard.as_network_member(), aliases.to_vec(), backend.clone());
}
let target = GuardWaitTarget { guard };
wait_strategy.wait_until_ready(&target).await
}
struct GuardWaitTarget<'a> {
guard: &'a ContainerGuard,
}
#[async_trait::async_trait]
impl WaitTarget for GuardWaitTarget<'_> {
fn host(&self) -> &str {
self.guard.host()
}
fn mapped_port(&self, guest_port: u16) -> u16 {
self.guard.get_mapped_port(guest_port).unwrap_or(guest_port)
}
fn exposed_guest_ports(&self) -> Vec<u16> {
self.guard.exposed_ports.clone()
}
async fn current_logs(&self) -> String {
self.guard.logs().await.unwrap_or_default()
}
fn describe(&self) -> String {
self.guard.describe()
}
}
pub struct ContainerGuard {
handle: Option<Box<dyn SandboxHandle>>,
backend: Arc<dyn SandboxBackend>,
mapped_ports: Mutex<Vec<(u16, u16)>>,
network: Option<Arc<Network>>,
image: String,
exposed_ports: Vec<u16>,
name: String,
}
impl ContainerGuard {
fn handle_ref(&self) -> &dyn SandboxHandle {
self.handle
.as_deref()
.expect("ContainerGuard invariant: handle is only None after being consumed by stop()")
}
fn as_network_member(&self) -> Arc<dyn NetworkMember> {
Arc::new(GuardMemberSnapshot {
mapped_ports: self
.mapped_ports
.lock()
.expect("mapped_ports mutex poisoned")
.clone(),
})
}
pub fn network(&self) -> Option<&Arc<Network>> {
self.network.as_ref()
}
pub fn host(&self) -> &str {
"127.0.0.1"
}
pub fn get_mapped_port(&self, guest_port: u16) -> Result<u16> {
let mapped = self
.mapped_ports
.lock()
.expect("mapped_ports mutex poisoned");
if let Some(&(_, host_port)) = mapped.iter().find(|&&(g, _)| g == guest_port) {
return Ok(host_port);
}
if !self.is_running() {
Err(RightsizeError::Backend(format!(
"Cannot get mapped port {guest_port} on {}: the container is not running — call \
start() first, or check that it did not stop/fail after start()",
self.describe()
)))
} else {
Err(RightsizeError::Backend(format!(
"Port {guest_port} is not exposed on {} — call with_exposed_ports({guest_port}) \
before start(), or check exposed_ports for the port you actually declared",
self.describe()
)))
}
}
pub async fn logs(&self) -> Result<String> {
self.backend.logs(self.require_handle()?).await
}
pub async fn exec(&self, cmd: &[&str]) -> Result<ExecResult> {
let cmd: Vec<String> = cmd.iter().map(|s| s.to_string()).collect();
self.backend.exec(self.require_handle()?, &cmd).await
}
pub async fn follow_output(
&self,
consumer: impl Fn(String) + Send + Sync + 'static,
) -> Result<crate::backend::FollowHandle> {
self.backend
.follow_logs(self.require_handle()?, Box::new(consumer))
.await
}
pub fn is_running(&self) -> bool {
self.handle.is_some()
}
fn require_handle(&self) -> Result<&dyn SandboxHandle> {
self.handle.as_deref().ok_or_else(|| {
RightsizeError::Backend(format!(
"{} is not running — call start() first",
self.describe()
))
})
}
fn describe(&self) -> String {
Container::describe(&self.name, &self.image)
}
pub async fn stop(mut self) -> Result<()> {
self.stop_inner().await;
Ok(())
}
async fn stop_inner(&mut self) {
let Some(handle) = self.handle.take() else {
return; };
let _ = self.backend.stop(handle.as_ref()).await;
let _ = self.backend.remove(handle.as_ref()).await;
let mut mapped = self
.mapped_ports
.lock()
.expect("mapped_ports mutex poisoned");
for &(_, host_port) in mapped.iter() {
free_ports().release(host_port);
}
mapped.clear();
}
}
struct GuardMemberSnapshot {
mapped_ports: Vec<(u16, u16)>,
}
impl NetworkMember for GuardMemberSnapshot {
fn is_running(&self) -> bool {
true }
fn mapped_ports(&self) -> Vec<(u16, u16)> {
self.mapped_ports.clone()
}
}
impl Drop for ContainerGuard {
fn drop(&mut self) {
let Some(handle) = self.handle.take() else {
return; };
if let Ok(mut mapped) = self.mapped_ports.lock() {
for &(_, host_port) in mapped.iter() {
free_ports().release(host_port);
}
mapped.clear();
}
cleanup::enqueue(CleanupJob {
backend: self.backend.clone(),
container_id: handle.id().to_string(),
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::wait::{WaitStrategy, WaitTarget};
use std::sync::Mutex as StdMutex;
use std::time::Duration;
fn expect_start_err(result: Result<ContainerGuard>, msg: &str) -> RightsizeError {
match result {
Ok(_) => panic!("{msg}: expected an error, got Ok"),
Err(e) => e,
}
}
struct FakeHandle {
id: String,
spec: ContainerSpec,
}
impl SandboxHandle for FakeHandle {
fn id(&self) -> &str {
&self.id
}
fn spec(&self) -> &ContainerSpec {
&self.spec
}
}
struct ReadyImmediately;
#[async_trait::async_trait]
impl WaitStrategy for ReadyImmediately {
async fn wait_until_ready(&self, _target: &dyn WaitTarget) -> Result<()> {
Ok(())
}
fn with_startup_timeout(self: Box<Self>, _timeout: Duration) -> Box<dyn WaitStrategy> {
self
}
}
struct NeverReady {
precaptured_port: Arc<StdMutex<Option<u16>>>,
}
#[async_trait::async_trait]
impl WaitStrategy for NeverReady {
async fn wait_until_ready(&self, target: &dyn WaitTarget) -> Result<()> {
*self.precaptured_port.lock().unwrap() = Some(target.mapped_port(6379));
Err(RightsizeError::ContainerLaunch("never ready".to_string()))
}
fn with_startup_timeout(self: Box<Self>, _timeout: Duration) -> Box<dyn WaitStrategy> {
self
}
}
#[derive(Default)]
struct FakeBackendState {
created: Vec<ContainerSpec>,
started: Vec<String>,
stopped: Vec<String>,
installed_links: Vec<(String, Vec<crate::backend::NetworkLink>)>,
}
struct FakeBackend {
state: StdMutex<FakeBackendState>,
fail_install_network_links: bool,
}
impl FakeBackend {
fn new() -> Arc<Self> {
Arc::new(Self {
state: StdMutex::new(FakeBackendState::default()),
fail_install_network_links: false,
})
}
fn failing_install_network_links() -> Arc<Self> {
Arc::new(Self {
state: StdMutex::new(FakeBackendState::default()),
fail_install_network_links: true,
})
}
}
#[async_trait::async_trait]
impl SandboxBackend for FakeBackend {
fn name(&self) -> &str {
"fake"
}
fn supports_native_networks(&self) -> bool {
false
}
async fn create(&self, spec: ContainerSpec) -> Result<Box<dyn SandboxHandle>> {
self.state.lock().unwrap().created.push(spec.clone());
Ok(Box::new(FakeHandle {
id: spec.name.clone(),
spec,
}))
}
async fn start(&self, handle: &dyn SandboxHandle) -> Result<()> {
self.state
.lock()
.unwrap()
.started
.push(handle.id().to_string());
Ok(())
}
async fn stop(&self, handle: &dyn SandboxHandle) -> Result<()> {
self.state
.lock()
.unwrap()
.stopped
.push(handle.id().to_string());
Ok(())
}
async fn remove(&self, _handle: &dyn SandboxHandle) -> Result<()> {
Ok(())
}
async fn exec(&self, _handle: &dyn SandboxHandle, cmd: &[String]) -> Result<ExecResult> {
Ok(ExecResult {
exit_code: 0,
stdout: cmd.join(" "),
stderr: String::new(),
})
}
async fn logs(&self, _handle: &dyn SandboxHandle) -> Result<String> {
Ok(String::new())
}
async fn follow_logs(
&self,
_handle: &dyn SandboxHandle,
_consumer: Box<dyn Fn(String) + Send + Sync>,
) -> Result<crate::backend::FollowHandle> {
unimplemented!("not exercised by this test suite")
}
async fn ensure_network(&self, _network_id: &str) -> Result<()> {
Ok(())
}
async fn remove_network(&self, _network_id: &str) -> Result<()> {
Ok(())
}
async fn install_network_links(
&self,
handle: &dyn SandboxHandle,
links: &[crate::backend::NetworkLink],
) -> Result<()> {
if self.fail_install_network_links {
return Err(RightsizeError::unsupported_with_remedy(
"network links (no nc in image; try docker)",
"fake",
"run with a different backend",
));
}
if !links.is_empty() {
self.state
.lock()
.unwrap()
.installed_links
.push((handle.id().to_string(), links.to_vec()));
}
Ok(())
}
fn cleanup_sync(&self, _container_id: &str) {}
}
fn container_on(backend: &Arc<FakeBackend>) -> Container {
Container::new("redis:8.6-alpine")
.with_backend(backend.clone())
.waiting_for(ReadyImmediately)
}
#[tokio::test]
async fn u1_start_allocates_ports_creates_spec_waits_and_maps_ports() {
let backend = FakeBackend::new();
let c = container_on(&backend)
.with_exposed_ports(&[6379])
.with_env("A", "1");
let guard = c.start().await.expect("start must succeed");
let spec = {
let state = backend.state.lock().unwrap();
assert_eq!(state.created.len(), 1);
state.created[0].clone()
};
assert_eq!(spec.image, "redis:8.6-alpine");
assert_eq!(spec.env, vec![("A".to_string(), "1".to_string())]);
assert_eq!(spec.ports.len(), 1);
assert_eq!(spec.ports[0].guest_port, 6379);
assert!(spec.ports[0].host_port > 0);
assert_eq!(
guard.get_mapped_port(6379).unwrap(),
spec.ports[0].host_port
);
assert!(guard.is_running());
guard.stop().await.unwrap();
}
#[tokio::test]
async fn get_mapped_port_reports_not_running_after_stop_clears_the_mappings() {
let backend = FakeBackend::new();
let c = container_on(&backend).with_exposed_ports(&[6379]);
let guard = c.start().await.unwrap();
assert!(guard.get_mapped_port(6379).unwrap() > 0);
guard.stop().await.unwrap();
let backend2 = FakeBackend::new();
let c2 = container_on(&backend2).with_exposed_ports(&[6379]);
let mut guard2 = c2.start().await.unwrap();
guard2.stop_inner().await;
let err = guard2.get_mapped_port(6379).unwrap_err().to_string();
assert!(err.contains("not running"), "{err}");
assert!(!err.contains("not exposed"), "{err}");
}
#[tokio::test]
async fn get_mapped_port_reports_not_exposed_for_an_undeclared_port() {
let backend = FakeBackend::new();
let c = container_on(&backend).with_exposed_ports(&[6379]);
let guard = c.start().await.unwrap();
let err = guard.get_mapped_port(9999).unwrap_err().to_string();
assert!(err.contains("not exposed"), "{err}");
guard.stop().await.unwrap();
}
#[tokio::test]
async fn u2_starting_on_a_network_installs_links_to_running_siblings() {
let backend = FakeBackend::new();
let net = Arc::new(Network::new_network());
let stub = container_on(&backend)
.with_exposed_ports(&[8888])
.with_network(&net)
.with_network_aliases(&["configuration-stub"]);
let stub_guard = stub.start().await.unwrap();
let app = container_on(&backend)
.with_exposed_ports(&[8080])
.with_network(&net);
let app_guard = app.start().await.unwrap();
let (consumer_id, links) = {
let state = backend.state.lock().unwrap();
assert_eq!(state.installed_links.len(), 1);
state.installed_links[0].clone()
};
assert_eq!(
consumer_id,
backend.state.lock().unwrap().created.last().unwrap().name
);
assert_eq!(
links,
vec![crate::backend::NetworkLink {
alias: "configuration-stub".to_string(),
guest_port: 8888,
target_host_port: stub_guard.get_mapped_port(8888).unwrap(),
}]
);
assert_eq!(
net.resolve("configuration-stub", 8888).unwrap(),
"configuration-stub:8888"
);
assert!(net.resolve("nope", 1).is_err());
app_guard.stop().await.unwrap();
stub_guard.stop().await.unwrap();
}
#[tokio::test]
async fn single_container_on_network_installs_no_links_but_is_registered() {
let backend = FakeBackend::new();
let net = Arc::new(Network::new_network());
let solo = container_on(&backend)
.with_exposed_ports(&[9999])
.with_network(&net)
.with_network_aliases(&["solo"]);
let solo_guard = solo.start().await.unwrap();
assert!(
backend.state.lock().unwrap().installed_links.is_empty(),
"a lone container must not link to itself"
);
let joiner = container_on(&backend)
.with_exposed_ports(&[8080])
.with_network(&net);
let joiner_guard = joiner.start().await.unwrap();
let (_, links) = {
let state = backend.state.lock().unwrap();
assert_eq!(state.installed_links.len(), 1);
state.installed_links[0].clone()
};
assert_eq!(
links,
vec![crate::backend::NetworkLink {
alias: "solo".to_string(),
guest_port: 9999,
target_host_port: solo_guard.get_mapped_port(9999).unwrap(),
}]
);
joiner_guard.stop().await.unwrap();
solo_guard.stop().await.unwrap();
}
#[tokio::test]
async fn u5_wait_strategy_failure_stops_the_container_and_releases_ports() {
let backend = FakeBackend::new();
let precaptured_port = Arc::new(StdMutex::new(None));
let c = Container::new("redis:8.6-alpine")
.with_backend(backend.clone())
.waiting_for(NeverReady {
precaptured_port: precaptured_port.clone(),
})
.with_exposed_ports(&[6379]);
let err = expect_start_err(c.start().await, "wait strategy must fail start()");
assert!(err.to_string().contains("never ready"), "{err}");
let name = backend.state.lock().unwrap().created[0].name.clone();
assert!(
backend.state.lock().unwrap().stopped.contains(&name),
"started container must be stopped when the wait strategy fails"
);
let port = precaptured_port
.lock()
.unwrap()
.expect("wait strategy must have observed a real mapped port");
assert!(port > 0);
assert!(
!free_ports().issued_view().contains(&port),
"port {port} must be released by the wait-strategy-failure cleanup path"
);
}
#[tokio::test]
async fn u5_install_network_links_failure_stops_the_container() {
let backend = FakeBackend::failing_install_network_links();
let net = Arc::new(Network::new_network());
let c = container_on(&backend)
.with_exposed_ports(&[8080])
.with_network(&net);
let err = expect_start_err(
c.start().await,
"install_network_links failure must propagate",
);
assert!(err.to_string().contains("nc"), "{err}");
let created = backend.state.lock().unwrap().created[0].clone();
assert!(
backend
.state
.lock()
.unwrap()
.stopped
.contains(&created.name),
"started container must be stopped on link-install failure"
);
let port = created
.ports
.first()
.expect("spec must carry the allocated host port")
.host_port;
assert!(
!free_ports().issued_view().contains(&port),
"port {port} must be released when install_network_links fails, \
not just when the wait strategy fails"
);
}
#[tokio::test]
async fn u7_stop_is_idempotent_across_the_stop_then_drop_sequence() {
let backend = FakeBackend::new();
let c = container_on(&backend).with_exposed_ports(&[6379]);
let guard = c.start().await.unwrap();
let name = backend.state.lock().unwrap().created[0].name.clone();
let mapped_port = guard.get_mapped_port(6379).unwrap();
guard.stop().await.unwrap();
assert_eq!(
backend
.state
.lock()
.unwrap()
.stopped
.iter()
.filter(|n| **n == name)
.count(),
1,
"backend.stop must be called exactly once"
);
assert!(
!free_ports().issued_view().contains(&mapped_port),
"port must be released by stop()"
);
}
#[tokio::test]
async fn stop_before_start_is_a_no_op() {
let backend = FakeBackend::new();
let c = container_on(&backend).with_exposed_ports(&[6379]);
let mut guard = c.start().await.unwrap();
let name = backend.state.lock().unwrap().created[0].name.clone();
guard.stop_inner().await;
assert_eq!(
backend
.state
.lock()
.unwrap()
.stopped
.iter()
.filter(|n| **n == name)
.count(),
1
);
guard.stop_inner().await; assert_eq!(
backend
.state
.lock()
.unwrap()
.stopped
.iter()
.filter(|n| **n == name)
.count(),
1,
"a second stop must not re-call backend.stop"
);
assert!(!guard.is_running());
}
struct PortConflictBackend {
fail_first: usize,
conflict: Box<dyn Fn(u16) -> RightsizeError + Send + Sync>,
created: StdMutex<Vec<ContainerSpec>>,
started_ports: StdMutex<Vec<u16>>,
start_attempts: std::sync::atomic::AtomicUsize,
}
impl PortConflictBackend {
fn new(fail_first: usize) -> Arc<Self> {
Self::with_conflict(fail_first, |port| {
RightsizeError::Backend(format!(
"driver failed programming external connectivity: failed to bind host port 127.0.0.1:{port}/tcp: address already in use"
))
})
}
fn with_conflict(
fail_first: usize,
conflict: impl Fn(u16) -> RightsizeError + Send + Sync + 'static,
) -> Arc<Self> {
Arc::new(Self {
fail_first,
conflict: Box::new(conflict),
created: StdMutex::new(Vec::new()),
started_ports: StdMutex::new(Vec::new()),
start_attempts: std::sync::atomic::AtomicUsize::new(0),
})
}
fn create_count(&self) -> usize {
self.created.lock().unwrap().len()
}
}
#[async_trait::async_trait]
impl SandboxBackend for PortConflictBackend {
fn name(&self) -> &str {
"port-conflict"
}
fn supports_native_networks(&self) -> bool {
false
}
async fn create(&self, spec: ContainerSpec) -> Result<Box<dyn SandboxHandle>> {
self.created.lock().unwrap().push(spec.clone());
Ok(Box::new(FakeHandle {
id: spec.name.clone(),
spec,
}))
}
async fn start(&self, handle: &dyn SandboxHandle) -> Result<()> {
let attempt = self.start_attempts.fetch_add(1, Ordering::SeqCst) + 1;
let port = handle.spec().ports[0].host_port;
self.started_ports.lock().unwrap().push(port);
if attempt <= self.fail_first {
return Err((self.conflict)(port));
}
Ok(())
}
async fn stop(&self, _handle: &dyn SandboxHandle) -> Result<()> {
Ok(())
}
async fn remove(&self, _handle: &dyn SandboxHandle) -> Result<()> {
Ok(())
}
async fn exec(&self, _handle: &dyn SandboxHandle, _cmd: &[String]) -> Result<ExecResult> {
Ok(ExecResult {
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
})
}
async fn logs(&self, _handle: &dyn SandboxHandle) -> Result<String> {
Ok(String::new())
}
async fn follow_logs(
&self,
_handle: &dyn SandboxHandle,
_consumer: Box<dyn Fn(String) + Send + Sync>,
) -> Result<crate::backend::FollowHandle> {
unimplemented!()
}
async fn ensure_network(&self, _network_id: &str) -> Result<()> {
Ok(())
}
async fn remove_network(&self, _network_id: &str) -> Result<()> {
Ok(())
}
fn cleanup_sync(&self, _container_id: &str) {}
}
#[tokio::test]
async fn u6_start_retries_with_fresh_host_ports_on_a_bind_conflict() {
let backend = PortConflictBackend::new(2);
let c = Container::new("redis:8.6-alpine")
.with_backend(backend.clone())
.waiting_for(ReadyImmediately)
.with_exposed_ports(&[6379]);
let guard = c.start().await.expect("must eventually succeed");
assert!(guard.is_running());
assert_eq!(
backend.create_count(),
3,
"each attempt recreates the container"
);
let started_ports = backend.started_ports.lock().unwrap().clone();
assert_eq!(started_ports.len(), 3, "start attempted three times");
let distinct: std::collections::HashSet<u16> = started_ports.iter().copied().collect();
assert_eq!(
distinct.len(),
started_ports.len(),
"ports are reallocated per attempt, not reused after a conflict"
);
guard.stop().await.unwrap();
}
#[tokio::test]
async fn u6_start_retries_on_the_typed_port_bind_conflict_bare_or_nested() {
let bare = PortConflictBackend::with_conflict(1, |port| RightsizeError::PortBindConflict {
message: format!("could not bind host port {port}"),
source: None,
});
let c = Container::new("redis:8.6-alpine")
.with_backend(bare.clone())
.waiting_for(ReadyImmediately)
.with_exposed_ports(&[6379]);
let guard = c
.start()
.await
.expect("must retry exactly once for the typed exception");
assert!(guard.is_running());
assert_eq!(bare.create_count(), 2);
guard.stop().await.unwrap();
let nested =
PortConflictBackend::with_conflict(1, |port| RightsizeError::PortBindConflict {
message: "start failed".to_string(),
source: Some(Box::new(RightsizeError::PortBindConflict {
message: "io error".to_string(),
source: Some(Box::new(RightsizeError::PortBindConflict {
message: format!("could not bind host port {port}"),
source: None,
})),
})),
});
let c = Container::new("redis:8.6-alpine")
.with_backend(nested.clone())
.waiting_for(ReadyImmediately)
.with_exposed_ports(&[6379]);
let guard = c
.start()
.await
.expect("must unwrap to find a nested typed exception");
assert!(guard.is_running());
assert_eq!(nested.create_count(), 2);
guard.stop().await.unwrap();
}
#[tokio::test]
async fn u6_truth_table_known_phrasings_retry_negative_does_not() {
let phrasings = [
"address already in use",
"port is already allocated",
"bind: address already in use",
"Bind for 0.0.0.0:32770 failed: PORT IS ALREADY ALLOCATED",
];
for phrasing in phrasings {
let backend = PortConflictBackend::with_conflict(1, move |_port| {
RightsizeError::Backend(phrasing.to_string())
});
let c = Container::new("redis:8.6-alpine")
.with_backend(backend.clone())
.waiting_for(ReadyImmediately)
.with_exposed_ports(&[6379]);
let guard = c.start().await.unwrap_or_else(|e| {
panic!("must retry and succeed for phrasing '{phrasing}': {e}")
});
assert!(guard.is_running());
assert_eq!(
backend.create_count(),
2,
"must retry exactly once for phrasing: {phrasing}"
);
guard.stop().await.unwrap();
}
let boom = PortConflictBackend::with_conflict(99, |_port| {
RightsizeError::Backend("boom".to_string())
});
let c = Container::new("redis:8.6-alpine")
.with_backend(boom.clone())
.waiting_for(ReadyImmediately)
.with_exposed_ports(&[6379]);
let err = expect_start_err(
c.start().await,
"a non-conflict exception must fail fast, no retry",
);
assert_eq!(err.to_string(), "boom");
assert_eq!(
boom.create_count(),
1,
"a non-conflict exception must fail fast, no retry"
);
}
#[tokio::test]
async fn u4_and_u6_start_gives_up_after_the_retry_budget_is_exhausted_and_releases_every_attempts_ports()
{
let backend = PortConflictBackend::new(99);
let c = Container::new("redis:8.6-alpine")
.with_backend(backend.clone())
.waiting_for(ReadyImmediately)
.with_exposed_ports(&[6379]);
let err = expect_start_err(c.start().await, "must give up after the retry budget");
assert!(err.to_string().contains("free host ports"), "{err}");
let started_ports = backend.started_ports.lock().unwrap().clone();
assert_eq!(
started_ports.len(),
PORT_BIND_ATTEMPTS,
"all attempts must have been tried"
);
let distinct: std::collections::HashSet<u16> = started_ports.iter().copied().collect();
assert_eq!(
distinct.len(),
PORT_BIND_ATTEMPTS,
"each retry must allocate a fresh port"
);
let issued = free_ports().issued_view();
for port in started_ports {
assert!(
!issued.contains(&port),
"port {port} from a discarded attempt must be released — mutation-verified: this is the U4 port-release gate"
);
}
}
#[tokio::test]
async fn with_memory_limit_carries_through_to_the_container_spec() {
let backend = FakeBackend::new();
let limited = container_on(&backend)
.with_exposed_ports(&[6379])
.with_memory_limit(1024);
let guard = limited.start().await.unwrap();
assert_eq!(
backend.state.lock().unwrap().created[0].memory_limit_mb,
Some(1024)
);
guard.stop().await.unwrap();
let unset = container_on(&backend).with_exposed_ports(&[6379]);
let guard = unset.start().await.unwrap();
assert_eq!(
backend
.state
.lock()
.unwrap()
.created
.last()
.unwrap()
.memory_limit_mb,
None
);
guard.stop().await.unwrap();
}
#[test]
fn dedup_env_last_wins_keeps_first_position_but_last_value() {
let env = vec![
("A".to_string(), "1".to_string()),
("B".to_string(), "2".to_string()),
("A".to_string(), "override".to_string()),
("C".to_string(), "3".to_string()),
("B".to_string(), "final".to_string()),
];
let deduped = dedup_env_last_wins(env);
assert_eq!(
deduped,
vec![
("A".to_string(), "override".to_string()),
("B".to_string(), "final".to_string()),
("C".to_string(), "3".to_string()),
],
"A and B must keep their FIRST-occurrence position; each must carry its LAST value"
);
}
#[test]
fn dedup_env_last_wins_is_a_no_op_on_already_unique_keys() {
let env = vec![
("X".to_string(), "1".to_string()),
("Y".to_string(), "2".to_string()),
];
assert_eq!(dedup_env_last_wins(env.clone()), env);
}
#[tokio::test]
async fn duplicate_with_env_calls_resolve_last_wins_in_the_spec_reaching_the_backend() {
let backend = FakeBackend::new();
let c = container_on(&backend)
.with_exposed_ports(&[6379])
.with_env("MODE", "first")
.with_env("OTHER", "x")
.with_env("MODE", "second");
let guard = c.start().await.unwrap();
let spec = backend.state.lock().unwrap().created[0].clone();
assert_eq!(
spec.env,
vec![
("MODE".to_string(), "second".to_string()),
("OTHER".to_string(), "x".to_string()),
],
"MODE must appear exactly once, in its first-occurrence position, with its last value"
);
guard.stop().await.unwrap();
}
#[tokio::test]
async fn a_spec_customizer_overriding_an_existing_key_also_resolves_last_wins() {
let backend = FakeBackend::new();
let c = container_on(&backend)
.with_exposed_ports(&[6379])
.with_env("KAFKA_ADVERTISED_LISTENERS", "PLACEHOLDER")
.with_spec_customizer(|mut spec, _mapped| {
spec.env.push((
"KAFKA_ADVERTISED_LISTENERS".to_string(),
"PLAINTEXT://127.0.0.1:9999".to_string(),
));
spec
});
let guard = c.start().await.unwrap();
let spec = backend.state.lock().unwrap().created[0].clone();
assert_eq!(
spec.env,
vec![(
"KAFKA_ADVERTISED_LISTENERS".to_string(),
"PLAINTEXT://127.0.0.1:9999".to_string()
)],
"the customizer's later push must win, deduped to a single entry"
);
guard.stop().await.unwrap();
}
#[tokio::test]
async fn u3_exec_and_mapped_port_require_a_running_container() {
let backend = FakeBackend::new();
let c = container_on(&backend).with_exposed_ports(&[6379]);
let mut guard = c.start().await.unwrap();
guard.stop_inner().await;
assert!(guard.exec(&["ls"]).await.is_err());
assert!(guard.get_mapped_port(6379).is_err());
}
#[tokio::test]
async fn exec_returns_the_backends_result() {
let backend = FakeBackend::new();
let c = container_on(&backend);
let guard = c.start().await.unwrap();
let result = guard.exec(&["ls", "-la"]).await.unwrap();
assert_eq!(result.stdout, "ls -la");
guard.stop().await.unwrap();
}
#[tokio::test]
async fn dropping_a_guard_without_stop_releases_its_ports_synchronously() {
let backend = FakeBackend::new();
let c = container_on(&backend).with_exposed_ports(&[6379]);
let guard = c.start().await.unwrap();
let port = guard.get_mapped_port(6379).unwrap();
assert!(free_ports().issued_view().contains(&port));
drop(guard);
assert!(
!free_ports().issued_view().contains(&port),
"Drop must release mapped ports synchronously even without an explicit stop()"
);
}
}