Skip to main content

dol/
docker.rs

1//! Docker client abstraction and domain types.
2//!
3//! Defines the [`DockerClient`] async trait and the [`BollardDockerClient`]
4//! implementation. Also provides domain types ([`Container`], [`Image`],
5//! [`Network`], [`Volume`], [`MetricSample`], [`DockerEvent`]) and the
6//! [`MockDockerClient`] for unit testing.
7//!
8//! # Example
9//!
10//! ```ignore
11//! let docker = BollardDockerClient::connect_with_config(&config)?;
12//! let containers = docker.list_containers().await?;
13//! ```
14
15use std::collections::HashMap;
16use std::time::Duration;
17
18use crate::ONE_GB;
19use crate::config::DolConfig;
20use bollard::container::LogOutput;
21use bollard::models::Network as BollardNetwork;
22use bollard::models::{
23    ContainerInspectResponse, ContainerSummary, EventMessage, ImageSummary, PortSummary,
24    Volume as BollardVolume,
25};
26use bollard::query_parameters as qp;
27use futures_util::{Stream, StreamExt};
28use serde::{Deserialize, Serialize};
29use std::pin::Pin;
30use thiserror::Error;
31use tokio::time::timeout;
32
33/// Shared type alias for the complex Docker event stream return type.
34pub type DockerEventStream = Pin<Box<dyn Stream<Item = Result<DockerEvent, DockerError>> + Send>>;
35
36/// Shared type alias for the Docker log stream return type.
37pub type DockerLogStream = Pin<Box<dyn Stream<Item = Result<String, DockerError>> + Send>>;
38
39// ── Domain types ───────────────────────────────────────────────────────────
40
41#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
42pub struct Container {
43    pub id: String,
44    pub name: String,
45    pub image: String,
46    pub status: String,
47    pub state: String,
48    pub ports: Vec<String>,
49    pub labels: Vec<String>,
50    pub created_at: Option<String>,
51    pub started_at: Option<String>,
52    pub finished_at: Option<String>,
53    pub restart_count: Option<u64>,
54    pub health: Option<String>,
55}
56
57#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
58pub struct Image {
59    pub id: String,
60    pub repository: String,
61    pub tag: String,
62    pub digest: Option<String>,
63    pub size: String,
64    pub created_at: Option<String>,
65    pub labels: Vec<String>,
66}
67
68#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
69pub struct Network {
70    pub id: String,
71    pub name: String,
72    pub driver: String,
73    pub scope: String,
74    pub containers: Vec<String>,
75    pub labels: Vec<String>,
76}
77
78#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
79pub struct Volume {
80    pub name: String,
81    pub driver: String,
82    pub mountpoint: Option<String>,
83    pub scope: Option<String>,
84    pub labels: Vec<String>,
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct MetricSample {
89    pub container_id: String,
90    pub container_name: String,
91    pub timestamp: String,
92    pub cpu_percent: Option<f64>,
93    pub memory_usage_bytes: Option<u64>,
94    pub memory_limit_bytes: Option<u64>,
95    pub network_rx_bytes: Option<u64>,
96    pub network_tx_bytes: Option<u64>,
97    pub disk_read_bytes: Option<u64>,
98    pub disk_write_bytes: Option<u64>,
99}
100
101#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
102pub struct DockerEvent {
103    pub time: String,
104    pub event_type: String,
105    pub action: String,
106    pub actor_id: String,
107    pub container: Option<String>,
108    pub image: Option<String>,
109    pub attributes: Vec<(String, String)>,
110}
111
112// ── Error type ─────────────────────────────────────────────────────────────
113
114#[derive(Debug, Error)]
115pub enum DockerError {
116    #[error("bollard error: {0}")]
117    Bollard(#[from] bollard::errors::Error),
118    #[error("container not found: {0}")]
119    NotFound(String),
120    #[error("invalid response: {0}")]
121    InvalidResponse(String),
122    #[error("Docker API call timed out after {0}s")]
123    Timeout(u64),
124}
125
126// ── Async trait ────────────────────────────────────────────────────────────
127
128pub trait DockerClient {
129    fn list_containers(
130        &self,
131    ) -> impl std::future::Future<Output = Result<Vec<Container>, DockerError>> + Send;
132    fn list_images(
133        &self,
134    ) -> impl std::future::Future<Output = Result<Vec<Image>, DockerError>> + Send;
135    fn list_networks(
136        &self,
137    ) -> impl std::future::Future<Output = Result<Vec<Network>, DockerError>> + Send;
138    fn list_volumes(
139        &self,
140    ) -> impl std::future::Future<Output = Result<Vec<Volume>, DockerError>> + Send;
141    fn inspect_container(
142        &self,
143        id: &str,
144    ) -> impl std::future::Future<Output = Result<Container, DockerError>> + Send;
145    fn container_logs(
146        &self,
147        id: &str,
148        tail: usize,
149    ) -> impl std::future::Future<Output = Result<Vec<String>, DockerError>> + Send;
150    fn container_logs_stream(
151        &self,
152        id: &str,
153        tail: usize,
154    ) -> impl std::future::Future<Output = Result<DockerLogStream, DockerError>> + Send;
155    fn container_stats(
156        &self,
157        id: &str,
158    ) -> impl std::future::Future<Output = Result<MetricSample, DockerError>> + Send;
159    fn events_stream(
160        &self,
161        since: Option<&str>,
162        until: Option<&str>,
163    ) -> impl std::future::Future<Output = Result<DockerEventStream, DockerError>> + Send;
164    fn ping(&self) -> impl std::future::Future<Output = Result<bool, DockerError>> + Send;
165}
166
167// ── Docker API timeout configuration ───────────────────────────────────────
168
169/// Holds all configurable timeout values for Docker API calls.
170#[derive(Debug, Clone)]
171pub struct DockerApiConfig {
172    pub call_timeout: Duration,
173    pub quick_timeout: Duration,
174    pub max_retries: u32,
175    pub retry_base_ms: u64,
176}
177
178impl Default for DockerApiConfig {
179    fn default() -> Self {
180        Self {
181            call_timeout: Duration::from_secs(30),
182            quick_timeout: Duration::from_secs(10),
183            max_retries: 2,
184            retry_base_ms: 200,
185        }
186    }
187}
188
189impl From<&DolConfig> for DockerApiConfig {
190    fn from(cfg: &DolConfig) -> Self {
191        Self {
192            call_timeout: Duration::from_secs(cfg.api_timeout.unwrap_or(30)),
193            quick_timeout: Duration::from_secs(cfg.api_quick_timeout.unwrap_or(10)),
194            max_retries: 2,
195            retry_base_ms: 200,
196        }
197    }
198}
199
200// ── Timeout & retry helpers ────────────────────────────────────────────────
201
202/// Run a bollard API future with a standard timeout.
203/// Returns `DockerError::Timeout` if the operation does not complete in time.
204async fn docker_call_timeout<T>(
205    fut: impl std::future::Future<Output = Result<T, bollard::errors::Error>>,
206    duration: Duration,
207) -> Result<T, DockerError> {
208    timeout(duration, fut)
209        .await
210        .map_err(|_| DockerError::Timeout(duration.as_secs()))?
211        .map_err(DockerError::Bollard)
212}
213
214/// Run a bollard API future with the default timeout from config.
215async fn docker_call<T>(
216    cfg: &DockerApiConfig,
217    fut: impl std::future::Future<Output = Result<T, bollard::errors::Error>>,
218) -> Result<T, DockerError> {
219    docker_call_timeout(fut, cfg.call_timeout).await
220}
221
222/// Run a bollard API future with the quick (short) timeout from config.
223async fn docker_call_quick<T>(
224    cfg: &DockerApiConfig,
225    fut: impl std::future::Future<Output = Result<T, bollard::errors::Error>>,
226) -> Result<T, DockerError> {
227    docker_call_timeout(fut, cfg.quick_timeout).await
228}
229
230/// Run a bollard API future factory with retries (exponential backoff) for
231/// transient errors. Uses a closure so a fresh future is created on each retry
232/// attempt. Only operations that are safe to retry (read-only queries like
233/// `inspect_container`, `container_stats`) should use this.
234async fn docker_call_with_retry<T, F, Fut>(cfg: &DockerApiConfig, f: F) -> Result<T, DockerError>
235where
236    F: Fn() -> Fut,
237    Fut: std::future::Future<Output = Result<T, bollard::errors::Error>>,
238{
239    let mut last_err = None;
240    for attempt in 0..=cfg.max_retries {
241        match timeout(cfg.call_timeout, f()).await {
242            Ok(Ok(val)) => return Ok(val),
243            Ok(Err(e)) => {
244                last_err = Some(DockerError::Bollard(e));
245                if attempt < cfg.max_retries {
246                    let delay = cfg.retry_base_ms * (1u64 << attempt);
247                    tokio::time::sleep(Duration::from_millis(delay)).await;
248                }
249            }
250            Err(_) => {
251                last_err = Some(DockerError::Timeout(cfg.call_timeout.as_secs()));
252                if attempt < cfg.max_retries {
253                    let delay = cfg.retry_base_ms * (1u64 << attempt);
254                    tokio::time::sleep(Duration::from_millis(delay)).await;
255                }
256            }
257        }
258    }
259    Err(last_err.unwrap_or(DockerError::Timeout(cfg.call_timeout.as_secs())))
260}
261
262pub async fn list_running_containers<C: DockerClient>(
263    client: &C,
264) -> Result<Vec<Container>, DockerError> {
265    Ok(client
266        .list_containers()
267        .await?
268        .into_iter()
269        .filter(|c| c.state == "running")
270        .collect())
271}
272
273// ── BollardDockerClient ───────────────────────────────────────────────────
274
275#[derive(Clone)]
276pub struct BollardDockerClient {
277    docker: bollard::Docker,
278    api_cfg: DockerApiConfig,
279}
280
281impl BollardDockerClient {
282    /// Connect with default timeout values.
283    pub fn connect() -> Result<Self, DockerError> {
284        Self::connect_with_config(&DolConfig::default())
285    }
286
287    /// Connect with timeout values from the given config.
288    pub fn connect_with_config(config: &DolConfig) -> Result<Self, DockerError> {
289        let docker =
290            bollard::Docker::connect_with_local_defaults().map_err(DockerError::Bollard)?;
291        Ok(Self {
292            docker,
293            api_cfg: DockerApiConfig::from(config),
294        })
295    }
296
297    /// Connect to a specific Docker host with timeout values from config.
298    pub fn connect_with_host(host: &str, config: &DolConfig) -> Result<Self, DockerError> {
299        let docker = if host.starts_with("tcp://") || host.starts_with("http://") {
300            let h = host
301                .strip_prefix("tcp://")
302                .or_else(|| host.strip_prefix("http://"))
303                .unwrap_or(host);
304            bollard::Docker::connect_with_http(h, 120, bollard::API_DEFAULT_VERSION)
305                .map_err(DockerError::Bollard)?
306        } else if host.starts_with("unix://") {
307            let path = host.strip_prefix("unix://").unwrap_or(host);
308            bollard::Docker::connect_with_socket(path, 120, bollard::API_DEFAULT_VERSION)
309                .map_err(DockerError::Bollard)?
310        } else {
311            bollard::Docker::connect_with_local_defaults().map_err(DockerError::Bollard)?
312        };
313        Ok(Self {
314            docker,
315            api_cfg: DockerApiConfig::from(config),
316        })
317    }
318
319    /// Get a reference to the API config (used by the metrics collector).
320    #[must_use]
321    pub const fn api_config(&self) -> &DockerApiConfig {
322        &self.api_cfg
323    }
324
325    #[must_use]
326    pub fn from_docker(docker: bollard::Docker) -> Self {
327        Self {
328            docker,
329            api_cfg: DockerApiConfig::default(),
330        }
331    }
332}
333
334impl DockerClient for BollardDockerClient {
335    async fn list_containers(&self) -> Result<Vec<Container>, DockerError> {
336        let options = Some(qp::ListContainersOptions {
337            all: true,
338            ..Default::default()
339        });
340        let summaries = docker_call(&self.api_cfg, self.docker.list_containers(options)).await?;
341        let mut containers: Vec<Container> = summaries.iter().map(container_from_summary).collect();
342
343        if !containers.is_empty() {
344            let ids: Vec<String> = containers.iter().map(|c| c.id.clone()).collect();
345            for (i, id) in ids.iter().enumerate() {
346                if let Ok(inspect) = docker_call_with_retry(&self.api_cfg, || {
347                    self.docker.inspect_container(id, None)
348                })
349                .await
350                {
351                    let s = inspect.state.as_ref();
352                    containers[i].started_at = s
353                        .and_then(|st| st.started_at.clone())
354                        .filter(|ts| !ts.is_empty() && !ts.starts_with("0001"));
355                    containers[i].finished_at = s
356                        .and_then(|st| st.finished_at.clone())
357                        .filter(|ts| !ts.is_empty() && !ts.starts_with("0001"));
358                    containers[i].restart_count = inspect.restart_count.map(|c| c.max(0) as u64);
359                    containers[i].health = s
360                        .and_then(|st| st.health.as_ref())
361                        .map(|h| h.status.map(|e| format!("{e:?}")).unwrap_or_default())
362                        .filter(|s| !s.is_empty());
363                }
364            }
365        }
366
367        Ok(containers)
368    }
369
370    async fn list_images(&self) -> Result<Vec<Image>, DockerError> {
371        let summaries = docker_call(
372            &self.api_cfg,
373            self.docker.list_images(None::<qp::ListImagesOptions>),
374        )
375        .await?;
376        Ok(summaries.iter().map(image_from_summary).collect())
377    }
378
379    async fn list_networks(&self) -> Result<Vec<Network>, DockerError> {
380        let networks = docker_call(
381            &self.api_cfg,
382            self.docker.list_networks(None::<qp::ListNetworksOptions>),
383        )
384        .await?;
385        Ok(networks.into_iter().map(network_from_bollard).collect())
386    }
387
388    async fn list_volumes(&self) -> Result<Vec<Volume>, DockerError> {
389        let response = docker_call(
390            &self.api_cfg,
391            self.docker.list_volumes(None::<qp::ListVolumesOptions>),
392        )
393        .await?;
394        Ok(response
395            .volumes
396            .unwrap_or_default()
397            .into_iter()
398            .map(volume_from_bollard)
399            .collect())
400    }
401
402    async fn inspect_container(&self, id: &str) -> Result<Container, DockerError> {
403        let inspect =
404            docker_call_with_retry(&self.api_cfg, || self.docker.inspect_container(id, None))
405                .await?;
406        Ok(container_from_inspect(&inspect))
407    }
408
409    async fn container_logs(&self, id: &str, tail: usize) -> Result<Vec<String>, DockerError> {
410        let options = Some(qp::LogsOptions {
411            tail: tail.to_string(),
412            stdout: true,
413            stderr: true,
414            ..Default::default()
415        });
416        let stream_fut = self.docker.logs(id, options);
417        let mut stream = stream_fut;
418        let mut lines = Vec::new();
419        loop {
420            let next = tokio::time::timeout(self.api_cfg.call_timeout, stream.next()).await;
421            match next {
422                Ok(Some(Ok(LogOutput::StdOut { message } | LogOutput::StdErr { message }))) => {
423                    let s = String::from_utf8_lossy(&message).to_string();
424                    for line in s.lines() {
425                        lines.push(line.to_owned());
426                    }
427                }
428                Ok(Some(Ok(_))) => {}
429                Ok(Some(Err(e))) => return Err(DockerError::Bollard(e)),
430                Ok(None) => break,
431                Err(_) => return Err(DockerError::Timeout(self.api_cfg.call_timeout.as_secs())),
432            }
433        }
434        Ok(lines)
435    }
436
437    async fn container_logs_stream(
438        &self,
439        id: &str,
440        tail: usize,
441    ) -> Result<DockerLogStream, DockerError> {
442        let options = Some(qp::LogsOptions {
443            tail: tail.to_string(),
444            stdout: true,
445            stderr: true,
446            follow: true,
447            ..Default::default()
448        });
449        let stream = self.docker.logs(id, options);
450        use bollard::container::LogOutput as LO;
451        let mapped = stream.flat_map(|item| {
452            let items: Vec<Result<String, DockerError>> = match item {
453                Ok(LO::StdOut { message } | LO::StdErr { message }) => {
454                    String::from_utf8_lossy(&message)
455                        .lines()
456                        .map(|l| Ok(l.to_owned()))
457                        .collect()
458                }
459                Ok(_) => Vec::new(),
460                Err(e) => vec![Err(DockerError::Bollard(e))],
461            };
462            futures_util::stream::iter(items)
463        });
464        Ok(Box::pin(mapped))
465    }
466
467    async fn container_stats(&self, id: &str) -> Result<MetricSample, DockerError> {
468        use bollard::query_parameters::StatsOptions;
469        let stream_fut = self.docker.stats(
470            id,
471            Some(StatsOptions {
472                stream: false,
473                ..Default::default()
474            }),
475        );
476        let mut stream = stream_fut;
477        let next = tokio::time::timeout(self.api_cfg.call_timeout, stream.next()).await;
478        match next {
479            Ok(Some(Ok(stats))) => {
480                let container_name = docker_call_with_retry(&self.api_cfg, || {
481                    self.docker.inspect_container(id, None)
482                })
483                .await
484                .ok()
485                .and_then(|i| i.name)
486                .map_or_else(|| id.to_owned(), |n| n.trim_start_matches('/').to_owned());
487                Ok(metric_sample_from_bollard_stats(
488                    id,
489                    &container_name,
490                    &stats,
491                ))
492            }
493            Ok(Some(Err(e))) => Err(DockerError::Bollard(e)),
494            Ok(None) => Err(DockerError::NotFound(id.to_owned())),
495            Err(_) => Err(DockerError::Timeout(self.api_cfg.call_timeout.as_secs())),
496        }
497    }
498
499    async fn events_stream(
500        &self,
501        since: Option<&str>,
502        until: Option<&str>,
503    ) -> Result<DockerEventStream, DockerError> {
504        let options = Some(qp::EventsOptions {
505            since: since.map(std::borrow::ToOwned::to_owned),
506            until: until.map(std::borrow::ToOwned::to_owned),
507            ..Default::default()
508        });
509        let stream = self.docker.events(options);
510        let mapped = stream.map(|item| match item {
511            Ok(msg) => Ok(docker_event_from_message(msg)),
512            Err(e) => Err(DockerError::Bollard(e)),
513        });
514        Ok(Box::pin(mapped))
515    }
516
517    async fn ping(&self) -> Result<bool, DockerError> {
518        docker_call_quick(&self.api_cfg, self.docker.ping()).await?;
519        Ok(true)
520    }
521}
522
523// ── MockDockerClient (kept for unit tests) ─────────────────────────────────
524
525#[derive(Debug, Clone, Default)]
526pub struct MockDockerClient {
527    pub containers: Vec<Container>,
528    pub images: Vec<Image>,
529    pub networks: Vec<Network>,
530    pub volumes: Vec<Volume>,
531    pub logs: HashMap<String, Vec<String>>,
532    pub events: Vec<DockerEvent>,
533}
534
535impl DockerClient for MockDockerClient {
536    async fn list_containers(&self) -> Result<Vec<Container>, DockerError> {
537        Ok(self.containers.clone())
538    }
539
540    async fn list_images(&self) -> Result<Vec<Image>, DockerError> {
541        Ok(self.images.clone())
542    }
543
544    async fn list_networks(&self) -> Result<Vec<Network>, DockerError> {
545        Ok(self.networks.clone())
546    }
547
548    async fn list_volumes(&self) -> Result<Vec<Volume>, DockerError> {
549        Ok(self.volumes.clone())
550    }
551
552    async fn inspect_container(&self, id: &str) -> Result<Container, DockerError> {
553        self.containers
554            .iter()
555            .find(|c| c.id.starts_with(id) || c.name == id)
556            .cloned()
557            .ok_or_else(|| DockerError::NotFound(id.to_owned()))
558    }
559
560    async fn container_logs(&self, id: &str, _tail: usize) -> Result<Vec<String>, DockerError> {
561        let key = self
562            .containers
563            .iter()
564            .find(|c| c.id.starts_with(id) || c.name == id)
565            .map_or_else(|| id.to_owned(), |c| c.id.clone());
566        Ok(self.logs.get(&key).cloned().unwrap_or_default())
567    }
568
569    async fn container_logs_stream(
570        &self,
571        id: &str,
572        _tail: usize,
573    ) -> Result<DockerLogStream, DockerError> {
574        let key = self
575            .containers
576            .iter()
577            .find(|c| c.id.starts_with(id) || c.name == id)
578            .map_or_else(|| id.to_owned(), |c| c.id.clone());
579        let logs = self.logs.get(&key).cloned().unwrap_or_default();
580        let items: Vec<Result<String, DockerError>> = logs.into_iter().map(Ok).collect();
581        Ok(Box::pin(futures_util::stream::iter(items)))
582    }
583
584    async fn container_stats(&self, id: &str) -> Result<MetricSample, DockerError> {
585        let container = self.inspect_container(id).await?;
586        Ok(MetricSample {
587            container_id: container.id.clone(),
588            container_name: container.name,
589            timestamp: chrono::Utc::now().to_rfc3339(),
590            cpu_percent: None,
591            memory_usage_bytes: None,
592            memory_limit_bytes: None,
593            network_rx_bytes: None,
594            network_tx_bytes: None,
595            disk_read_bytes: None,
596            disk_write_bytes: None,
597        })
598    }
599
600    async fn events_stream(
601        &self,
602        _since: Option<&str>,
603        _until: Option<&str>,
604    ) -> Result<DockerEventStream, DockerError> {
605        let events: Vec<Result<DockerEvent, DockerError>> =
606            self.events.clone().into_iter().map(Ok).collect();
607        Ok(Box::pin(futures_util::stream::iter(events)))
608    }
609
610    async fn ping(&self) -> Result<bool, DockerError> {
611        Ok(true)
612    }
613}
614
615// ── Conversion helpers ─────────────────────────────────────────────────────
616
617fn container_from_summary(s: &ContainerSummary) -> Container {
618    Container {
619        id: s.id.as_deref().unwrap_or_default().to_owned(),
620        name: s
621            .names
622            .as_ref()
623            .and_then(|n| n.first())
624            .map(|n| n.trim_start_matches('/').to_owned())
625            .unwrap_or_default(),
626        image: s.image.as_deref().unwrap_or_default().to_owned(),
627        status: s.status.as_deref().unwrap_or_default().to_owned(),
628        state: s
629            .state
630            .as_ref()
631            .map(|e| format!("{e:?}").to_lowercase())
632            .unwrap_or_default(),
633        ports: ports_from_summary(s.ports.as_ref()),
634        labels: labels_map_to_vec(s.labels.as_ref()),
635        created_at: s.created.map(unix_to_iso),
636        started_at: None,
637        finished_at: None,
638        restart_count: None,
639        health: None,
640    }
641}
642
643fn container_from_inspect(inspect: &ContainerInspectResponse) -> Container {
644    let config = inspect.config.as_ref();
645    let state = inspect.state.as_ref();
646    let net_settings = inspect.network_settings.as_ref();
647
648    let state_status = state
649        .and_then(|s| s.status.as_ref())
650        .map(|e| format!("{e:?}").to_lowercase())
651        .unwrap_or_default();
652
653    Container {
654        id: inspect.id.as_deref().unwrap_or_default().to_owned(),
655        name: inspect
656            .name
657            .as_deref()
658            .map(|n| n.trim_start_matches('/').to_owned())
659            .unwrap_or_default(),
660        image: config
661            .and_then(|c| c.image.as_deref())
662            .unwrap_or_default()
663            .to_owned(),
664        status: state_status.clone(),
665        state: state_status,
666        ports: net_settings
667            .and_then(|ns| ns.ports.as_ref())
668            .map(|ports_obj| {
669                ports_obj
670                    .iter()
671                    .flat_map(|(container_port, bindings)| {
672                        let arr = bindings.as_ref();
673                        if arr.is_none_or(std::vec::Vec::is_empty) {
674                            vec![container_port.clone()]
675                        } else {
676                            arr.expect("bindings should be Some here (checked by is_none_or)")
677                                .iter()
678                                .map(|b| {
679                                    let host_ip = b.host_ip.as_deref().unwrap_or("");
680                                    let host_port = b.host_port.as_deref().unwrap_or("");
681                                    if host_port.is_empty() {
682                                        container_port.clone()
683                                    } else if host_ip.is_empty() || host_ip == "0.0.0.0" {
684                                        format!("{host_port}->{container_port}")
685                                    } else {
686                                        format!("{host_ip}:{host_port}->{container_port}")
687                                    }
688                                })
689                                .collect::<Vec<_>>()
690                        }
691                    })
692                    .collect()
693            })
694            .unwrap_or_default(),
695        labels: config
696            .and_then(|c| c.labels.as_ref())
697            .map(|l| labels_map_to_vec(Some(l)))
698            .unwrap_or_default(),
699        created_at: inspect.created.clone().filter(|s| !s.is_empty()),
700        started_at: state
701            .and_then(|s| s.started_at.clone())
702            .filter(|s| !s.starts_with("0001")),
703        finished_at: state
704            .and_then(|s| s.finished_at.clone())
705            .filter(|s| !s.starts_with("0001")),
706        restart_count: inspect.restart_count.map(|c| c.max(0) as u64),
707        health: state
708            .and_then(|s| s.health.as_ref())
709            .and_then(|h| h.status.as_ref().map(|e| format!("{e:?}")))
710            .filter(|s| !s.is_empty()),
711    }
712}
713
714fn image_from_summary(s: &ImageSummary) -> Image {
715    let id = s.id.clone();
716    let (repository, tag) = s.repo_tags.first().map_or_else(
717        || (id.clone(), "latest".to_owned()),
718        |full| {
719            if let Some((repo, t)) = full.rsplit_once(':') {
720                (repo.to_owned(), t.to_owned())
721            } else {
722                (full.clone(), "latest".to_owned())
723            }
724        },
725    );
726    let repository = if repository == "<none>" {
727        id.clone()
728    } else {
729        repository
730    };
731
732    Image {
733        id,
734        repository,
735        tag,
736        digest: s.repo_digests.first().cloned(),
737        size: format_bytes_human(s.size.max(0) as u64),
738        created_at: Some(unix_to_iso(s.created)),
739        labels: Some(&s.labels)
740            .map(|l| labels_map_to_vec(Some(l)))
741            .unwrap_or_default(),
742    }
743}
744
745fn network_from_bollard(n: BollardNetwork) -> Network {
746    Network {
747        id: n.id.unwrap_or_default(),
748        name: n.name.unwrap_or_default(),
749        driver: n.driver.unwrap_or_default(),
750        scope: n.scope.unwrap_or_default(),
751        containers: Vec::new(), // not available from list_networks in bollard
752        labels: n
753            .labels
754            .as_ref()
755            .map(|l| labels_map_to_vec(Some(l)))
756            .unwrap_or_default(),
757    }
758}
759
760fn volume_from_bollard(v: BollardVolume) -> Volume {
761    Volume {
762        name: v.name,
763        driver: v.driver,
764        mountpoint: Some(v.mountpoint).filter(|s| !s.is_empty()),
765        scope: v.scope.map(|s| format!("{s:?}").to_lowercase()),
766        labels: Some(&v.labels)
767            .map(|l| labels_map_to_vec(Some(l)))
768            .unwrap_or_default(),
769    }
770}
771
772// ── Event conversion ───────────────────────────────────────────────────────
773
774pub fn docker_event_from_message(msg: EventMessage) -> DockerEvent {
775    let actor: Option<&bollard::models::EventActor> = msg.actor.as_ref();
776    let attributes: Vec<(String, String)> = actor
777        .and_then(|a| a.attributes.as_ref())
778        .map(|attrs| attrs.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
779        .unwrap_or_default();
780
781    let container = attributes
782        .iter()
783        .find(|(k, _)| k == "name")
784        .map(|(_, v)| v.clone())
785        .or_else(|| msg.actor.as_ref().and_then(|a| a.id.clone()));
786    let image = attributes
787        .iter()
788        .find(|(k, _)| k == "image")
789        .map(|(_, v)| v.clone());
790
791    DockerEvent {
792        time: msg
793            .time
794            .map(unix_to_iso)
795            .or_else(|| msg.time_nano.map(|n| unix_to_iso(n / 1_000_000_000)))
796            .unwrap_or_default(),
797        event_type: msg.typ.map(|e| format!("{e:?}")).unwrap_or_default(),
798        action: msg.action.unwrap_or_default(),
799        actor_id: actor.and_then(|a| a.id.clone()).unwrap_or_default(),
800        container,
801        image,
802        attributes,
803    }
804}
805
806// ── Stats/metrics helpers ─────────────────────────────────────────────────
807
808#[allow(clippy::similar_names)]
809pub fn metric_sample_from_stats_json(value: &serde_json::Value) -> Result<MetricSample, String> {
810    let mem_usage = optional_string(value, &["MemUsage"]);
811    let (memory_usage_bytes, memory_limit_bytes) = mem_usage
812        .as_deref()
813        .map(parse_usage_pair)
814        .transpose()
815        .map_err(|()| "invalid MemUsage".to_string())?
816        .unwrap_or((None, None));
817    let net_io = optional_string(value, &["NetIO"]);
818    let (network_rx_bytes, network_tx_bytes) = net_io
819        .as_deref()
820        .map(parse_usage_pair)
821        .transpose()
822        .map_err(|()| "invalid NetIO".to_string())?
823        .unwrap_or((None, None));
824    let block_io = optional_string(value, &["BlockIO"]);
825    let (disk_read_bytes, disk_write_bytes) = block_io
826        .as_deref()
827        .map(parse_usage_pair)
828        .transpose()
829        .map_err(|()| "invalid BlockIO".to_string())?
830        .unwrap_or((None, None));
831
832    Ok(MetricSample {
833        container_id: string(value, &["ID", "Container"]),
834        container_name: string(value, &["Name"]),
835        timestamp: {
836            let ts = string(value, &["Timestamp"]);
837            if ts.is_empty() {
838                chrono::Utc::now().to_rfc3339()
839            } else {
840                ts
841            }
842        },
843        cpu_percent: optional_string(value, &["CPUPerc"])
844            .as_deref()
845            .and_then(|s| s.trim().trim_end_matches('%').parse().ok()),
846        memory_usage_bytes,
847        memory_limit_bytes,
848        network_rx_bytes,
849        network_tx_bytes,
850        disk_read_bytes,
851        disk_write_bytes,
852    })
853}
854
855#[must_use]
856pub fn metric_sample_from_bollard_stats(
857    container_id: &str,
858    container_name: &str,
859    stats: &bollard::models::ContainerStatsResponse,
860) -> MetricSample {
861    let cpu_percent = stats.cpu_stats.as_ref().and_then(|cpu| {
862        let total = cpu
863            .cpu_usage
864            .as_ref()
865            .and_then(|u| u.total_usage)
866            .unwrap_or(0);
867        let system = cpu.system_cpu_usage.unwrap_or(0);
868        let cpus = cpu.online_cpus.unwrap_or(1).max(1);
869        if system > 0 && total > 0 {
870            Some((total as f64 / system as f64) * f64::from(cpus) * 100.0)
871        } else {
872            None
873        }
874    });
875    MetricSample {
876        container_id: container_id.to_owned(),
877        container_name: container_name.to_owned(),
878        timestamp: chrono::Utc::now().to_rfc3339(),
879        cpu_percent,
880        memory_usage_bytes: stats.memory_stats.as_ref().and_then(|m| m.usage),
881        memory_limit_bytes: stats.memory_stats.as_ref().and_then(|m| m.limit),
882        network_rx_bytes: stats
883            .networks
884            .as_ref()
885            .and_then(|n| n.values().next())
886            .and_then(|n| n.rx_bytes),
887        network_tx_bytes: stats
888            .networks
889            .as_ref()
890            .and_then(|n| n.values().next())
891            .and_then(|n| n.tx_bytes),
892        disk_read_bytes: None,
893        disk_write_bytes: None,
894    }
895}
896
897// ── Utility functions ──────────────────────────────────────────────────────
898
899fn labels_map_to_vec(labels: Option<&HashMap<String, String>>) -> Vec<String> {
900    labels
901        .map(|m| m.iter().map(|(k, v)| format!("{k}={v}")).collect())
902        .unwrap_or_default()
903}
904
905fn ports_from_summary(ports: Option<&Vec<PortSummary>>) -> Vec<String> {
906    ports
907        .map(|p| {
908            p.iter()
909                .map(|port| {
910                    let typ = port
911                        .typ
912                        .as_ref()
913                        .map_or_else(|| "tcp".to_owned(), |e| format!("{e:?}").to_lowercase());
914                    port.public_port.map_or_else(
915                        || format!("{}/{}", port.private_port, typ),
916                        |host_port| {
917                            format!(
918                                "{}:{}->{}/{}",
919                                port.ip.as_deref().unwrap_or("0.0.0.0"),
920                                host_port,
921                                port.private_port,
922                                typ
923                            )
924                        },
925                    )
926                })
927                .collect()
928        })
929        .unwrap_or_default()
930}
931
932fn unix_to_iso(unix: i64) -> String {
933    use chrono::TimeZone;
934    chrono::Utc
935        .timestamp_opt(unix, 0)
936        .single()
937        .map_or_else(|| unix.to_string(), |dt| dt.to_rfc3339())
938}
939
940#[must_use]
941pub fn format_bytes_human(bytes: u64) -> String {
942    if bytes >= ONE_GB {
943        format!("{:.1}GB", bytes as f64 / ONE_GB as f64)
944    } else if bytes >= 1_048_576 {
945        format!("{:.1}MB", bytes as f64 / 1_048_576.0)
946    } else if bytes >= 1024 {
947        format!("{:.1}KB", bytes as f64 / 1024.0)
948    } else {
949        format!("{bytes}B")
950    }
951}
952
953fn string(value: &serde_json::Value, keys: &[&str]) -> String {
954    optional_string(value, keys).unwrap_or_default()
955}
956fn optional_string(value: &serde_json::Value, keys: &[&str]) -> Option<String> {
957    keys.iter()
958        .find_map(|key| value.get(*key))
959        .and_then(serde_json::Value::as_str)
960        .map(str::trim)
961        .filter(|v| !v.is_empty())
962        .map(str::to_owned)
963}
964
965fn parse_usage_pair(value: &str) -> Result<(Option<u64>, Option<u64>), ()> {
966    let mut parts = value.split('/').map(str::trim);
967    let left = parts.next().ok_or(())?;
968    let right = parts.next();
969    Ok((
970        parse_byte_quantity(left)?,
971        right.map(parse_byte_quantity).transpose()?.flatten(),
972    ))
973}
974
975fn parse_byte_quantity(value: &str) -> Result<Option<u64>, ()> {
976    let v = value.trim();
977    if v.is_empty() || v == "--" {
978        return Ok(None);
979    }
980    let split = v
981        .find(|c: char| !(c.is_ascii_digit() || c == '.'))
982        .unwrap_or(v.len());
983    let number: f64 = v[..split].trim().parse().map_err(|_| ())?;
984    let unit = v[split..].trim().to_ascii_lowercase();
985    let mult = match unit.as_str() {
986        "" | "b" => 1.0,
987        "kb" => 1_000.0,
988        "mb" => 1_000_000.0,
989        "gb" => 1_000_000_000.0,
990        "tb" => 1_000_000_000_000.0,
991        "kib" => 1024.0,
992        "mib" => 1024.0 * 1024.0,
993        "gib" => 1024.0 * 1024.0 * 1024.0,
994        "tib" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
995        _ => return Err(()),
996    };
997    Ok(Some((number * mult).round() as u64))
998}
999
1000#[cfg(test)]
1001mod tests {
1002    use super::*;
1003
1004    fn rt() -> tokio::runtime::Runtime {
1005        tokio::runtime::Runtime::new().unwrap()
1006    }
1007
1008    #[test]
1009    fn mock_client_works() {
1010        let client = MockDockerClient {
1011            containers: vec![Container {
1012                id: "abc".into(),
1013                name: "api".into(),
1014                state: "running".into(),
1015                ..Container::default()
1016            }],
1017            ..Default::default()
1018        };
1019        let containers = rt().block_on(client.list_containers()).unwrap();
1020        assert_eq!(containers[0].name, "api");
1021    }
1022
1023    #[test]
1024    fn mock_inspect_not_found() {
1025        let err = rt()
1026            .block_on(MockDockerClient::default().inspect_container("x"))
1027            .unwrap_err();
1028        assert!(matches!(err, DockerError::NotFound(_)));
1029    }
1030
1031    #[test]
1032    fn mock_ping() {
1033        assert!(rt().block_on(MockDockerClient::default().ping()).unwrap());
1034    }
1035
1036    #[test]
1037    fn parses_usage_pair() {
1038        assert_eq!(
1039            parse_usage_pair("12.5MiB / 1GiB"),
1040            Ok((Some(13_107_200), Some(1_073_741_824)))
1041        );
1042    }
1043
1044    #[test]
1045    fn normalizes_docker_stats_json() {
1046        let value: serde_json::Value = serde_json::from_str(
1047            r#"{"Container":"abc123","Name":"api","CPUPerc":"87.50%","MemUsage":"128MiB / 1GiB","NetIO":"1.5kB / 2kB","BlockIO":"4MiB / 8MiB"}"#,
1048        ).unwrap();
1049        let sample = metric_sample_from_stats_json(&value).unwrap();
1050        assert_eq!(sample.container_id, "abc123");
1051        assert_eq!(sample.cpu_percent, Some(87.5));
1052    }
1053}