libcnb_test/test_context.rs
1use crate::docker::DockerRunCommand;
2use crate::pack::PackSbomDownloadCommand;
3use crate::{
4 BuildConfig, ContainerConfig, ContainerContext, LogOutput, TemporaryDockerResources,
5 TestRunner, util,
6};
7use libcnb_data::buildpack::BuildpackId;
8use libcnb_data::layer::LayerName;
9use libcnb_data::sbom::SbomFormat;
10use std::borrow::Borrow;
11use std::path::PathBuf;
12use tempfile::tempdir;
13
14/// Context for a currently executing test.
15pub struct TestContext<'a> {
16 /// Standard output of `pack`, interpreted as an UTF-8 string.
17 pub pack_stdout: String,
18 /// Standard error of `pack`, interpreted as an UTF-8 string.
19 pub pack_stderr: String,
20 /// The configuration used for this integration test.
21 pub config: BuildConfig,
22
23 pub(crate) docker_resources: TemporaryDockerResources,
24 pub(crate) runner: &'a TestRunner,
25}
26
27impl TestContext<'_> {
28 /// Starts a detached container using the provided [`ContainerConfig`].
29 ///
30 /// After the passed function has returned, the Docker container is removed.
31 ///
32 /// If you wish to run a shell command and don't need to customise the configuration, use
33 /// the convenience function [`TestContext::run_shell_command`] instead.
34 ///
35 /// # Examples
36 /// ```no_run
37 /// use libcnb_test::{BuildConfig, ContainerConfig, TestRunner};
38 ///
39 /// TestRunner::default().build(
40 /// BuildConfig::new("heroku/builder:22", "tests/fixtures/app"),
41 /// |context| {
42 /// // Start the container using the default process-type:
43 /// // https://buildpacks.io/docs/app-developer-guide/run-an-app/#default-process-type
44 /// context.start_container(ContainerConfig::new(), |container| {
45 /// // ...
46 /// });
47 ///
48 /// // Start the container using the specified process-type:
49 /// // https://buildpacks.io/docs/app-developer-guide/run-an-app/#non-default-process-type
50 /// context.start_container(ContainerConfig::new().entrypoint("worker"), |container| {
51 /// // ...
52 /// });
53 ///
54 /// // Start the container using the specified process-type and additional arguments:
55 /// // https://buildpacks.io/docs/app-developer-guide/run-an-app/#non-default-process-type-with-additional-arguments
56 /// context.start_container(
57 /// ContainerConfig::new()
58 /// .entrypoint("another-process")
59 /// .command(["--additional-arg"]),
60 /// |container| {
61 /// // ...
62 /// },
63 /// );
64 ///
65 /// // Start the container using the provided bash script:
66 /// // https://buildpacks.io/docs/app-developer-guide/run-an-app/#user-provided-shell-process-with-bash-script
67 /// // Only use this shell command form if you need to customise the `ContainerConfig`,
68 /// // otherwise use the convenience function `TestContext::run_shell_command` instead.
69 /// context.start_container(
70 /// ContainerConfig::new()
71 /// .entrypoint("launcher")
72 /// .command(["for i in {1..3}; do echo \"${i}\"; done"]),
73 /// |container| {
74 /// // ...
75 /// },
76 /// );
77 /// },
78 /// );
79 /// ```
80 ///
81 /// # Panics
82 ///
83 /// Panics if there was an error starting the container, such as when the specified entrypoint/command can't be found.
84 ///
85 /// Note: Does not panic if the container exits after starting (including if it crashes and exits non-zero).
86 pub fn start_container<C: Borrow<ContainerConfig>, F: FnOnce(ContainerContext)>(
87 &self,
88 config: C,
89 f: F,
90 ) {
91 let config = config.borrow();
92 let container_name = util::random_docker_identifier();
93
94 let mut docker_run_command =
95 DockerRunCommand::new(&self.docker_resources.image_name, &container_name);
96 docker_run_command.detach(true);
97 docker_run_command.platform(self.determine_container_platform());
98
99 if let Some(entrypoint) = &config.entrypoint {
100 docker_run_command.entrypoint(entrypoint);
101 }
102
103 if let Some(command) = &config.command {
104 docker_run_command.command(command);
105 }
106
107 config.env.iter().for_each(|(key, value)| {
108 docker_run_command.env(key, value);
109 });
110
111 config.exposed_ports.iter().for_each(|port| {
112 docker_run_command.expose_port(*port);
113 });
114
115 config.bind_mounts.iter().for_each(|(source, target)| {
116 docker_run_command.bind_mount(source, target);
117 });
118
119 // We create the ContainerContext early to ensure the cleanup in ContainerContext::drop
120 // is still performed even if the Docker command panics.
121 let container_context = ContainerContext {
122 container_name,
123 config: config.clone(),
124 };
125
126 util::run_command(docker_run_command)
127 .unwrap_or_else(|command_err| panic!("Error starting container:\n\n{command_err}"));
128
129 f(container_context);
130 }
131
132 /// Run the provided shell command.
133 ///
134 /// The CNB launcher will run the provided command using `bash`.
135 ///
136 /// Note: This method will block until the container stops.
137 ///
138 /// # Example
139 /// ```no_run
140 /// use libcnb_test::{BuildConfig, ContainerConfig, TestRunner};
141 ///
142 /// TestRunner::default().build(
143 /// BuildConfig::new("heroku/builder:22", "tests/fixtures/app"),
144 /// |context| {
145 /// // ...
146 /// let command_output =
147 /// context.run_shell_command("for i in {1..3}; do echo \"${i}\"; done");
148 /// assert_eq!(command_output.stdout, "1\n2\n3\n");
149 /// },
150 /// );
151 /// ```
152 ///
153 /// This is a convenience function for running shell commands inside the image, that is roughly equivalent to:
154 /// ```no_run
155 /// use libcnb_test::{BuildConfig, ContainerConfig, TestRunner};
156 ///
157 /// TestRunner::default().build(
158 /// BuildConfig::new("heroku/builder:22", "tests/fixtures/app"),
159 /// |context| {
160 /// // ...
161 /// context.start_container(
162 /// ContainerConfig::new()
163 /// .entrypoint("launcher")
164 /// .command(["for i in {1..3}; do echo \"${i}\"; done"]),
165 /// |container| {
166 /// let log_output = container.logs_wait();
167 /// // ...
168 /// },
169 /// );
170 /// },
171 /// );
172 /// ```
173 ///
174 /// However, in addition to requiring less boilerplate, `run_shell_command` is also able
175 /// to validate the exit status of the container, so should be used instead of `start_container`
176 /// where possible.
177 ///
178 /// # Panics
179 ///
180 /// Panics if there was an error starting the container, or the command exited with a non-zero
181 /// exit code.
182 pub fn run_shell_command(&self, command: impl Into<String>) -> LogOutput {
183 let mut docker_run_command = DockerRunCommand::new(
184 &self.docker_resources.image_name,
185 util::random_docker_identifier(),
186 );
187 docker_run_command
188 .remove(true)
189 .platform(self.determine_container_platform())
190 .entrypoint(util::CNB_LAUNCHER_BINARY)
191 .command([command.into()]);
192
193 util::run_command(docker_run_command)
194 .unwrap_or_else(|command_err| panic!("Error running container:\n\n{command_err}"))
195 }
196
197 // We set an explicit platform when starting containers to prevent the Docker CLI's
198 // "no specific platform was requested" warning from cluttering the captured logs.
199 fn determine_container_platform(&self) -> &str {
200 match self.config.target_triple.as_str() {
201 "aarch64-unknown-linux-musl" => "linux/arm64",
202 "x86_64-unknown-linux-musl" => "linux/amd64",
203 _ => unimplemented!(
204 "Unable to determine container platform from target triple '{}'. Please file a GitHub issue.",
205 self.config.target_triple
206 ),
207 }
208 }
209
210 /// Downloads SBOM files from the built image into a temporary directory.
211 ///
212 /// References to the downloaded files are passed into the given function and will be cleaned-up
213 /// after the function exits.
214 ///
215 /// # Example
216 /// ```no_run
217 /// use libcnb_data::buildpack_id;
218 /// use libcnb_data::sbom::SbomFormat;
219 /// use libcnb_test::{BuildConfig, ContainerConfig, SbomType, TestRunner};
220 ///
221 /// TestRunner::default().build(
222 /// BuildConfig::new("heroku/builder:22", "tests/fixtures/app"),
223 /// |context| {
224 /// context.download_sbom_files(|sbom_files| {
225 /// assert!(sbom_files
226 /// .path_for(
227 /// buildpack_id!("heroku/jvm"),
228 /// SbomType::Launch,
229 /// SbomFormat::SyftJson
230 /// )
231 /// .exists());
232 /// });
233 /// },
234 /// );
235 /// ```
236 ///
237 /// # Panics
238 ///
239 /// Panics if there was an error creating the temporary directory used to store the
240 /// SBOM files, or if the Pack CLI command used to download the SBOM files failed.
241 pub fn download_sbom_files<R, F: Fn(SbomFiles) -> R>(&self, f: F) -> R {
242 let temp_dir = tempdir().expect("Couldn't create temporary directory for SBOM files");
243
244 let mut command = PackSbomDownloadCommand::new(&self.docker_resources.image_name);
245 command.output_dir(temp_dir.path());
246
247 util::run_command(command)
248 .unwrap_or_else(|command_err| panic!("Error downloading SBOM files:\n\n{command_err}"));
249
250 f(SbomFiles {
251 sbom_files_directory: temp_dir.path().into(),
252 })
253 }
254
255 /// Starts a subsequent integration test build.
256 ///
257 /// This function behaves exactly like [`TestRunner::build`], but it will reuse the OCI image
258 /// from the previous test, causing the CNB lifecycle to restore any cached layers. It will use the
259 /// same [`TestRunner`] as the previous test run.
260 ///
261 /// This function allows testing of subsequent builds, including caching logic and buildpack
262 /// behaviour when build environment variables change, stacks are upgraded and more.
263 ///
264 /// Note that this function will consume the current context. This is because the image will
265 /// be changed by the subsequent test, invalidating the context. Running a subsequent test must
266 /// therefore be the last operation. You can nest subsequent runs if required.
267 ///
268 /// # Example
269 /// ```no_run
270 /// use libcnb_test::{assert_contains, BuildConfig, TestRunner};
271 ///
272 /// TestRunner::default().build(
273 /// BuildConfig::new("heroku/builder:22", "tests/fixtures/app"),
274 /// |context| {
275 /// assert_contains!(context.pack_stdout, "---> Installing dependencies");
276 ///
277 /// let config = context.config.clone();
278 /// context.rebuild(config, |context| {
279 /// assert_contains!(context.pack_stdout, "---> Using cached dependencies");
280 /// });
281 /// },
282 /// );
283 /// ```
284 pub fn rebuild<C: Borrow<BuildConfig>, F: FnOnce(TestContext)>(self, config: C, f: F) {
285 self.runner.build_internal(self.docker_resources, config, f);
286 }
287}
288
289/// Downloaded SBOM files.
290pub struct SbomFiles {
291 sbom_files_directory: PathBuf,
292}
293
294/// The type of SBOM.
295///
296/// Not to be confused with [`libcnb_data::sbom::SbomFormat`].
297pub enum SbomType {
298 /// Launch SBOM
299 Launch,
300 /// Layer SBOM
301 Layer(LayerName),
302}
303
304impl SbomFiles {
305 /// Returns the path of a specific downloaded SBOM file.
306 pub fn path_for<I: Borrow<BuildpackId>, T: Borrow<SbomType>, F: Borrow<SbomFormat>>(
307 &self,
308 buildpack_id: I,
309 sbom_type: T,
310 format: F,
311 ) -> PathBuf {
312 self.sbom_files_directory
313 .join("layers")
314 .join("sbom")
315 .join("launch")
316 .join(buildpack_id.borrow().replace('/', "_"))
317 .join(match sbom_type.borrow() {
318 SbomType::Layer(layer_name) => layer_name.to_string(),
319 SbomType::Launch => String::new(),
320 })
321 .join(match format.borrow() {
322 SbomFormat::CycloneDxJson => "sbom.cdx.json",
323 SbomFormat::SpdxJson => "sbom.spdx.json",
324 SbomFormat::SyftJson => "sbom.syft.json",
325 })
326 }
327}