dockertest/
composition.rs

1//! Represent a concrete instance of an Image, before it is ran as a Container.
2
3use crate::image::Image;
4use crate::waitfor::{NoWait, WaitFor};
5
6use std::collections::HashMap;
7use tracing::{event, Level};
8
9/// Specifies the starting policy of a container specification.
10///
11/// - [StartPolicy::Strict] policy will enforce that the container is started in the order
12///     it was added to [DockerTest].
13/// - [StartPolicy::Relaxed] policy will not enforce any ordering,
14///     all container specifications with a relaxed policy will be started concurrently.
15///     These are all started asynchrously started before the strict policy containers
16///     are started sequentially.
17///
18/// [DockerTest]: crate::DockerTest
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub enum StartPolicy {
21    /// Concurrently start the Container with other Relaxed instances.
22    Relaxed,
23    /// Start Containers' sequentially in the order added to DockerTest.
24    Strict,
25}
26
27/// Specifies who is responsible for managing a static container.
28///
29/// - [StaticManagementPolicy::External] indicates that the user is responsible for managing the
30///     container, DockerTest will never start or remove/stop the container. The container will
31///     be available through its handle in [DockerOperations]. If no external network is
32///     supplied, the test-scoped network will be added to the external network, and subsequently
33///     removed once the test terminates.
34///     The externally managed container is assumed to be in a running state when the test starts.
35///     If DockerTest cannot locate the the container, the test will fail.
36/// - [StaticManagementPolicy::Internal] indicates that DockerTest will handle the lifecycle of
37///     the container between all DockerTest instances within the test binary.
38/// - [StaticManagementPolicy::Dynamic] indicates that DockerTest will start the
39///     container if it does not already exists and will not clean it up. This way the same
40///     container can be re-used across multiple `cargo test` invocations.
41///     If the `DOCKERTEST_DYNAMIC` environment variable is set to `INTERNAL` or `EXTERNAL`, the management policy
42///     will instead be set accordingly (either [StaticManagementPolicy::Internal] or [StaticManagementPolicy::External].
43///     The purpose of this is to facilitate running tests locally and in CI/CD pipelines without having to alter management policies.
44///     If a container already exists in a non-running state with the same name as a container with this policy, the startup
45///     procedure will fail.
46///
47/// [DockerOperations]: crate::DockerOperations
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub enum StaticManagementPolicy {
50    /// The lifecycle of the container is managed by the user.
51    External,
52    /// DockerTest handles the lifecycle of the container.
53    Internal,
54    /// DockerTest starts the container if it does not exist and does not remove it, and will
55    /// re-use the container across `cargo test` invocations.
56    Dynamic,
57}
58
59/// Specifies how should dockertest should handle log output from this container.
60#[derive(Clone, Debug)]
61pub enum LogAction {
62    /// Forward all outputs to their respective output sources of the dockertest process.
63    Forward,
64    /// Forward [LogSource] outputs to a specified file.
65    ForwardToFile {
66        /// The filepath to output to.
67        path: String,
68    },
69    /// Forward [LogSource] outputs to stdout of the dockertest process.
70    ForwardToStdOut,
71    /// Forward [LogSource] outputs to stderr of the dockertest process.
72    ForwardToStdErr,
73}
74
75/// Specifies which log sources we want to read from containers.
76#[derive(Clone, Debug)]
77pub enum LogSource {
78    /// Read stderr only.
79    StdErr,
80    /// Read stdout only.
81    StdOut,
82    /// Read stdout and stderr.
83    Both,
84}
85
86/// Specifies when [LogAction] is applicable.
87#[derive(Clone, Debug)]
88pub enum LogPolicy {
89    /// [LogAction] is always applicable.
90    Always,
91    /// [LogAction] is applicable only if an error occures.
92    OnError,
93    /// [LogAction] is applicable only if a startup error occures.
94    OnStartupError,
95}
96
97/// Specifies how dockertest should handle logging output from this specific container.
98#[derive(Clone, Debug)]
99pub struct LogOptions {
100    /// The logging actions to be performed.
101    pub action: LogAction,
102    /// Under which conditions should we perform the log actions?
103    pub policy: LogPolicy,
104    /// Specifies log sources we want to read from container.
105    pub source: LogSource,
106}
107
108impl Default for LogOptions {
109    fn default() -> LogOptions {
110        LogOptions {
111            action: LogAction::Forward,
112            policy: LogPolicy::OnError,
113            source: LogSource::StdErr,
114        }
115    }
116}
117
118/// Represents an instance of an [Image].
119///
120/// The Composition is used to specialize an image whose name, version, tag and source is known,
121/// but before one can create a [crate::container:: OperationalContainer] from an image,
122/// it must be augmented with information about how to start it, how to ensure it has been
123/// started, environment variables and runtime commands.
124/// Thus, this structure represents the concrete instance of an [Image] that will be started
125/// and become a [crate::container::OperationalContainer].
126///
127/// NOTE: This is an internal implementation detail. This used to be a public interface.
128#[derive(Clone, Debug)]
129pub struct Composition {
130    /// User provided name of the container.
131    ///
132    /// This will dictate the final container_name and the container_handle_key of the container
133    /// that will be created from this Composition.
134    user_provided_container_name: Option<String>,
135
136    /// Network aliases for the container.
137    pub(crate) network_aliases: Option<Vec<String>>,
138
139    /// The name of the container to be created by this Composition.
140    ///
141    /// When the composition is created, this field defaults to the repository name of the
142    /// associated image. If the user provides an alternative container name, this will be stored
143    /// in its own dedicated field.
144    ///
145    /// The final format of the container name we will create will be on the following format:
146    /// `{namespace}-{name}-{suffix}` where
147    /// - `{namespace}` is the configured namespace with [crate::DockerTest].
148    /// - `{name}` is either the user provided container name, or this default value.
149    /// - `{suffix}` randomly generated pattern.
150    pub(crate) container_name: String,
151
152    /// A trait object holding the implementation that indicate container readiness.
153    pub(crate) wait: Box<dyn WaitFor>,
154
155    /// The environmentable variables that will be passed to the container.
156    pub(crate) env: HashMap<String, String>,
157
158    /// The command to pass to the container.
159    pub(crate) cmd: Vec<String>,
160
161    /// The start policy of this container, codifing the inter-depdencies between containers.
162    pub(crate) start_policy: StartPolicy,
163
164    /// The base image that will be the container we will be starting.
165    image: Image,
166
167    /// Named volumes associated with this composition, are in the form of:
168    /// - "(VOLUME_NAME,CONTAINER_PATH)"
169    pub(crate) named_volumes: Vec<(String, String)>,
170
171    /// Final form of named volume names.
172    ///
173    /// DockerTest is responsible for constructing the final names and adding them to this vector.
174    /// The final name will be on the form `VOLUME_NAME-RANDOM_SUFFIX/CONTAINER_PATH`.
175    pub(crate) final_named_volume_names: Vec<String>,
176
177    /// Bind mounts associated with this composition, are in the form of:
178    /// - `HOST_PATH:CONTAINER_PATH`
179    ///
180    /// NOTE: As bind mounts do not outlive the container they are mounted in they do not need to
181    /// be cleaned up.
182    pub(crate) bind_mounts: Vec<String>,
183
184    /// All user specified container name injections as environment variables.
185    /// Tuple contains (handle, env).
186    pub(crate) inject_container_name_env: Vec<(String, String)>,
187
188    /// Port mapping (used for Windows-compatibility)
189    pub(crate) port: Vec<(String, String)>,
190
191    /// Allocates an ephemeral host port for all of a container’s exposed ports.
192    ///
193    /// Port forwarding is useful on operating systems where there is no network connectivity
194    /// between system and the Docker Desktop VM.
195    pub(crate) publish_all_ports: bool,
196
197    /// Who is responsible for managing the lifecycle of the container.
198    ///
199    /// A composition can be marked as static, where the lifecycle of the container outlives
200    /// the individual test.
201    management: Option<StaticManagementPolicy>,
202
203    /// Logging options for this specific container.
204    pub(crate) log_options: Option<LogOptions>,
205
206    /// Tmpfs mount paths to create.
207    ///
208    /// These are destination paths within the container to create tmpfs filesystems for,
209    /// they require no source paths.
210    ///
211    /// tmpfs details: <https://docs.docker.com/engine/storage/tmpfs/>
212    pub(crate) tmpfs: Vec<String>,
213
214    /// Whether this composition should be started in privileged mode.
215    /// Privileged mode is required for some images, such as the `docker:dind` image.
216    /// See https://docs.docker.com/engine/reference/run/#runtime-privilege-and-linux-capabilities
217    /// for more information.
218    /// Defaults to false.
219    /// NOTE: This is only supported on Linux.
220    /// NOTE: This is only supported on Docker 1.13 and above.
221    /// NOTE: This is only supported on Docker API 1.25 and above.
222    /// NOTE: This is only supported on Docker Engine 1.13 and above.
223    pub(crate) privileged: bool,
224}
225
226impl Composition {
227    /// Creates a [Composition] based on the [Image] repository name provided.
228    ///
229    /// This will internally create the [Image] based on the provided repository name,
230    /// and default the tag to `latest`.
231    ///
232    /// This is the shortcut method of constructing a [Composition].
233    /// See [with_image](Composition::with_image) to create one with a provided [Image].
234    pub fn with_repository<T: ToString>(repository: T) -> Composition {
235        let copy = repository.to_string();
236        Composition {
237            user_provided_container_name: None,
238            network_aliases: None,
239            image: Image::with_repository(&copy),
240            container_name: copy.replace('/', "-"),
241            wait: Box::new(NoWait {}),
242            env: HashMap::new(),
243            cmd: Vec::new(),
244            start_policy: StartPolicy::Relaxed,
245            bind_mounts: Vec::new(),
246            named_volumes: Vec::new(),
247            inject_container_name_env: Vec::new(),
248            final_named_volume_names: Vec::new(),
249            port: Vec::new(),
250            publish_all_ports: false,
251            management: None,
252            log_options: Some(LogOptions::default()),
253            privileged: false,
254            tmpfs: Vec::new(),
255        }
256    }
257
258    /// Creates a [Composition] with the provided [Image].
259    ///
260    /// This is the long-winded way of defining a [Composition].
261    /// See [with_repository](Composition::with_repository) for the shortcut method.
262    pub fn with_image(image: Image) -> Composition {
263        Composition {
264            user_provided_container_name: None,
265            network_aliases: None,
266            container_name: image.repository().to_string().replace('/', "-"),
267            image,
268            wait: Box::new(NoWait {}),
269            env: HashMap::new(),
270            cmd: Vec::new(),
271            start_policy: StartPolicy::Relaxed,
272            bind_mounts: Vec::new(),
273            named_volumes: Vec::new(),
274            inject_container_name_env: Vec::new(),
275            final_named_volume_names: Vec::new(),
276            port: Vec::new(),
277            publish_all_ports: false,
278            management: None,
279            log_options: Some(LogOptions::default()),
280            privileged: false,
281            tmpfs: Vec::new(),
282        }
283    }
284
285    /// Adds the given tmpfs mount paths to this [Composition]
286    ///
287    /// See [tmpfs] for details.
288    ///
289    /// [tmpfs]: Composition::tmpfs
290    #[cfg(target_os = "linux")]
291    pub fn with_tmpfs(self, paths: Vec<String>) -> Composition {
292        Composition {
293            tmpfs: paths,
294            ..self
295        }
296    }
297
298    /// Sets the [StartPolicy] for this [Composition].
299    ///
300    /// Defaults to a [relaxed](StartPolicy::Relaxed) policy.
301    pub fn with_start_policy(self, start_policy: StartPolicy) -> Composition {
302        Composition {
303            start_policy,
304            ..self
305        }
306    }
307
308    /// Assigns the full set of environmental variables available for the [OperationalContainer].
309    ///
310    /// Each key in the map should be the environmental variable name
311    /// and its corresponding value will be set as its value.
312    ///
313    /// This method replaces the entire existing env map provided.
314    ///
315    /// [OperationalContainer]: crate::container::OperationalContainer
316    pub fn with_env(self, env: HashMap<String, String>) -> Composition {
317        Composition { env, ..self }
318    }
319
320    /// Sets the command of the container.
321    ///
322    /// If no entries in the command vector is provided to the [Composition],
323    /// the command within the [Image] will be used, if any.
324    pub fn with_cmd(self, cmd: Vec<String>) -> Composition {
325        Composition { cmd, ..self }
326    }
327
328    /// Add a host port mapping to the container.
329    ///
330    /// This is useful when the host environment running docker cannot support IP routing
331    /// within the docker network, such that test containers cannot communicate between themselves.
332    /// This escape hatch allows the host to be involved to route traffic.
333    /// This mechanism is not recommended, as concurrent tests utilizing the same host port
334    /// will fail since the port is already in use.
335    /// It is recommended to use [Composition::publish_all_ports].
336    ///
337    /// If an port mapping on the exported port has already been issued on the [Composition],
338    /// it will be overidden.
339    pub fn port_map(&mut self, exported: u32, host: u32) -> &mut Composition {
340        self.port
341            .push((format!("{}/tcp", exported), format!("{}", host)));
342        self
343    }
344
345    /// Allocates an ephemeral host port for all of the container's exposed ports.
346    ///
347    /// Mapped host ports can be found via [crate::container::OperationalContainer::host_port] method.
348    pub fn publish_all_ports(&mut self, publish: bool) -> &mut Composition {
349        self.publish_all_ports = publish;
350        self
351    }
352
353    /// Sets the name of the container that will eventually be started.
354    ///
355    /// This is merely part of the final container name, and the full container name issued
356    /// to docker will be generated.
357    /// The container name assigned here is also used to resolve the `handle` concept used
358    /// by dockertest.
359    ///
360    /// The container name defaults to the repository name.
361    ///
362    /// NOTE: If the `Composition` is a static container with an
363    /// `External` management policy the container name *MUST* match the container_name of
364    /// the external container and is required to be set.
365    pub fn with_container_name<T: ToString>(self, container_name: T) -> Composition {
366        Composition {
367            user_provided_container_name: Some(container_name.to_string()),
368            ..self
369        }
370    }
371
372    /// Sets network aliases for this `Composition`.
373    pub fn with_alias(self, aliases: Vec<String>) -> Composition {
374        Composition {
375            network_aliases: Some(aliases),
376            ..self
377        }
378    }
379
380    /// Adds network alias to this `Composition`
381    pub fn alias(&mut self, alias: String) -> &mut Composition {
382        match self.network_aliases {
383            Some(ref mut network_aliases) => network_aliases.push(alias),
384            None => self.network_aliases = Some(vec![alias]),
385        };
386        self
387    }
388
389    /// Sets the `WaitFor` trait object for this `Composition`.
390    ///
391    /// The default `WaitFor` implementation used is [RunningWait].
392    ///
393    /// [RunningWait]: crate::waitfor::RunningWait
394    pub fn with_wait_for(self, wait: Box<dyn WaitFor>) -> Composition {
395        Composition { wait, ..self }
396    }
397
398    /// Sets log options for this `Composition`.
399    /// By default `LogAction::Forward`, `LogPolicy::OnError`, and `LogSource::StdErr` is enabled.
400    /// To clear default log option pass `None` or specify your own log options.
401    pub fn with_log_options(self, log_options: Option<LogOptions>) -> Composition {
402        Composition {
403            log_options,
404            ..self
405        }
406    }
407
408    /// Sets the environment variable to the given value.
409    ///
410    /// NOTE: if [with_env] is called after a call to [env], all values added by [env] will be overwritten.
411    ///
412    /// [env]: Composition::env
413    /// [with_env]: Composition::with_env
414    pub fn env<T: ToString, S: ToString>(&mut self, name: T, value: S) -> &mut Composition {
415        self.env.insert(name.to_string(), value.to_string());
416        self
417    }
418
419    /// Appends the command string to the current command vector.
420    ///
421    /// If no entries in the command vector is provided to the [Composition],
422    /// the command within the [Image] will be used, if any.
423    ///
424    /// NOTE: if [with_cmd] is called after a call to [cmd], all entries to the command vector
425    /// added with [cmd] will be overwritten.
426    ///
427    /// [cmd]: Composition::cmd
428    /// [with_cmd]: Composition::with_cmd
429    pub fn cmd<T: ToString>(&mut self, cmd: T) -> &mut Composition {
430        self.cmd.push(cmd.to_string());
431        self
432    }
433
434    /// Appends the tmpfs mount path to the current set of tmpfs mount paths.
435    ///
436    /// NOTE: if [with_tmpfs] is called after a call to [tmpfs], all entries to the tmpfs vector
437    /// added with [with_tmpfs] will be overwritten.
438    ///
439    /// Details:
440    ///   - Only available on linux.
441    ///   - Size of the tmpfs mount defaults to 50% of the hosts total RAM.
442    ///   - Defaults to file mode '1777' (world-writable).
443    ///
444    /// [tmpfs]: Composition::tmpfs
445    /// [with_tmpfs]: Composition::with_tmpfs
446    #[cfg(target_os = "linux")]
447    pub fn tmpfs<T: ToString>(&mut self, path: T) -> &mut Composition {
448        self.tmpfs.push(path.to_string());
449        self
450    }
451
452    /// Adds the given named volume to the Composition.
453    /// Named volumes can be shared between containers, specifying the same named volume for
454    /// another Composition will give both access to the volume.
455    /// `path_in_container` has to be an absolute path.
456    pub fn named_volume<T: ToString, S: ToString>(
457        &mut self,
458        volume_name: T,
459        path_in_container: S,
460    ) -> &mut Composition {
461        self.named_volumes
462            .push((volume_name.to_string(), path_in_container.to_string()));
463        self
464    }
465    /// Adds the given bind mount to the Composition.
466    /// A bind mount only exists for a single container and maps a given file or directory from the
467    /// host to the container.
468    /// Use named volumes if you want to share data between containers.
469    /// The `host_path` can either point to a directory or a file that MUST exist on the local host.
470    /// `path_in_container` has to be an absolute path.
471    pub fn bind_mount<T: ToString, S: ToString>(
472        &mut self,
473        host_path: T,
474        path_in_container: S,
475    ) -> &mut Composition {
476        // The ':Z' is needed due to permission issues, see
477        // https://stackoverflow.com/questions/24288616/permission-denied-on-accessing-host-directory-in-docker
478        // for more details
479        self.bind_mounts.push(format!(
480            "{}:{}:Z",
481            host_path.to_string(),
482            path_in_container.to_string()
483        ));
484        self
485    }
486
487    /// Inject the generated container name identified by `handle` into
488    /// this Composition environment variable `env`.
489    ///
490    /// This is used to establish inter communication between running containers
491    /// controlled by dockertest. This is traditionally established through environment variables
492    /// for connection details, and thus the DNS resolving capabilities within docker will
493    /// map the container name into the correct IP address.
494    ///
495    /// To correctly use this feature, the `StartPolicy` between the dependant containers
496    /// must be configured such that these connections can successfully be established.
497    /// Dockertest will not make any attempt to verify the integrity of these dependencies.
498    pub fn inject_container_name<T: ToString, E: ToString>(
499        &mut self,
500        handle: T,
501        env: E,
502    ) -> &mut Composition {
503        self.inject_container_name_env
504            .push((handle.to_string(), env.to_string()));
505        self
506    }
507
508    /// Defines this as a static container which will will only be cleaned up after the full test
509    /// binary has executed.
510    /// If the static container is used across multiple tests in the same test binary, Dockertest can only guarantee that
511    /// the container will be started in its designated start order or earlier as other tests might
512    /// have already started it.
513    /// The container_name *MUST* be set to a unique value when using static containers.
514    /// To refer to the same container across `Dockertest` instances set the same container name for the
515    /// compostions.
516    ///
517    /// NOTE: When the `External` management policy is used, the container_name must be set to the
518    /// name of the external container.
519    pub fn static_container(&mut self, management: StaticManagementPolicy) -> &mut Composition {
520        let management = match management {
521            StaticManagementPolicy::External | StaticManagementPolicy::Internal => management,
522            StaticManagementPolicy::Dynamic => match std::env::var("DOCKERTEST_DYNAMIC") {
523                Ok(val) => match val.as_str() {
524                    "EXTERNAL" => StaticManagementPolicy::External,
525                    "INTERNAL" => StaticManagementPolicy::Internal,
526                    "DYNAMIC" => StaticManagementPolicy::Dynamic,
527                    _ => {
528                        event!(Level::WARN, "DOCKERTEST_DYNAMIC environment variable set to unknown value, defaulting to Dynamic policy");
529                        StaticManagementPolicy::Dynamic
530                    }
531                },
532                Err(_) => management,
533            },
534        };
535        self.management = Some(management);
536        self
537    }
538
539    /// Should this container be started with priviledged mode enabled?
540    /// This is required for some containers to run correctly.
541    /// See https://docs.docker.com/engine/reference/run/#runtime-privilege-and-linux-capabilities
542    /// for more details.
543    pub fn privileged(&mut self) -> &mut Composition {
544        self.privileged = true;
545        self
546    }
547
548    /// Fetch the assigned [StaticManagementPolicy], if any.
549    pub(crate) fn static_management_policy(&self) -> &Option<StaticManagementPolicy> {
550        &self.management
551    }
552
553    /// Query whether this Composition should be handled through static container checks.
554    pub(crate) fn is_static(&self) -> bool {
555        self.management.is_some()
556    }
557
558    // Configure the container's name with the given namespace as prefix
559    // and suffix.
560    // We do this to ensure that we do not have overlapping container names
561    // and make it clear which containers are run by DockerTest.
562    pub(crate) fn configure_container_name(&mut self, namespace: &str, suffix: &str) {
563        let name = match &self.user_provided_container_name {
564            None => self.image.repository(),
565            Some(n) => n,
566        };
567
568        if !self.is_static() {
569            // The docker daemon does not like '/' or '\' in container names
570            let stripped_name = name.replace('/', "_");
571
572            self.container_name = format!("{}-{}-{}", namespace, stripped_name, suffix);
573        } else {
574            self.container_name = name.to_string();
575        }
576    }
577
578    // Returns the Image associated with this Composition.
579    pub(crate) fn image(&self) -> &Image {
580        &self.image
581    }
582
583    /// Retrieve a copy of the applicable handle name for this composition.
584    ///
585    /// NOTE: this value will be outdated if [Composition::with_container_name] is invoked
586    /// with a different name.
587    pub fn handle(&self) -> String {
588        match &self.user_provided_container_name {
589            None => self.image.repository().to_string(),
590            Some(n) => n.clone(),
591        }
592    }
593}