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}