Skip to main content

reinhardt_commands/local_infra/
docker.rs

1//! Docker API adapter for local infrastructure.
2
3use async_trait::async_trait;
4use bollard::Docker;
5use bollard::errors::Error as BollardError;
6use bollard::models::{ContainerCreateBody, HostConfig, PortBinding};
7use bollard::query_parameters::{
8	CreateContainerOptionsBuilder, CreateImageOptionsBuilder, ListContainersOptionsBuilder,
9	RemoveContainerOptionsBuilder, StartContainerOptions,
10};
11use futures_util::StreamExt;
12use std::collections::HashMap;
13use std::sync::{Arc, Mutex};
14
15/// Docker API operation error.
16#[derive(Debug, thiserror::Error)]
17pub enum DockerError {
18	/// Docker backend returned an error.
19	#[error("{0}")]
20	Backend(String),
21}
22
23impl From<BollardError> for DockerError {
24	fn from(err: BollardError) -> Self {
25		Self::Backend(err.to_string())
26	}
27}
28
29/// Result type for Docker API operations.
30pub(crate) type DockerResult<T> = Result<T, DockerError>;
31
32/// Runtime request for a detached local infrastructure container.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct DockerRunSpec {
35	/// Container name.
36	pub name: String,
37	/// Image reference.
38	pub image: String,
39	/// Host port to bind.
40	pub host_port: u16,
41	/// Container port to expose.
42	pub container_port: u16,
43	/// Container environment variables.
44	pub env: Vec<(String, String)>,
45}
46
47/// One Docker API operation captured by a fake engine.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum DockerCall {
50	/// Container existence lookup.
51	ContainerExists {
52		/// Container name.
53		name: String,
54	},
55	/// Container port binding lookup.
56	ContainerPortBinding {
57		/// Container name.
58		name: String,
59		/// Container port whose host binding is requested.
60		container_port: u16,
61	},
62	/// Forced container removal.
63	RemoveContainer {
64		/// Container name.
65		name: String,
66	},
67	/// Detached container creation and start.
68	RunDetached {
69		/// Detached container run request.
70		spec: DockerRunSpec,
71	},
72}
73
74/// Docker backend used by local infrastructure commands.
75#[async_trait]
76pub trait DockerEngine: Clone + Send + Sync + 'static {
77	/// Return whether a container with this exact name exists.
78	async fn container_exists(&self, name: &str) -> DockerResult<bool>;
79
80	/// Return the loopback host port bound to a container port.
81	async fn container_port_binding(
82		&self,
83		name: &str,
84		container_port: u16,
85	) -> DockerResult<Option<u16>> {
86		Err(DockerError::Backend(format!(
87			"Docker backend does not support inspecting `{name}` port {container_port}"
88		)))
89	}
90
91	/// Remove a container by name, ignoring missing containers.
92	async fn remove_container(&self, name: &str) -> DockerResult<()>;
93
94	/// Create and start a detached container.
95	async fn run_detached(&self, spec: DockerRunSpec) -> DockerResult<()>;
96}
97
98/// Bollard-backed Docker Engine API client.
99#[derive(Clone)]
100pub struct BollardDockerEngine {
101	docker: Docker,
102}
103
104impl BollardDockerEngine {
105	/// Connect to the local Docker backend using bollard's default discovery.
106	pub fn local() -> DockerResult<Self> {
107		let docker = Docker::connect_with_local_defaults()?;
108		Ok(Self { docker })
109	}
110
111	async fn ensure_image_available(&self, image: &str) -> DockerResult<()> {
112		match self.docker.inspect_image(image).await {
113			Ok(_) => Ok(()),
114			Err(err) if is_missing_image_error(&err) => self.pull_image(image).await,
115			Err(err) => Err(DockerError::Backend(format!(
116				"failed to inspect Docker image `{image}`: {err}"
117			))),
118		}
119	}
120
121	async fn pull_image(&self, image: &str) -> DockerResult<()> {
122		let image_ref = ImagePullReference::parse(image);
123		let mut options = CreateImageOptionsBuilder::default().from_image(image_ref.from_image);
124		if let Some(tag) = image_ref.tag {
125			options = options.tag(tag);
126		}
127		let mut pull = self.docker.create_image(Some(options.build()), None, None);
128
129		while let Some(result) = pull.next().await {
130			result.map_err(|err| {
131				DockerError::Backend(format!("failed to pull Docker image `{image}`: {err}"))
132			})?;
133		}
134
135		Ok(())
136	}
137}
138
139#[derive(Debug, PartialEq, Eq)]
140struct ImagePullReference<'a> {
141	from_image: &'a str,
142	tag: Option<&'a str>,
143}
144
145impl<'a> ImagePullReference<'a> {
146	fn parse(image: &'a str) -> Self {
147		if image.contains('@') {
148			return Self {
149				from_image: image,
150				tag: None,
151			};
152		}
153
154		let slash_index = image.rfind('/');
155		let tag_separator = image
156			.rfind(':')
157			.filter(|colon_index| slash_index.is_none_or(|slash_index| *colon_index > slash_index));
158
159		match tag_separator {
160			Some(index) => Self {
161				from_image: &image[..index],
162				tag: Some(&image[index + 1..]),
163			},
164			None => Self {
165				from_image: image,
166				tag: Some("latest"),
167			},
168		}
169	}
170}
171
172fn is_missing_image_error(err: &BollardError) -> bool {
173	matches!(
174		err,
175		BollardError::DockerResponseServerError {
176			status_code: 404,
177			..
178		}
179	)
180}
181
182#[async_trait]
183impl DockerEngine for BollardDockerEngine {
184	async fn container_exists(&self, name: &str) -> DockerResult<bool> {
185		let mut filters = HashMap::new();
186		filters.insert("name", vec![name]);
187		let containers = self
188			.docker
189			.list_containers(Some(
190				ListContainersOptionsBuilder::default()
191					.all(true)
192					.filters(&filters)
193					.build(),
194			))
195			.await
196			.map_err(DockerError::from)?;
197
198		Ok(containers.into_iter().any(|container| {
199			container
200				.names
201				.unwrap_or_default()
202				.iter()
203				.any(|container_name| container_name == &format!("/{name}"))
204		}))
205	}
206
207	async fn container_port_binding(
208		&self,
209		name: &str,
210		container_port: u16,
211	) -> DockerResult<Option<u16>> {
212		let inspected = self
213			.docker
214			.inspect_container(name, None)
215			.await
216			.map_err(DockerError::from)?;
217		let port = format!("{container_port}/tcp");
218		let bindings = inspected
219			.network_settings
220			.and_then(|settings| settings.ports)
221			.and_then(|mut ports| ports.remove(&port))
222			.flatten()
223			.unwrap_or_default();
224		if bindings.len() != 1 {
225			return Ok(None);
226		}
227		let binding = &bindings[0];
228		if binding.host_ip.as_deref() != Some("127.0.0.1") {
229			return Ok(None);
230		}
231		Ok(binding
232			.host_port
233			.as_deref()
234			.and_then(|port| port.parse().ok()))
235	}
236
237	async fn remove_container(&self, name: &str) -> DockerResult<()> {
238		if !self.container_exists(name).await? {
239			return Ok(());
240		}
241		self.docker
242			.remove_container(
243				name,
244				Some(
245					RemoveContainerOptionsBuilder::default()
246						.force(true)
247						.v(true)
248						.build(),
249				),
250			)
251			.await
252			.or_else(|err| match err {
253				BollardError::DockerResponseServerError {
254					status_code: 404, ..
255				} => Ok(()),
256				err => Err(DockerError::from(err)),
257			})
258	}
259
260	async fn run_detached(&self, spec: DockerRunSpec) -> DockerResult<()> {
261		self.ensure_image_available(&spec.image).await?;
262
263		let exposed_port = format!("{}/tcp", spec.container_port);
264		let mut port_bindings = HashMap::new();
265		port_bindings.insert(
266			exposed_port.clone(),
267			Some(vec![PortBinding {
268				host_ip: Some("127.0.0.1".to_string()),
269				host_port: Some(spec.host_port.to_string()),
270			}]),
271		);
272
273		let body = ContainerCreateBody {
274			image: Some(spec.image.clone()),
275			env: Some(
276				spec.env
277					.iter()
278					.map(|(key, value)| format!("{key}={value}"))
279					.collect(),
280			),
281			exposed_ports: Some(vec![exposed_port]),
282			host_config: Some(HostConfig {
283				auto_remove: Some(true),
284				port_bindings: Some(port_bindings),
285				..Default::default()
286			}),
287			..Default::default()
288		};
289
290		let options = CreateContainerOptionsBuilder::default()
291			.name(&spec.name)
292			.build();
293		let container = self
294			.docker
295			.create_container(Some(options), body)
296			.await
297			.map_err(DockerError::from)?;
298		self.docker
299			.start_container(&container.id, None::<StartContainerOptions>)
300			.await
301			.map_err(DockerError::from)
302	}
303}
304
305#[cfg(test)]
306mod tests {
307	use super::*;
308
309	#[test]
310	fn missing_image_error_is_pullable() {
311		let err = BollardError::DockerResponseServerError {
312			status_code: 404,
313			message: "No such image: postgres:17-alpine".to_string(),
314		};
315
316		assert!(is_missing_image_error(&err));
317	}
318
319	#[test]
320	fn non_missing_image_error_is_not_pullable() {
321		let err = BollardError::DockerResponseServerError {
322			status_code: 500,
323			message: "Docker daemon failed".to_string(),
324		};
325
326		assert!(!is_missing_image_error(&err));
327	}
328
329	#[test]
330	fn image_pull_reference_splits_tagged_image() {
331		assert_eq!(
332			ImagePullReference::parse("postgres:17-alpine"),
333			ImagePullReference {
334				from_image: "postgres",
335				tag: Some("17-alpine")
336			}
337		);
338	}
339
340	#[test]
341	fn image_pull_reference_defaults_untagged_image_to_latest() {
342		assert_eq!(
343			ImagePullReference::parse("redis"),
344			ImagePullReference {
345				from_image: "redis",
346				tag: Some("latest")
347			}
348		);
349	}
350
351	#[test]
352	fn image_pull_reference_defaults_namespaced_untagged_image_to_latest() {
353		assert_eq!(
354			ImagePullReference::parse("library/redis"),
355			ImagePullReference {
356				from_image: "library/redis",
357				tag: Some("latest")
358			}
359		);
360	}
361
362	#[test]
363	fn image_pull_reference_preserves_registry_port() {
364		assert_eq!(
365			ImagePullReference::parse("registry.example.com:5000/reinhardt/postgres:17-alpine"),
366			ImagePullReference {
367				from_image: "registry.example.com:5000/reinhardt/postgres",
368				tag: Some("17-alpine")
369			}
370		);
371	}
372
373	#[test]
374	fn image_pull_reference_keeps_digest_reference() {
375		assert_eq!(
376			ImagePullReference::parse("postgres@sha256:abcdef"),
377			ImagePullReference {
378				from_image: "postgres@sha256:abcdef",
379				tag: None
380			}
381		);
382	}
383}
384
385/// Fake Docker engine for tests.
386#[derive(Debug, Clone)]
387pub struct FakeDockerEngine {
388	calls: Arc<Mutex<Vec<DockerCall>>>,
389	exists: Arc<Mutex<Vec<bool>>>,
390	port_bindings: Arc<Mutex<Vec<Option<u16>>>>,
391}
392
393impl FakeDockerEngine {
394	/// Create a fake engine that returns existence checks in order.
395	pub fn new(exists: Vec<bool>) -> Self {
396		Self {
397			calls: Arc::new(Mutex::new(Vec::new())),
398			exists: Arc::new(Mutex::new(exists)),
399			port_bindings: Arc::new(Mutex::new(Vec::new())),
400		}
401	}
402
403	/// Configure container port binding results in lookup order.
404	pub fn with_port_bindings(self, port_bindings: Vec<Option<u16>>) -> Self {
405		*self.port_bindings.lock().expect("port bindings lock") = port_bindings;
406		self
407	}
408
409	/// Return captured Docker API operations.
410	pub fn calls(&self) -> Vec<DockerCall> {
411		self.calls.lock().expect("calls lock").clone()
412	}
413}
414
415#[async_trait]
416impl DockerEngine for FakeDockerEngine {
417	async fn container_exists(&self, name: &str) -> DockerResult<bool> {
418		self.calls
419			.lock()
420			.expect("calls lock")
421			.push(DockerCall::ContainerExists {
422				name: name.to_string(),
423			});
424		let mut exists = self.exists.lock().expect("exists lock");
425		Ok(if exists.is_empty() {
426			false
427		} else {
428			exists.remove(0)
429		})
430	}
431
432	async fn container_port_binding(
433		&self,
434		name: &str,
435		container_port: u16,
436	) -> DockerResult<Option<u16>> {
437		self.calls
438			.lock()
439			.expect("calls lock")
440			.push(DockerCall::ContainerPortBinding {
441				name: name.to_string(),
442				container_port,
443			});
444		let mut port_bindings = self.port_bindings.lock().expect("port bindings lock");
445		Ok(if port_bindings.is_empty() {
446			None
447		} else {
448			port_bindings.remove(0)
449		})
450	}
451
452	async fn remove_container(&self, name: &str) -> DockerResult<()> {
453		self.calls
454			.lock()
455			.expect("calls lock")
456			.push(DockerCall::RemoveContainer {
457				name: name.to_string(),
458			});
459		Ok(())
460	}
461
462	async fn run_detached(&self, spec: DockerRunSpec) -> DockerResult<()> {
463		self.calls
464			.lock()
465			.expect("calls lock")
466			.push(DockerCall::RunDetached { spec });
467		Ok(())
468	}
469}