omry_integration/
containers.rs

1//! Utilities for setting up and working with test containers.
2use std::collections::HashMap;
3
4use anyhow::Result;
5use testcontainers::bollard::Docker;
6use testcontainers::bollard::query_parameters::ListNetworksOptions;
7use testcontainers::core::ContainerRequest;
8use testcontainers::core::logs::consumer::{LogConsumer, logging_consumer::LoggingConsumer};
9use testcontainers::{Image, ImageExt};
10
11/// Trait alias for the [`LogConsumer`] trait with trait bounds we need to use it
12/// with the [`ContainerConfig`] struct.
13pub trait ContainerLogConsumer: LogConsumer + Send + Sync + 'static {}
14impl<L> ContainerLogConsumer for L where L: LogConsumer + Send + Sync + 'static {}
15
16/// Container configuration for the subset of [`ImageExt`] parameters
17/// that we use in tests.
18#[derive(Debug, Clone)]
19pub struct ImageExtConfig<L>
20where
21    L: ContainerLogConsumer,
22{
23    container_name: Option<String>,
24    network: Option<String>,
25    log_consumer: Option<L>,
26}
27
28impl<L> ImageExtConfig<L>
29where
30    L: ContainerLogConsumer,
31{
32    /// Sets the container name in the config to the given string.
33    #[must_use]
34    pub fn with_container_name(mut self, container_name: String) -> Self {
35        self.container_name = Some(container_name);
36        self
37    }
38
39    /// Sets the container network name to the given string.
40    #[must_use]
41    pub fn with_network(mut self, name: String) -> Self {
42        self.network = Some(name);
43        self
44    }
45
46    /// Sets the log consumer for the container to the given log consumer.
47    #[must_use]
48    pub fn with_log_consumer(mut self, log_consumer: L) -> Self {
49        self.log_consumer = Some(log_consumer);
50        self
51    }
52
53    /// Calls the appropriate [`ImageExt`] methods for the values
54    /// that have been set in the other methods.
55    pub fn configure_container<RI, I>(self, image: RI) -> ContainerRequest<I>
56    where
57        RI: Into<ContainerRequest<I>>,
58        I: Image,
59    {
60        let mut container_request = image.into();
61
62        if let Some(container_name) = self.container_name {
63            container_request = container_request.with_container_name(container_name);
64        }
65
66        if let Some(network) = self.network {
67            container_request = container_request.with_network(network);
68        }
69
70        if let Some(log_consumer) = self.log_consumer {
71            container_request = container_request.with_log_consumer(log_consumer);
72        }
73
74        container_request
75    }
76}
77
78impl Default for ImageExtConfig<LoggingConsumer> {
79    fn default() -> Self {
80        Self {
81            container_name: None,
82            network: None,
83            log_consumer: None,
84        }
85    }
86}
87
88/// Image configuration for the subset of [`Image`] parameters
89/// that we use in tests.
90#[derive(Debug, Copy, Clone)]
91pub struct ImageConfig {
92    /// Whether the image should be configured to expose any posts to the host.
93    ///
94    /// If [`true`], it is up to the [`Image`] implementation to decide
95    /// which ports to expose, if any.
96    pub should_expose_ports: bool,
97}
98
99impl Default for ImageConfig {
100    /// Creates an [`ImageConfig`] with the default values that make sense
101    /// for most tests.
102    fn default() -> Self {
103        Self {
104            should_expose_ports: true,
105        }
106    }
107}
108
109impl ImageConfig {
110    /// Sets `should_expose_ports` to the given value.
111    #[must_use]
112    pub fn with_expose_ports(mut self, should_expose_ports: bool) -> Self {
113        self.should_expose_ports = should_expose_ports;
114        self
115    }
116}
117
118/// Struct to hold container configuration values.
119#[derive(Debug)]
120pub struct ContainerConfig<L>
121where
122    L: ContainerLogConsumer,
123{
124    /// Subset of options available for configuring [`Image`] implementations.
125    pub image_config: ImageConfig,
126
127    /// Subset of options available for configuring containers via [`ImageExt`] methods.
128    pub image_ext_config: ImageExtConfig<L>,
129}
130
131impl<L> ContainerConfig<L>
132where
133    L: ContainerLogConsumer,
134{
135    /// Creates a new [`ContainerConfig`] with the given configuration values.
136    pub fn new(image_config: ImageConfig, image_ext_config: ImageExtConfig<L>) -> Self {
137        Self {
138            image_config,
139            image_ext_config,
140        }
141    }
142
143    /// Alias for [`ImageExtConfig::configure_container`].
144    pub fn configure_container<RI, I>(self, image: RI) -> ContainerRequest<I>
145    where
146        RI: Into<ContainerRequest<I>>,
147        I: Image,
148    {
149        self.image_ext_config.configure_container(image)
150    }
151}
152
153impl Default for ContainerConfig<LoggingConsumer> {
154    fn default() -> Self {
155        Self {
156            image_config: ImageConfig::default(),
157            image_ext_config: ImageExtConfig::default(),
158        }
159    }
160}
161
162/// Tries to get the gateway IP of the docker bridge network.
163///
164/// We need this to connect to local services within docker
165/// regardless if we're running on Mac or linux.
166///
167/// ## Errors
168/// Returns [`anyhow::Result`] if we can't:
169///
170/// - connect to Docker on the default socket
171/// - list Docker networks
172/// - find the 'bridge' network
173/// - get the IPAM gateway IP for the bridge network
174pub async fn get_bridge_gateway_ip() -> Result<String> {
175    let docker = Docker::connect_with_socket_defaults()?;
176
177    let mut filters = HashMap::new();
178    let network_name = "bridge".to_string();
179    filters.insert("name".to_string(), vec![network_name.clone()]);
180    let list_options = ListNetworksOptions {
181        filters: Some(filters),
182    };
183
184    let networks = docker.list_networks(Some(list_options)).await?;
185    let bridge = networks
186        .first()
187        .ok_or_else(|| anyhow::anyhow!("can't find network '{network_name}'"))?;
188
189    let gateway_ip = bridge
190        .ipam
191        .as_ref()
192        .and_then(|ipam| ipam.config.as_ref())
193        .and_then(|configs| configs.first())
194        .and_then(|config| config.gateway.as_ref())
195        .ok_or_else(|| {
196            anyhow::anyhow!(
197                "can't get the IPAM gateway IP for network. \
198        full network object:\n{bridge:#?}"
199            )
200        })?;
201
202    Ok(gateway_ip.to_string())
203}