reinhardt_commands/local_infra/
docker.rs1use 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#[derive(Debug, thiserror::Error)]
17pub enum DockerError {
18 #[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
29pub(crate) type DockerResult<T> = Result<T, DockerError>;
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct DockerRunSpec {
35 pub name: String,
37 pub image: String,
39 pub host_port: u16,
41 pub container_port: u16,
43 pub env: Vec<(String, String)>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum DockerCall {
50 ContainerExists {
52 name: String,
54 },
55 ContainerPortBinding {
57 name: String,
59 container_port: u16,
61 },
62 RemoveContainer {
64 name: String,
66 },
67 RunDetached {
69 spec: DockerRunSpec,
71 },
72}
73
74#[async_trait]
76pub trait DockerEngine: Clone + Send + Sync + 'static {
77 async fn container_exists(&self, name: &str) -> DockerResult<bool>;
79
80 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 async fn remove_container(&self, name: &str) -> DockerResult<()>;
93
94 async fn run_detached(&self, spec: DockerRunSpec) -> DockerResult<()>;
96}
97
98#[derive(Clone)]
100pub struct BollardDockerEngine {
101 docker: Docker,
102}
103
104impl BollardDockerEngine {
105 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#[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 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 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 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}