dockertest 0.3.1

A library to control docker containers when running your integration tests.
Documentation
//! Represent a concrete instance of an Image, before it is ran as a Container.

use crate::container::{CreatedContainer, PendingContainer};
use crate::image::Image;
use crate::static_container::STATIC_CONTAINERS;
use crate::waitfor::{NoWait, WaitFor};
use crate::DockerTestError;

use bollard::{
    container::{
        Config, CreateContainerOptions, InspectContainerOptions, NetworkingConfig,
        RemoveContainerOptions,
    },
    models::HostConfig,
    service::{EndpointSettings, PortBinding},
    Docker,
};

use futures::future::TryFutureExt;
use std::collections::HashMap;
use tracing::{event, Level};

/// Specifies the starting policy of a [Composition].
///
/// - [StartPolicy::Strict] policy will enforce that the composition is started in the order
///     it was added to [crate::DockerTest].
/// - [StartPolicy::Relaxed] policy will not enforce any ordering,
///     all Compositions with a relaxed policy will be started concurrently.
///     These are all started asynchrously started before the strict policy containers
///     are started sequentially.
#[derive(Clone, PartialEq)]
pub enum StartPolicy {
    /// Concurrently start the Container with other Relaxed instances.
    Relaxed,
    /// Start Containers' sequentially in the order added to DockerTest.
    Strict,
}

/// Specifies who is responsible for managing a static container.
///
/// - [StaticManagementPolicy::External] indicates that the user is responsible for managing the
///     container, DockerTest will never start or remove/stop the container. The container will
///     be available through its handle in [crate::DockerOperations]. If no external network is
///     supplied, the test-scoped network will be added to the external network, and subsequently
///     removed once the test terminates.
///     The externally managed container is assumed to be in a running state when the test starts.
///     If DockerTest cannot locate the the container, the test will fail.
/// - [StaticManagementPolicy::Internal] indicates that DockerTest will handle the lifecycle of
///     the container between all DockerTest instances within the test binary.
/// - [StaticManagementPolicy::Dynamic] indicates that DockerTest will start the
///     container if it does not already exists and will not clean it up. This way the same
///     container can be re-used across multiple `cargo test` invocations.
///     If the `DOCKERTEST_DYNAMIC` environment variable is set to `INTERNAL` or `EXTERNAL`, the management policy
///     will instead be set accordingly (either [StaticManagementPolicy::Internal] or [StaticManagementPolicy::External].
///     The purpose of this is to facilitate running tests locally and in CI/CD pipelines without having to alter management policies.
///     If a container already exists in a non-running state with the same name as a container with this policy, the startup
///     procedure will fail.
#[derive(Clone, PartialEq)]
pub enum StaticManagementPolicy {
    /// The lifecycle of the container is managed by the user.
    External,
    /// DockerTest handles the lifecycle of the container.
    Internal,
    /// DockerTest starts the container if it does not exist and does not remove it, and will
    /// re-use the container across `cargo test` invocations.
    Dynamic,
}

/// Specifies how should a [Composition] handle logs.
#[derive(Clone, Debug)]
pub enum LogAction {
    /// Forward all outputs to their respective output sources of the dockertest process.
    Forward,
    /// Forward [LogSource] outputs to a specified file.
    ForwardToFile {
        /// The filepath to output to.
        path: String,
    },
    /// Forward [LogSource] outputs to stdout of the dockertest process.
    ForwardToStdOut,
    /// Forward [LogSource] outputs to stderr of the dockertest process.
    ForwardToStdErr,
}

/// Specifies which log sources we want to read from containers.
#[derive(Clone, Debug)]
pub enum LogSource {
    /// Read stderr only.
    StdErr,
    /// Read stdout only.
    StdOut,
    /// Read stdout and stderr.
    Both,
}

/// Specifies when [LogAction] is applicable.
#[derive(Clone, Debug)]
pub enum LogPolicy {
    /// [LogAction] is always applicable.
    Always,
    /// [LogAction] is applicable only if an error occures.
    OnError,
}

/// Specifies how [Composition] should handle container logging.
#[derive(Clone, Debug)]
pub struct LogOptions {
    /// The logging actions to be performed
    pub action: LogAction,
    /// Under which conditions should we perform the log actions?
    pub policy: LogPolicy,
    /// Specifies log sources we want to read from container.
    pub source: LogSource,
}

impl Default for LogOptions {
    fn default() -> LogOptions {
        LogOptions {
            action: LogAction::Forward,
            policy: LogPolicy::OnError,
            source: LogSource::StdErr,
        }
    }
}

/// Represents an instance of an [Image].
///
/// The [Composition] is used to specialize an image whose name, version, tag and source is known,
/// but before one can create a [crate::container:: RunningContainer] from an image,
/// it must be augmented with information about how to start it, how to ensure it has been
/// started, environment variables and runtime commands.
/// Thus, this structure represents the concrete instance of an [Image] that will be started
/// and become a [crate::container::RunningContainer].
///
/// # Examples
/// ```rust
/// # use dockertest::Composition;
/// let mut hello = Composition::with_repository("hello-world")
///     .with_container_name("my-hello-world")
///     .with_cmd(vec!["command".to_string(), "arg".to_string()]);
/// hello.env("MY_ENV", "MY VALUE");
/// hello.cmd("appended_to_original_cmd!");
/// ```
#[derive(Clone)]
pub struct Composition {
    /// User provided name of the container.
    ///
    /// This will dictate the final container_name and the container_handle_key of the container
    /// that will be created from this Composition.
    user_provided_container_name: Option<String>,

    /// Network aliases for the container.
    network_aliases: Option<Vec<String>>,

    /// The name of the container to be created by this Composition.
    ///
    /// When the composition is created, this field defaults to the repository name of the
    /// associated image. If the user provides an alternative container name, this will be stored
    /// in its own dedicated field.
    ///
    /// The final format of the container name we will create will be on the following format:
    /// `{namespace}-{name}-{suffix}` where
    /// - `{namespace}` is the configured namespace with [crate::DockerTest].
    /// - `{name}` is either the user provided container name, or this default value.
    /// - `{suffix}` randomly generated pattern.
    pub(crate) container_name: String,

    /// A trait object holding the implementation that indicate container readiness.
    wait: Box<dyn WaitFor>,

    /// The environmentable variables that will be passed to the container.
    pub(crate) env: HashMap<String, String>,

    /// The command to pass to the container.
    cmd: Vec<String>,

    /// The start policy of this container, codifing the inter-depdencies between containers.
    start_policy: StartPolicy,

    /// The base image that will be the container we will be starting.
    image: Image,

    /// Named volumes associated with this composition, are in the form of:
    /// - "(VOLUME_NAME,CONTAINER_PATH)"
    pub(crate) named_volumes: Vec<(String, String)>,

    /// Final form of named volume names.
    ///
    /// DockerTest is responsible for constructing the final names and adding them to this vector.
    /// The final name will be on the form `VOLUME_NAME-RANDOM_SUFFIX/CONTAINER_PATH`.
    pub(crate) final_named_volume_names: Vec<String>,

    /// Bind mounts associated with this composition, are in the form of:
    /// - `HOST_PATH:CONTAINER_PATH`
    ///
    /// NOTE: As bind mounts do not outlive the container they are mounted in they do not need to
    /// be cleaned up.
    bind_mounts: Vec<String>,

    /// All user specified container name injections as environment variables.
    /// Tuple contains (handle, env).
    pub(crate) inject_container_name_env: Vec<(String, String)>,

    /// Port mapping (used for Windows-compatibility)
    port: Vec<(String, String)>,

    /// Allocates an ephemeral host port for all of a container’s exposed ports.
    ///
    /// Port forwarding is useful on operating systems where there is no network connectivity
    /// between system and the Docker Desktop VM.
    pub(crate) publish_all_ports: bool,

    /// Who is responsible for managing the lifecycle of the container.
    ///
    /// A composition can be marked as static, where the lifecycle of the container outlives
    /// the individual test.
    management: Option<StaticManagementPolicy>,

    /// Logging options for this specific container.
    pub(crate) log_options: Option<LogOptions>,
}

impl Composition {
    /// Creates a [Composition] based on the [Image] repository name provided.
    ///
    /// This will internally create the [Image] based on the provided repository name,
    /// and default the tag to `latest`.
    ///
    /// This is the shortcut method of constructing a [Composition].
    /// See [with_image](Composition::with_image) to create one with a provided [Image].
    pub fn with_repository<T: ToString>(repository: T) -> Composition {
        let copy = repository.to_string();
        Composition {
            user_provided_container_name: None,
            network_aliases: None,
            image: Image::with_repository(&copy),
            container_name: copy.replace('/', "-"),
            wait: Box::new(NoWait {}),
            env: HashMap::new(),
            cmd: Vec::new(),
            start_policy: StartPolicy::Relaxed,
            bind_mounts: Vec::new(),
            named_volumes: Vec::new(),
            inject_container_name_env: Vec::new(),
            final_named_volume_names: Vec::new(),
            port: Vec::new(),
            publish_all_ports: false,
            management: None,
            log_options: Some(LogOptions::default()),
        }
    }

    /// Creates a [Composition] with the provided [Image].
    ///
    /// This is the long-winded way of defining a [Composition].
    /// See [with_repository](Composition::with_repository) for the shortcut method.
    pub fn with_image(image: Image) -> Composition {
        Composition {
            user_provided_container_name: None,
            network_aliases: None,
            container_name: image.repository().to_string().replace('/', "-"),
            image,
            wait: Box::new(NoWait {}),
            env: HashMap::new(),
            cmd: Vec::new(),
            start_policy: StartPolicy::Relaxed,
            bind_mounts: Vec::new(),
            named_volumes: Vec::new(),
            inject_container_name_env: Vec::new(),
            final_named_volume_names: Vec::new(),
            port: Vec::new(),
            publish_all_ports: false,
            management: None,
            log_options: Some(LogOptions::default()),
        }
    }

    /// Sets the [StartPolicy] for this [Composition].
    ///
    /// Defaults to a [relaxed](StartPolicy::Relaxed) policy.
    pub fn with_start_policy(self, start_policy: StartPolicy) -> Composition {
        Composition {
            start_policy,
            ..self
        }
    }

    /// Assigns the full set of environmental variables available for the [RunningContainer].
    ///
    /// Each key in the map should be the environmental variable name
    /// and its corresponding value will be set as its value.
    ///
    /// This method replaces the entire existing env map provided.
    ///
    /// [RunningContainer]: crate::container::RunningContainer
    pub fn with_env(self, env: HashMap<String, String>) -> Composition {
        Composition { env, ..self }
    }

    /// Sets the command of the container.
    ///
    /// If no entries in the command vector is provided to the [Composition],
    /// the command within the [Image] will be used, if any.
    pub fn with_cmd(self, cmd: Vec<String>) -> Composition {
        Composition { cmd, ..self }
    }

    /// Add a host port mapping to the container.
    ///
    /// This is useful when the host environment running docker cannot support IP routing
    /// within the docker network, such that test containers cannot communicate between themselves.
    /// This escape hatch allows the host to be involved to route traffic.
    /// This mechanism is not recommended, as concurrent tests utilizing the same host port
    /// will fail since the port is already in use.
    /// It is recommended to use [Composition::publish_all_ports].
    ///
    /// If an port mapping on the exported port has already been issued on the [Composition],
    /// it will be overidden.
    pub fn port_map(&mut self, exported: u32, host: u32) -> &mut Composition {
        self.port
            .push((format!("{}/tcp", exported), format!("{}", host)));
        self
    }

    /// Allocates an ephemeral host port for all of the container's exposed ports.
    ///
    /// Mapped host ports can be found via [crate::container::RunningContainer::host_port] method.
    pub fn publish_all_ports(&mut self) -> &mut Composition {
        self.publish_all_ports = true;
        self
    }

    /// Sets the name of the container that will eventually be started.
    ///
    /// This is merely part of the final container name, and the full container name issued
    /// to docker will be generated.
    /// The container name assigned here is also used to resolve the `handle` concept used
    /// by dockertest.
    ///
    /// The container name defaults to the repository name.
    ///
    /// NOTE: If the `Composition` is a static container with an
    /// `External` management policy the container name *MUST* match the container_name of
    /// the external container and is required to be set.
    pub fn with_container_name<T: ToString>(self, container_name: T) -> Composition {
        Composition {
            user_provided_container_name: Some(container_name.to_string()),
            ..self
        }
    }

    /// Sets network aliases for this `Composition`.
    pub fn with_alias(self, aliases: Vec<String>) -> Composition {
        Composition {
            network_aliases: Some(aliases),
            ..self
        }
    }

    /// Adds network alias to this `Composition`
    pub fn alias(&mut self, alias: String) -> &mut Composition {
        match self.network_aliases {
            Some(ref mut network_aliases) => network_aliases.push(alias),
            None => self.network_aliases = Some(vec![alias]),
        };
        self
    }

    /// Sets the `WaitFor` trait object for this `Composition`.
    ///
    /// The default `WaitFor` implementation used is [RunningWait].
    ///
    /// [RunningWait]: crate::waitfor::RunningWait
    pub fn with_wait_for(self, wait: Box<dyn WaitFor>) -> Composition {
        Composition { wait, ..self }
    }

    /// Sets log options for this `Composition`.
    /// By default `LogAction::Forward`, `LogPolicy::OnError`, and `LogSource::StdErr` is enabled.
    /// To clear default log option pass `None` or specify your own log options.
    pub fn with_log_options(self, log_options: Option<LogOptions>) -> Composition {
        Composition {
            log_options,
            ..self
        }
    }

    /// Sets the environment variable to the given value.
    ///
    /// NOTE: if [with_env] is called after a call to [env], all values added by [env] will be overwritten.
    ///
    /// [env]: Composition::env
    /// [with_env]: Composition::with_env
    pub fn env<T: ToString, S: ToString>(&mut self, name: T, value: S) -> &mut Composition {
        self.env.insert(name.to_string(), value.to_string());
        self
    }

    /// Appends the command string to the current command vector.
    ///
    /// If no entries in the command vector is provided to the [Composition],
    /// the command within the [Image] will be used, if any.
    ///
    /// NOTE: if [with_cmd] is called after a call to [cmd], all entries to the command vector
    /// added with [cmd] will be overwritten.
    ///
    /// [cmd]: Composition::cmd
    /// [with_cmd]: Composition::with_cmd
    pub fn cmd<T: ToString>(&mut self, cmd: T) -> &mut Composition {
        self.cmd.push(cmd.to_string());
        self
    }

    /// Adds the given named volume to the Composition.
    /// Named volumes can be shared between containers, specifying the same named volume for
    /// another Composition will give both access to the volume.
    /// `path_in_container` has to be an absolute path.
    pub fn named_volume<T: ToString, S: ToString>(
        &mut self,
        volume_name: T,
        path_in_container: S,
    ) -> &mut Composition {
        self.named_volumes
            .push((volume_name.to_string(), path_in_container.to_string()));
        self
    }
    /// Adds the given bind mount to the Composition.
    /// A bind mount only exists for a single container and maps a given file or directory from the
    /// host to the container.
    /// Use named volumes if you want to share data between containers.
    /// The `host_path` can either point to a directory or a file that MUST exist on the local host.
    /// `path_in_container` has to be an absolute path.
    pub fn bind_mount<T: ToString, S: ToString>(
        &mut self,
        host_path: T,
        path_in_container: S,
    ) -> &mut Composition {
        // The ':Z' is needed due to permission issues, see
        // https://stackoverflow.com/questions/24288616/permission-denied-on-accessing-host-directory-in-docker
        // for more details
        self.bind_mounts.push(format!(
            "{}:{}:Z",
            host_path.to_string(),
            path_in_container.to_string()
        ));
        self
    }

    /// Inject the generated container name identified by `handle` into
    /// this Composition environment variable `env`.
    ///
    /// This is used to establish inter communication between running containers
    /// controlled by dockertest. This is traditionally established through environment variables
    /// for connection details, and thus the DNS resolving capabilities within docker will
    /// map the container name into the correct IP address.
    ///
    /// To correctly use this feature, the `StartPolicy` between the dependant containers
    /// must be configured such that these connections can successfully be established.
    /// Dockertest will not make any attempt to verify the integrity of these dependencies.
    pub fn inject_container_name<T: ToString, E: ToString>(
        &mut self,
        handle: T,
        env: E,
    ) -> &mut Composition {
        self.inject_container_name_env
            .push((handle.to_string(), env.to_string()));
        self
    }

    /// Defines this as a static container which will will only be cleaned up after the full test
    /// binary has executed.
    /// If the static container is used across multiple tests in the same test binary, Dockertest can only guarantee that
    /// the container will be started in its designated start order or earlier as other tests might
    /// have already started it.
    /// The container_name *MUST* be set to a unique value when using static containers.
    /// To refer to the same container across `Dockertest` instances set the same container name for the
    /// compostions.
    ///
    /// NOTE: When the `External` management policy is used, the container_name must be set to the
    /// name of the external container.
    pub fn static_container(&mut self, management: StaticManagementPolicy) -> &mut Composition {
        let management = match management {
            StaticManagementPolicy::External | StaticManagementPolicy::Internal => management,
            StaticManagementPolicy::Dynamic => match std::env::var("DOCKERTEST_DYNAMIC") {
                Ok(val) => match val.as_str() {
                    "EXTERNAL" => StaticManagementPolicy::External,
                    "INTERNAL" => StaticManagementPolicy::Internal,
                    "DYNAMIC" => StaticManagementPolicy::Dynamic,
                    _ => {
                        event!(Level::WARN, "DOCKERTEST_DYNAMIC environment variable set to unknown value, defaulting to Dynamic policy");
                        StaticManagementPolicy::Dynamic
                    }
                },
                Err(_) => management,
            },
        };
        self.management = Some(management);
        self
    }

    /// Fetch the assigned [StaticManagementPolicy], if any.
    pub(crate) fn static_management_policy(&self) -> &Option<StaticManagementPolicy> {
        &self.management
    }

    /// Query whether or not a [StaticManagementPolicy] has been assigned to this composition.
    fn is_static(&self) -> bool {
        self.management.is_some()
    }

    // Configure the container's name with the given namespace as prefix
    // and suffix.
    // We do this to ensure that we do not have overlapping container names
    // and make it clear which containers are run by DockerTest.
    pub(crate) fn configure_container_name(&mut self, namespace: &str, suffix: &str) {
        let name = match &self.user_provided_container_name {
            None => self.image.repository(),
            Some(n) => n,
        };

        if !self.is_static() {
            // The docker daemon does not like '/' or '\' in container names
            let stripped_name = name.replace('/', "_");

            self.container_name = format!("{}-{}-{}", namespace, stripped_name, suffix);
        } else {
            self.container_name = name.to_string();
        }
    }

    /// TODO: Refactor what is returned when creating the static container.
    pub(crate) async fn create(
        self,
        client: &Docker,
        network: Option<&str>,
        is_external_network: bool,
    ) -> Result<CreatedContainer, DockerTestError> {
        if self.is_static() {
            STATIC_CONTAINERS
                .create(self, client, network, is_external_network)
                .await
        } else {
            self.create_inner(client, network)
                .await
                .map(CreatedContainer::Pending)
        }
    }

    // Performs container creation, should NOT be called outside of this module or the static
    // module.
    // This is only exposed such that the static module can reach it.
    pub(crate) async fn create_inner(
        self,
        client: &Docker,
        network: Option<&str>,
    ) -> Result<PendingContainer, DockerTestError> {
        event!(Level::DEBUG, "creating container: {}", self.container_name);

        let start_policy_clone = self.start_policy.clone();
        let container_name_clone = self.container_name.clone();

        if !self.is_static() {
            // Ensure we can remove the previous container instance, if it somehow still exists.
            // Only bail on non-recoverable failure.
            match remove_container_if_exists(client, &self.container_name).await {
                Ok(_) => {}
                Err(e) => match e {
                    DockerTestError::Recoverable(_) => {}
                    _ => return Err(e),
                },
            }
        }

        let image_id = self.image.retrieved_id();
        // Additional programming guard.
        // This Composition cannot be created without an image id, which
        // is set through `Image::pull`
        if image_id.is_empty() {
            return Err(DockerTestError::Processing("`Composition::create()` invoked without populating its image through `Image::pull()`".to_string()));
        }

        // As we can't return temporary values owned by this closure
        // we have to first convert our map into a vector of owned strings,
        // then convert it to a vector of borrowed strings (&str).
        // There is probably a better way to do this...
        let envs: Vec<String> = self
            .env
            .iter()
            .map(|(key, value)| format!("{}={}", key, value))
            .collect();
        let envs = envs.iter().map(|s| s.as_ref()).collect();
        let cmds = self.cmd.iter().map(|s| s.as_ref()).collect();

        let mut volumes: Vec<String> = Vec::new();
        for v in self.bind_mounts.iter() {
            event!(
                Level::DEBUG,
                "creating host_mounted_volume: {} for container {}",
                v.as_str(),
                self.container_name
            );
            volumes.push(v.to_string());
        }

        for v in self.final_named_volume_names.iter() {
            event!(
                Level::DEBUG,
                "creating named_volume: {} for container {}",
                &v,
                self.container_name
            );
            volumes.push(v.to_string());
        }

        let mut port_map: HashMap<String, Option<Vec<PortBinding>>> = HashMap::new();
        let mut exposed_ports: HashMap<&str, HashMap<(), ()>> = HashMap::new();

        for (exposed, host) in &self.port {
            let dest_port: Vec<PortBinding> = vec![PortBinding {
                host_ip: Some("127.0.0.1".to_string()),
                host_port: Some(host.clone()),
            }];
            port_map.insert(exposed.to_string(), Some(dest_port));
            exposed_ports.insert(exposed, HashMap::new());
        }

        let network_aliases = self.network_aliases.as_ref();
        let mut net_config = None;

        // Construct host config
        let host_config = network.map(|n| HostConfig {
            network_mode: Some(n.to_string()),
            binds: Some(volumes),
            port_bindings: Some(port_map),
            publish_all_ports: Some(self.publish_all_ports),
            ..Default::default()
        });

        if let Some(n) = network {
            net_config = network_aliases.map(|a| {
                let mut endpoints = HashMap::new();
                let settings = EndpointSettings {
                    aliases: Some(a.to_vec()),
                    ..Default::default()
                };
                endpoints.insert(n, settings);
                NetworkingConfig {
                    endpoints_config: endpoints,
                }
            });
        }

        // Construct options for create container
        let options = Some(CreateContainerOptions {
            name: &self.container_name,
        });

        let config = Config::<&str> {
            image: Some(&image_id),
            cmd: Some(cmds),
            env: Some(envs),
            networking_config: net_config,
            host_config,
            exposed_ports: Some(exposed_ports),
            ..Default::default()
        };

        let container_info = client
            .create_container(options, config)
            .map_err(|e| DockerTestError::Daemon(format!("failed to create container: {}", e)))
            .await?;

        let static_management_policy = self.static_management_policy().clone();
        Ok(PendingContainer::new(
            &container_name_clone,
            &container_info.id,
            self.handle(),
            start_policy_clone,
            self.wait,
            client.clone(),
            static_management_policy,
            self.log_options.clone(),
        ))
    }

    // Returns the Image associated with this Composition.
    pub(crate) fn image(&self) -> &Image {
        &self.image
    }

    /// Retrieve a copy of the applicable handle name for this composition.
    ///
    /// NOTE: this value will be outdated if [Composition::with_container_name] is invoked
    /// with a different name.
    pub fn handle(&self) -> String {
        match &self.user_provided_container_name {
            None => self.image.repository().to_string(),
            Some(n) => n.clone(),
        }
    }
}

// Forcefully removes the given container if it exists.
async fn remove_container_if_exists(client: &Docker, name: &str) -> Result<(), DockerTestError> {
    client
        .inspect_container(name, None::<InspectContainerOptions>)
        .map_err(|e| DockerTestError::Recoverable(format!("container did not exist: {}", e)))
        .await?;

    // We were able to inspect it successfully, it exists.
    // Therefore, we can simply force remove it.
    let options = Some(RemoveContainerOptions {
        force: true,
        ..Default::default()
    });
    client
        .remove_container(name, options)
        .map_err(|e| DockerTestError::Daemon(format!("failed to remove existing container: {}", e)))
        .await
}

#[cfg(test)]
mod tests {
    use crate::composition::{remove_container_if_exists, Composition, StartPolicy};
    use crate::image::{Image, Source};
    use crate::utils::connect_with_local_or_tls_defaults;
    use crate::DockerTestError;

    use std::collections::HashMap;

    // Tests that the with_repository constructor creates
    // a Composition with the correct values
    #[test]
    fn test_with_repository_constructor() {
        let repository = "this_is_a_repository".to_string();

        let instance = Composition::with_repository(&repository);
        assert_eq!(
            repository,
            instance.image.repository(),
            "repository is not set to the correct value"
        );
        assert_eq!(
            repository, instance.container_name,
            "container_name should default to the repository"
        );
        assert_eq!(
            instance.env.len(),
            0,
            "there should be no environmental variables after constructing a Composition"
        );
        assert_eq!(
            instance.cmd.len(),
            0,
            "there should be no commands after constructing a Composition"
        );

        let equal = match instance.start_policy {
            StartPolicy::Relaxed => true,
            _ => false,
        };
        assert!(equal, "start_policy should default to relaxed");
    }

    // Tests that the with_image constructor creates
    // a Composition with the correct values
    #[test]
    fn test_with_image_constructor() {
        let repository = "this_is_a_repository".to_string();
        let image = Image::with_repository(&repository);

        let instance = Composition::with_image(image);
        assert_eq!(
            repository,
            instance.image.repository(),
            "repository is not set to the correct value"
        );
        assert_eq!(
            repository, instance.container_name,
            "container_name should default to the repository"
        );
        assert_eq!(
            instance.env.len(),
            0,
            "there should be no environmental variables after constructing a Composition"
        );
        assert_eq!(
            instance.cmd.len(),
            0,
            "there should be no commands after constructing a Composition"
        );

        let equal = match instance.start_policy {
            StartPolicy::Relaxed => true,
            _ => false,
        };
        assert!(equal, "start_policy should default to relaxed");
    }

    // Tests all methods that consumes the Composition
    // and mutates a field
    #[test]
    fn test_mutators() {
        let mut env = HashMap::new();

        let env_variable = "GOPATH".to_string();
        let env_value = "/home/kim/unsafe".to_string();

        env.insert(env_variable, env_value);
        let expected_env = env.clone();

        let cmd = "this_is_a_command".to_string();
        let mut cmds = Vec::new();
        cmds.push(cmd);

        let expected_cmds = cmds.clone();

        let repository = "this_is_a_repository".to_string();

        let container_name = "this_is_a_container_name";

        let instance = Composition::with_repository(&repository)
            .with_start_policy(StartPolicy::Strict)
            .with_env(env)
            .with_cmd(cmds)
            .with_container_name(container_name);

        let equal = match instance.start_policy {
            StartPolicy::Strict => true,
            _ => false,
        };

        assert!(equal, "start_policy was not changed after invoking mutator");
        assert_eq!(
            expected_env, instance.env,
            "environmental variables not set correctly"
        );

        assert_eq!(expected_cmds, instance.cmd, "commands not set correctly");

        let correct_container_name = match instance.user_provided_container_name {
            Some(n) => n == container_name,
            None => false,
        };

        assert!(correct_container_name, "container_name not set correctly");
    }

    // Tests that the env method succesfully
    // adds the given environment variable to the Composition
    #[test]
    fn test_add_env() {
        let env_variable = "this_is_an_env_var".to_string();
        let env_value = "this_is_an_env_value".to_string();

        let repository = "this_is_a_repository".to_string();
        let mut instance = Composition::with_repository(&repository);

        instance.env(env_variable.clone(), env_value.clone());

        assert_eq!(
            *instance
                .env
                .get(&env_variable)
                .expect("failed to get value from map that should be there"),
            env_value,
            "environmental variable not added correctly"
        );
    }

    // Tests that the cmd method succesfully
    // adds the given command to the Composition
    #[test]
    fn test_add_cmd() {
        let cmd = "this_is_a_command".to_string();
        let expected_cmd = vec![cmd.clone()];

        let repository = "this_is_a_repository".to_string();
        let mut instance = Composition::with_repository(&repository);

        instance.cmd(cmd);

        assert_eq!(
            instance.cmd, expected_cmd,
            "command value not added correctly"
        );
    }

    /// Tests that we cannot create a container from a non-existent local repository image.
    #[tokio::test]
    async fn test_create_with_non_existing_local_image() {
        let client = connect_with_local_or_tls_defaults().unwrap();
        let repository = "dockertest_create_with_non_existing_local_image";
        let composition = Composition::with_repository(repository);

        // Invoking image pull to populate the image id should err
        // TODO: assert a proper error message
        assert!(composition
            .image
            .pull(&client, &Source::Local)
            .await
            .is_err());

        // This will then fail due to missing image id
        let result = composition.create(&client, None, false).await;
        // TODO: assert a proper error message
        assert!(
            result.is_err(),
            "should fail to start a Composition with non-exisiting image"
        );
    }

    /// Check that a simple composition from repository can be successfully created.
    #[tokio::test]
    async fn test_simple_create_composition_from_repository_success() {
        let client = connect_with_local_or_tls_defaults().unwrap();
        let repository = "dockertest-rs/hello";
        let mut composition = Composition::with_repository(repository);
        composition.container_name =
            "test_simple_create_composition_from_repository_success".to_string();

        // ensure image metadata is populated (through pull infrastructure)
        composition
            .image
            .pull(&client, &Source::Local)
            .await
            .unwrap();

        let result = composition.create(&client, None, false).await;
        assert!(
            result.is_ok(),
            "failed to start Composition: {}",
            result.err().unwrap()
        );
    }

    /// Tests that two consecutive Compositions creating a container with the exact same
    /// `container_name` will still be allowed to be created.
    ///
    /// The expected behaviour is thus to remove the old container
    /// (since we assume it will be an _old_ name collision).
    #[tokio::test]
    async fn test_create_with_existing_container() {
        let client = connect_with_local_or_tls_defaults().unwrap();
        let repository = "dockertest-rs/hello";
        let container_name = "dockertest_create_with_existing_container".to_string();

        let mut composition1 = Composition::with_repository(repository);
        // configure the `container_name` inline instead of through `with_container_name`,
        // to avoid the user provided container name logic.
        composition1.container_name = container_name;
        // ensure image metadata is populated (through pull infrastructure)
        composition1
            .image
            .pull(&client, &Source::Local)
            .await
            .unwrap();

        let composition2 = composition1.clone();

        // Initial setup - first container that already exists.
        let result = composition1.create(&client, None, false).await;
        assert!(
            result.is_ok(),
            "failed to start first composition: {}",
            result.err().unwrap()
        );

        // Creating a second one should still be allowed, since we expect the first one
        // to be removed.
        let result = composition2.create(&client, None, false).await;
        assert!(
            result.is_ok(),
            "failed to start second composition: {}",
            result.err().unwrap()
        );
    }

    /// Tests the `remove_container_if_exists` method when container exists.
    #[tokio::test]
    async fn test_remove_existing_container() {
        let client = connect_with_local_or_tls_defaults().unwrap();
        let repository = "dockertest-rs/hello";
        let container_name = "dockertest_remove_existing_container_test_name";
        let mut composition = Composition::with_repository(repository);
        composition.container_name = container_name.to_string();

        // ensure image metadata is populated (through pull infrastructure)
        composition
            .image
            .pull(&client, &Source::Local)
            .await
            .unwrap();

        // Create out composition
        let result = composition.create(&client, None, false).await;
        assert!(
            result.is_ok(),
            "failed to start composition: {}",
            result.err().unwrap()
        );

        // Remove it by name
        let result = remove_container_if_exists(&client, container_name).await;
        assert!(
            result.is_ok(),
            "failed to remove existing container: {}",
            result.unwrap_err()
        );
    }

    /// Tests that we fail when trying to remove a non-existing container through
    /// `remove_container_if_exists`.
    #[tokio::test]
    async fn test_remove_non_existing_container() {
        let client = connect_with_local_or_tls_defaults().unwrap();

        let result = remove_container_if_exists(&client, "dockertest_non_existing_container").await;

        let res = match result {
            Ok(_) => false,
            Err(e) => match e {
                DockerTestError::Recoverable(_) => true,
                _ => false,
            },
        };
        assert!(res, "should fail to remove non-existing container");
    }

    // Tests that the configurate_container_name method correctly sets the Composition's
    // container_name when the user has not specified a container_name
    #[test]
    fn test_configurate_container_name_without_user_supplied_name() {
        let repository = "hello-world";
        let mut composition = Composition::with_repository(&repository);

        let suffix = "test123";
        let namespace = "namespace";

        let expected_output = format!("{}-{}-{}", namespace, repository, suffix);

        composition.configure_container_name(&namespace, suffix);

        assert_eq!(
            composition.container_name, expected_output,
            "container_name not configurated correctly"
        );
    }

    // Tests that the configurate_container_name method correctly sets the Composition's
    // container_name when the user has specified a container_name
    #[test]
    fn test_configurate_container_name_with_user_supplied_name() {
        let repository = "hello-world";
        let container_name = "this_is_a_container";
        let mut composition =
            Composition::with_repository(&repository).with_container_name(container_name);

        let suffix = "test123";
        let namespace = "namespace";

        let expected_output = format!("{}-{}-{}", namespace, container_name, suffix);

        composition.configure_container_name(&namespace, suffix);

        assert_eq!(
            composition.container_name, expected_output,
            "container_name not configurated correctly"
        );
    }

    // Tests that the configurate_container_name method replaces forward slashes with underscore
    // when a user provided name is given.
    // The docker daemon does not like forward slashes in container names.
    #[test]
    fn test_configurate_container_name_with_user_supplied_name_containing_slashes() {
        let repository = "hello-world";
        let container_name = "this/is/a_container";
        let expected_container_name = "this_is_a_container";

        let mut composition =
            Composition::with_repository(&repository).with_container_name(container_name);

        let suffix = "test123";
        let namespace = "namespace";

        let expected_output = format!("{}-{}-{}", namespace, expected_container_name, suffix);

        composition.configure_container_name(&namespace, suffix);

        assert_eq!(
            composition.container_name, expected_output,
            "container_name not configurated correctly"
        );
    }

    // Tests that the configurate_container_name method replaces forward slashes with underscore
    // when no user provided container name is provided.
    // The docker daemon does not like forward slashes in container names.
    #[test]
    fn test_configurate_container_name_without_user_supplied_name_containing_slashes() {
        let repository = "hello/world";
        let expected_container_name = "hello_world";

        let mut composition = Composition::with_repository(&repository);

        let suffix = "test123";
        let namespace = "namespace";

        let expected_output = format!("{}-{}-{}", namespace, expected_container_name, suffix);

        composition.configure_container_name(&namespace, suffix);

        assert_eq!(
            composition.container_name, expected_output,
            "container_name not configurated correctly"
        );
    }
}