1use 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
33pub type DockerEventStream = Pin<Box<dyn Stream<Item = Result<DockerEvent, DockerError>> + Send>>;
35
36pub type DockerLogStream = Pin<Box<dyn Stream<Item = Result<String, DockerError>> + Send>>;
38
39#[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#[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
126pub 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#[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
200async 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
214async 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
222async 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
230async 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#[derive(Clone)]
276pub struct BollardDockerClient {
277 docker: bollard::Docker,
278 api_cfg: DockerApiConfig,
279}
280
281impl BollardDockerClient {
282 pub fn connect() -> Result<Self, DockerError> {
284 Self::connect_with_config(&DolConfig::default())
285 }
286
287 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 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 #[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#[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
615fn 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(), 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
772pub 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#[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
897fn 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}