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}