spin_build/
lib.rs

1#![deny(missing_docs)]
2
3//! A library for building Spin components.
4
5mod manifest;
6
7use anyhow::{anyhow, bail, Context, Result};
8use manifest::ComponentBuildInfo;
9use spin_common::{paths::parent_dir, ui::quoted_path};
10use std::{
11    collections::HashSet,
12    path::{Path, PathBuf},
13};
14use subprocess::{Exec, Redirection};
15
16use crate::manifest::component_build_configs;
17
18/// If present, run the build command of each component.
19pub async fn build(
20    manifest_file: &Path,
21    component_ids: &[String],
22    target_checks: TargetChecking,
23    cache_root: Option<PathBuf>,
24) -> Result<()> {
25    let build_info = component_build_configs(manifest_file)
26        .await
27        .with_context(|| {
28            format!(
29                "Cannot read manifest file from {}",
30                quoted_path(manifest_file)
31            )
32        })?;
33    let app_dir = parent_dir(manifest_file)?;
34
35    let build_result = build_components(component_ids, build_info.components(), &app_dir);
36
37    // Emit any required warnings now, so that they don't bury any errors.
38    if let Some(e) = build_info.load_error() {
39        // The manifest had errors. We managed to attempt a build anyway, but we want to
40        // let the user know about them.
41        terminal::warn!("The manifest has errors not related to the Wasm component build. Error details:\n{e:#}");
42        // Checking deployment targets requires a healthy manifest (because trigger types etc.),
43        // if any of these were specified, warn they are being skipped.
44        let should_have_checked_targets =
45            target_checks.check() && build_info.has_deployment_targets();
46        if should_have_checked_targets {
47            terminal::warn!(
48                "The manifest error(s) prevented Spin from checking the deployment targets."
49            );
50        }
51    }
52
53    // If the build failed, exit with an error at this point.
54    build_result?;
55
56    let Some(manifest) = build_info.manifest() else {
57        // We can't proceed to checking (because that needs a full healthy manifest), and we've
58        // already emitted any necessary warning, so quit.
59        return Ok(());
60    };
61
62    if target_checks.check() {
63        let application = spin_environments::ApplicationToValidate::new(
64            manifest.clone(),
65            manifest_file.parent().unwrap(),
66        )
67        .await
68        .context("unable to load application for checking against deployment targets")?;
69        let target_validation = spin_environments::validate_application_against_environment_ids(
70            &application,
71            build_info.deployment_targets(),
72            cache_root.clone(),
73            &app_dir,
74        )
75        .await
76        .context("unable to check if the application is compatible with deployment targets")?;
77
78        if !target_validation.is_ok() {
79            for error in target_validation.errors() {
80                terminal::error!("{error}");
81            }
82            anyhow::bail!("All components built successfully, but one or more was incompatible with one or more of the deployment targets.");
83        }
84    }
85
86    Ok(())
87}
88
89/// Run all component build commands, using the default options (build all
90/// components, perform target checking). We run a "default build" in several
91/// places and this centralises the logic of what such a "default build" means.
92pub async fn build_default(manifest_file: &Path, cache_root: Option<PathBuf>) -> Result<()> {
93    build(manifest_file, &[], TargetChecking::Check, cache_root).await
94}
95
96fn build_components(
97    component_ids: &[String],
98    components: Vec<ComponentBuildInfo>,
99    app_dir: &Path,
100) -> Result<(), anyhow::Error> {
101    let components_to_build = if component_ids.is_empty() {
102        components
103    } else {
104        let all_ids: HashSet<_> = components.iter().map(|c| &c.id).collect();
105        let unknown_component_ids: Vec<_> = component_ids
106            .iter()
107            .filter(|id| !all_ids.contains(id))
108            .map(|s| s.as_str())
109            .collect();
110
111        if !unknown_component_ids.is_empty() {
112            bail!("Unknown component(s) {}", unknown_component_ids.join(", "));
113        }
114
115        components
116            .into_iter()
117            .filter(|c| component_ids.contains(&c.id))
118            .collect()
119    };
120
121    if components_to_build.iter().all(|c| c.build.is_none()) {
122        println!("None of the components have a build command.");
123        println!("For information on specifying a build command, see https://spinframework.dev/build#setting-up-for-spin-build.");
124        return Ok(());
125    }
126
127    components_to_build
128        .into_iter()
129        .map(|c| build_component(c, app_dir))
130        .collect::<Result<Vec<_>, _>>()?;
131
132    terminal::step!("Finished", "building all Spin components");
133    Ok(())
134}
135
136/// Run the build command of the component.
137fn build_component(build_info: ComponentBuildInfo, app_dir: &Path) -> Result<()> {
138    match build_info.build {
139        Some(b) => {
140            let command_count = b.commands().len();
141
142            if command_count > 1 {
143                terminal::step!(
144                    "Building",
145                    "component {} ({} commands)",
146                    build_info.id,
147                    command_count
148                );
149            }
150
151            for (index, command) in b.commands().enumerate() {
152                if command_count > 1 {
153                    terminal::step!(
154                        "Running build step",
155                        "{}/{} for component {} with '{}'",
156                        index + 1,
157                        command_count,
158                        build_info.id,
159                        command
160                    );
161                } else {
162                    terminal::step!("Building", "component {} with `{}`", build_info.id, command);
163                }
164
165                let workdir = construct_workdir(app_dir, b.workdir.as_ref())?;
166                if b.workdir.is_some() {
167                    println!("Working directory: {}", quoted_path(&workdir));
168                }
169
170                let exit_status = Exec::shell(command)
171                    .cwd(workdir)
172                    .stdout(Redirection::None)
173                    .stderr(Redirection::None)
174                    .stdin(Redirection::None)
175                    .popen()
176                    .map_err(|err| {
177                        anyhow!(
178                            "Cannot spawn build process '{:?}' for component {}: {}",
179                            &b.command,
180                            build_info.id,
181                            err
182                        )
183                    })?
184                    .wait()?;
185
186                if !exit_status.success() {
187                    bail!(
188                        "Build command for component {} failed with status {:?}",
189                        build_info.id,
190                        exit_status,
191                    );
192                }
193            }
194
195            Ok(())
196        }
197        _ => Ok(()),
198    }
199}
200
201/// Constructs the absolute working directory in which to run the build command.
202fn construct_workdir(app_dir: &Path, workdir: Option<impl AsRef<Path>>) -> Result<PathBuf> {
203    let mut cwd = app_dir.to_owned();
204
205    if let Some(workdir) = workdir {
206        // Using `Path::has_root` as `is_relative` and `is_absolute` have
207        // surprising behavior on Windows, see:
208        // https://doc.rust-lang.org/std/path/struct.Path.html#method.is_absolute
209        if workdir.as_ref().has_root() {
210            bail!("The workdir specified in the application file must be relative.");
211        }
212        cwd.push(workdir);
213    }
214
215    Ok(cwd)
216}
217
218/// Specifies target environment checking behaviour
219pub enum TargetChecking {
220    /// The build should check that all components are compatible with all target environments.
221    Check,
222    /// The build should not check target environments.
223    Skip,
224}
225
226impl TargetChecking {
227    /// Should the build check target environments?
228    fn check(&self) -> bool {
229        matches!(self, Self::Check)
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    fn test_data_root() -> PathBuf {
238        let crate_dir = env!("CARGO_MANIFEST_DIR");
239        PathBuf::from(crate_dir).join("tests")
240    }
241
242    #[tokio::test]
243    async fn can_load_even_if_trigger_invalid() {
244        let bad_trigger_file = test_data_root().join("bad_trigger.toml");
245        build(&bad_trigger_file, &[], TargetChecking::Skip, None)
246            .await
247            .unwrap();
248    }
249
250    #[tokio::test]
251    async fn succeeds_if_target_env_matches() {
252        let manifest_path = test_data_root().join("good_target_env.toml");
253        build(&manifest_path, &[], TargetChecking::Check, None)
254            .await
255            .unwrap();
256    }
257
258    #[tokio::test]
259    async fn fails_if_target_env_does_not_match() {
260        let manifest_path = test_data_root().join("bad_target_env.toml");
261        let err = build(&manifest_path, &[], TargetChecking::Check, None)
262            .await
263            .expect_err("should have failed")
264            .to_string();
265
266        // build prints validation errors rather than returning them to top level
267        // (because there could be multiple errors) - see has_meaningful_error_if_target_env_does_not_match
268        assert!(
269            err.contains("one or more was incompatible with one or more of the deployment targets")
270        );
271    }
272
273    #[tokio::test]
274    async fn has_meaningful_error_if_target_env_does_not_match() {
275        let manifest_file = test_data_root().join("bad_target_env.toml");
276        let manifest = spin_manifest::manifest_from_file(&manifest_file).unwrap();
277        let application = spin_environments::ApplicationToValidate::new(
278            manifest.clone(),
279            manifest_file.parent().unwrap(),
280        )
281        .await
282        .context("unable to load application for checking against deployment targets")
283        .unwrap();
284
285        let target_validation = spin_environments::validate_application_against_environment_ids(
286            &application,
287            &manifest.application.targets,
288            None,
289            manifest_file.parent().unwrap(),
290        )
291        .await
292        .context("unable to check if the application is compatible with deployment targets")
293        .unwrap();
294
295        assert_eq!(1, target_validation.errors().len());
296
297        let err = target_validation.errors()[0].to_string();
298
299        assert!(err.contains("can't run in environment wasi-minimal"));
300        assert!(err.contains("world wasi:cli/command@0.2.0"));
301        assert!(err.contains("requires imports named"));
302        assert!(err.contains("wasi:cli/stdout"));
303    }
304}