libcnb_test/
container_context.rs

1use crate::docker::{
2    DockerExecCommand, DockerLogsCommand, DockerPortCommand, DockerRemoveContainerCommand,
3};
4use crate::log::LogOutput;
5use crate::util::CommandError;
6use crate::{ContainerConfig, util};
7use std::net::SocketAddr;
8
9/// Context of a launched container.
10pub struct ContainerContext {
11    /// The randomly generated name of this container.
12    pub container_name: String,
13    pub(crate) config: ContainerConfig,
14}
15
16impl ContainerContext {
17    /// Gets the container's log output until the current point in time.
18    ///
19    /// Note: This method will only return logs until the current point in time. It will not
20    /// block until the container stops. Since the output of this method depends on timing, directly
21    /// asserting on its contents might result in flaky tests.
22    ///
23    /// See: [`logs_wait`](Self::logs_wait) for a blocking alternative.
24    ///
25    /// # Example
26    /// ```no_run
27    /// use libcnb_test::{assert_contains, assert_empty, BuildConfig, ContainerConfig, TestRunner};
28    ///
29    /// TestRunner::default().build(
30    ///     BuildConfig::new("heroku/builder:22", "tests/fixtures/app"),
31    ///     |context| {
32    ///         // ...
33    ///         context.start_container(ContainerConfig::new(), |container| {
34    ///             let log_output_until_now = container.logs_now();
35    ///             assert_empty!(log_output_until_now.stderr);
36    ///             assert_contains!(log_output_until_now.stdout, "Expected output");
37    ///         });
38    ///     },
39    /// );
40    /// ```
41    ///
42    /// # Panics
43    ///
44    /// Panics if there was an error retrieving the logs from the container.
45    #[must_use]
46    pub fn logs_now(&self) -> LogOutput {
47        util::run_command(DockerLogsCommand::new(&self.container_name))
48            .unwrap_or_else(|command_err| panic!("Error fetching container logs:\n\n{command_err}"))
49    }
50
51    /// Gets the container's log output until the container stops.
52    ///
53    /// Note: This method will block until the container stops. If the container never stops by
54    /// itself, your test will hang indefinitely. This is common when the container hosts an HTTP
55    /// service.
56    ///
57    /// See: [`logs_now`](Self::logs_now) for a non-blocking alternative.
58    ///
59    /// # Example
60    /// ```no_run
61    /// use libcnb_test::{assert_contains, assert_empty, BuildConfig, ContainerConfig, TestRunner};
62    ///
63    /// TestRunner::default().build(
64    ///     BuildConfig::new("heroku/builder:22", "tests/fixtures/app"),
65    ///     |context| {
66    ///         // ...
67    ///         context.start_container(ContainerConfig::new(), |container| {
68    ///             let all_log_output = container.logs_wait();
69    ///             assert_empty!(all_log_output.stderr);
70    ///             assert_contains!(all_log_output.stdout, "Expected output");
71    ///         });
72    ///     },
73    /// );
74    /// ```
75    ///
76    /// # Panics
77    ///
78    /// Panics if there was an error retrieving the logs from the container.
79    #[must_use]
80    pub fn logs_wait(&self) -> LogOutput {
81        let mut docker_logs_command = DockerLogsCommand::new(&self.container_name);
82        docker_logs_command.follow(true);
83        util::run_command(docker_logs_command)
84            .unwrap_or_else(|command_err| panic!("Error fetching container logs:\n\n{command_err}"))
85    }
86
87    /// Returns the local address of an exposed container port.
88    ///
89    /// # Example
90    /// ```no_run
91    /// use libcnb_test::{BuildConfig, ContainerConfig, TestRunner};
92    ///
93    /// TestRunner::default().build(
94    ///     BuildConfig::new("heroku/builder:22", "tests/fixtures/app"),
95    ///     |context| {
96    ///         // ...
97    ///         context.start_container(
98    ///             ContainerConfig::new()
99    ///                 .env("PORT", "12345")
100    ///                 .expose_port(12345),
101    ///             |container| {
102    ///                 let address_on_host = container.address_for_port(12345);
103    ///                 // ...
104    ///             },
105    ///         );
106    ///     },
107    /// );
108    /// ```
109    ///
110    /// # Panics
111    ///
112    /// Will panic if there was an error obtaining the container port mapping, or the specified port
113    /// was not exposed using [`ContainerConfig::expose_port`](crate::ContainerConfig::expose_port).
114    #[must_use]
115    pub fn address_for_port(&self, port: u16) -> SocketAddr {
116        assert!(
117            self.config.exposed_ports.contains(&port),
118            "Unknown port: Port {port} needs to be exposed first using `ContainerConfig::expose_port`"
119        );
120
121        let docker_port_command = DockerPortCommand::new(&self.container_name, port);
122
123        match util::run_command(docker_port_command) {
124            Ok(output) => output
125                .stdout
126                .trim()
127                .parse()
128                .unwrap_or_else(|error| panic!("Error parsing `docker port` output: {error}")),
129            Err(CommandError::NonZeroExitCode { log_output, .. }) => {
130                panic!(
131                    "Error obtaining container port mapping:\n{}\nThis normally means that the container crashed. Container logs:\n\n{}",
132                    log_output.stderr,
133                    self.logs_now()
134                );
135            }
136            Err(command_err) => {
137                panic!("Error obtaining container port mapping:\n\n{command_err}");
138            }
139        }
140    }
141
142    /// Executes a shell command inside an already running container.
143    ///
144    /// # Example
145    /// ```no_run
146    /// use libcnb_test::{assert_contains, BuildConfig, ContainerConfig, TestRunner};
147    ///
148    /// TestRunner::default().build(
149    ///     BuildConfig::new("heroku/builder:22", "tests/fixtures/app"),
150    ///     |context| {
151    ///         // ...
152    ///         context.start_container(ContainerConfig::new(), |container| {
153    ///             let log_output = container.shell_exec("ps");
154    ///             assert_contains!(log_output.stdout, "gunicorn");
155    ///         });
156    ///     },
157    /// );
158    /// ```
159    ///
160    /// # Panics
161    ///
162    /// Panics if it was not possible to exec into the container, or if the command
163    /// exited with a non-zero exit code.
164    pub fn shell_exec(&self, command: impl AsRef<str>) -> LogOutput {
165        let docker_exec_command = DockerExecCommand::new(
166            &self.container_name,
167            [util::CNB_LAUNCHER_BINARY, command.as_ref()],
168        );
169        util::run_command(docker_exec_command)
170            .unwrap_or_else(|command_err| panic!("Error performing docker exec:\n\n{command_err}"))
171    }
172}
173
174impl Drop for ContainerContext {
175    fn drop(&mut self) {
176        util::run_command(DockerRemoveContainerCommand::new(&self.container_name)).unwrap_or_else(
177            |command_err| panic!("Error removing Docker container:\n\n{command_err}"),
178        );
179    }
180}