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 spin_manifest::schema::v2;
11use std::{
12    collections::HashSet,
13    path::{Path, PathBuf},
14};
15use subprocess::{Exec, Redirection};
16
17use crate::manifest::component_build_configs;
18
19/// If present, run the build command of each component.
20pub async fn build(
21    manifest_file: &Path,
22    component_ids: &[String],
23    target_checks: TargetChecking,
24    cache_root: Option<PathBuf>,
25) -> Result<()> {
26    let build_info = component_build_configs(manifest_file)
27        .await
28        .with_context(|| {
29            format!(
30                "Cannot read manifest file from {}",
31                quoted_path(manifest_file)
32            )
33        })?;
34    let app_dir = parent_dir(manifest_file)?;
35
36    let build_result = build_components(component_ids, build_info.components(), &app_dir);
37
38    // Emit any required warnings now, so that they don't bury any errors.
39    if let Some(e) = build_info.load_error() {
40        // The manifest had errors. We managed to attempt a build anyway, but we want to
41        // let the user know about them.
42        terminal::warn!("The manifest has errors not related to the Wasm component build. Error details:\n{e:#}");
43        // Checking deployment targets requires a healthy manifest (because trigger types etc.),
44        // if any of these were specified, warn they are being skipped.
45        let should_have_checked_targets =
46            target_checks.check() && build_info.has_deployment_targets();
47        if should_have_checked_targets {
48            terminal::warn!(
49                "The manifest error(s) prevented Spin from checking the deployment targets."
50            );
51        }
52    }
53
54    // If the build failed, exit with an error at this point.
55    build_result?;
56
57    let Some(manifest) = build_info.manifest() else {
58        // We can't proceed to checking (because that needs a full healthy manifest), and we've
59        // already emitted any necessary warning, so quit.
60        return Ok(());
61    };
62
63    if target_checks.check() {
64        let application = spin_environments::ApplicationToValidate::new(
65            manifest.clone(),
66            manifest_file.parent().unwrap(),
67        )
68        .await
69        .context("unable to load application for checking against deployment targets")?;
70        let target_validation = spin_environments::validate_application_against_environment_ids(
71            &application,
72            build_info.deployment_targets(),
73            cache_root.clone(),
74            &app_dir,
75        )
76        .await
77        .context("unable to check if the application is compatible with deployment targets")?;
78
79        if !target_validation.is_ok() {
80            for error in target_validation.errors() {
81                terminal::error!("{error}");
82            }
83            anyhow::bail!("All components built successfully, but one or more was incompatible with one or more of the deployment targets.");
84        }
85    }
86
87    Ok(())
88}
89
90/// Run all component build commands, using the default options (build all
91/// components, perform target checking). We run a "default build" in several
92/// places and this centralises the logic of what such a "default build" means.
93pub async fn build_default(manifest_file: &Path, cache_root: Option<PathBuf>) -> Result<()> {
94    build(manifest_file, &[], TargetChecking::Check, cache_root).await
95}
96
97fn build_components(
98    component_ids: &[String],
99    components: Vec<ComponentBuildInfo>,
100    app_dir: &Path,
101) -> Result<(), anyhow::Error> {
102    let components_to_build = if component_ids.is_empty() {
103        components
104    } else {
105        let all_ids: HashSet<_> = components.iter().map(|c| &c.id).collect();
106        let unknown_component_ids: Vec<_> = component_ids
107            .iter()
108            .filter(|id| !all_ids.contains(id))
109            .map(|s| s.as_str())
110            .collect();
111
112        if !unknown_component_ids.is_empty() {
113            bail!("Unknown component(s) {}", unknown_component_ids.join(", "));
114        }
115
116        components
117            .into_iter()
118            .filter(|c| component_ids.contains(&c.id))
119            .collect()
120    };
121
122    if components_to_build.iter().all(|c| c.build.is_none()) {
123        println!("None of the components have a build command.");
124        println!("For information on specifying a build command, see https://spinframework.dev/build#setting-up-for-spin-build.");
125        return Ok(());
126    }
127
128    // If dependencies are being built as part of `spin build`, we would like
129    // them to be rebuilt earlier (e.g. so that consumers using the binary as a source
130    // of type information see the latest interface).
131    let (components_to_build, has_cycle) = sort(components_to_build);
132
133    if has_cycle {
134        tracing::debug!("There is a dependency cycle among components. Spin cannot guarantee to build dependencies before consumers.");
135    }
136
137    components_to_build
138        .into_iter()
139        .map(|c| build_component(c, app_dir))
140        .collect::<Result<Vec<_>, _>>()?;
141
142    terminal::step!("Finished", "building all Spin components");
143    Ok(())
144}
145
146/// Run the build command of the component.
147fn build_component(build_info: ComponentBuildInfo, app_dir: &Path) -> Result<()> {
148    match build_info.build {
149        Some(b) => {
150            let command_count = b.commands().len();
151
152            if command_count > 1 {
153                terminal::step!(
154                    "Building",
155                    "component {} ({} commands)",
156                    build_info.id,
157                    command_count
158                );
159            }
160
161            for (index, command) in b.commands().enumerate() {
162                if command_count > 1 {
163                    terminal::step!(
164                        "Running build step",
165                        "{}/{} for component {} with '{}'",
166                        index + 1,
167                        command_count,
168                        build_info.id,
169                        command
170                    );
171                } else {
172                    terminal::step!("Building", "component {} with `{}`", build_info.id, command);
173                }
174
175                let workdir = construct_workdir(app_dir, b.workdir.as_ref())?;
176                if b.workdir.is_some() {
177                    println!("Working directory: {}", quoted_path(&workdir));
178                }
179
180                let exit_status = Exec::shell(command)
181                    .cwd(workdir)
182                    .stdout(Redirection::None)
183                    .stderr(Redirection::None)
184                    .stdin(Redirection::None)
185                    .popen()
186                    .map_err(|err| {
187                        anyhow!(
188                            "Cannot spawn build process '{:?}' for component {}: {}",
189                            &b.command,
190                            build_info.id,
191                            err
192                        )
193                    })?
194                    .wait()?;
195
196                if !exit_status.success() {
197                    bail!(
198                        "Build command for component {} failed with status {:?}",
199                        build_info.id,
200                        exit_status,
201                    );
202                }
203            }
204
205            Ok(())
206        }
207        _ => Ok(()),
208    }
209}
210
211/// Constructs the absolute working directory in which to run the build command.
212fn construct_workdir(app_dir: &Path, workdir: Option<impl AsRef<Path>>) -> Result<PathBuf> {
213    let mut cwd = app_dir.to_owned();
214
215    if let Some(workdir) = workdir {
216        // Using `Path::has_root` as `is_relative` and `is_absolute` have
217        // surprising behavior on Windows, see:
218        // https://doc.rust-lang.org/std/path/struct.Path.html#method.is_absolute
219        if workdir.as_ref().has_root() {
220            bail!("The workdir specified in the application file must be relative.");
221        }
222        cwd.push(workdir);
223    }
224
225    Ok(cwd)
226}
227
228#[derive(Clone)]
229struct SortableBuildInfo {
230    source: Option<String>,
231    local_dependency_paths: Vec<String>,
232    build_info: ComponentBuildInfo,
233}
234
235impl From<&ComponentBuildInfo> for SortableBuildInfo {
236    fn from(value: &ComponentBuildInfo) -> Self {
237        fn local_dep_path(dep: &v2::ComponentDependency) -> Option<String> {
238            match dep {
239                v2::ComponentDependency::Local { path, .. } => Some(path.display().to_string()),
240                _ => None,
241            }
242        }
243
244        let source = match value.source.as_ref() {
245            Some(spin_manifest::schema::v2::ComponentSource::Local(path)) => Some(path.clone()),
246            _ => None,
247        };
248        let local_dependency_paths = value
249            .dependencies
250            .inner
251            .values()
252            .filter_map(local_dep_path)
253            .collect();
254
255        Self {
256            source,
257            local_dependency_paths,
258            build_info: value.clone(),
259        }
260    }
261}
262
263impl std::hash::Hash for SortableBuildInfo {
264    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
265        self.build_info.id.hash(state);
266        self.source.hash(state);
267        self.local_dependency_paths.hash(state);
268    }
269}
270
271impl PartialEq for SortableBuildInfo {
272    fn eq(&self, other: &Self) -> bool {
273        self.build_info.id == other.build_info.id
274            && self.source == other.source
275            && self.local_dependency_paths == other.local_dependency_paths
276    }
277}
278
279impl Eq for SortableBuildInfo {}
280
281/// Topo sort by local path dependency. Second result is if there was a cycle.
282fn sort(components: Vec<ComponentBuildInfo>) -> (Vec<ComponentBuildInfo>, bool) {
283    let sortables = components
284        .iter()
285        .map(SortableBuildInfo::from)
286        .collect::<Vec<_>>();
287    let mut sorter = topological_sort::TopologicalSort::<SortableBuildInfo>::new();
288
289    for s in &sortables {
290        sorter.insert(s.clone());
291    }
292
293    for s1 in &sortables {
294        for dep in &s1.local_dependency_paths {
295            for s2 in &sortables {
296                if s2.source.as_ref().is_some_and(|src| src == dep) {
297                    // s1 depends on s2
298                    sorter.add_link(topological_sort::DependencyLink {
299                        prec: s2.clone(),
300                        succ: s1.clone(),
301                    });
302                }
303            }
304        }
305    }
306
307    let result = sorter.map(|s| s.build_info).collect::<Vec<_>>();
308
309    // We shouldn't refuse to build if a cycle occurs, so return the original order to allow
310    // stuff to proceed.  (We could be smarter about this, but really it's a pathological situation
311    // and we don't need to bust a gut over it.)
312    if result.len() == components.len() {
313        (result, false)
314    } else {
315        (components, true)
316    }
317}
318
319/// Specifies target environment checking behaviour
320pub enum TargetChecking {
321    /// The build should check that all components are compatible with all target environments.
322    Check,
323    /// The build should not check target environments.
324    Skip,
325}
326
327impl TargetChecking {
328    /// Should the build check target environments?
329    fn check(&self) -> bool {
330        matches!(self, Self::Check)
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    fn test_data_root() -> PathBuf {
339        let crate_dir = env!("CARGO_MANIFEST_DIR");
340        PathBuf::from(crate_dir).join("tests")
341    }
342
343    #[tokio::test]
344    async fn can_load_even_if_trigger_invalid() {
345        let bad_trigger_file = test_data_root().join("bad_trigger.toml");
346        build(&bad_trigger_file, &[], TargetChecking::Skip, None)
347            .await
348            .unwrap();
349    }
350
351    #[tokio::test]
352    async fn succeeds_if_target_env_matches() {
353        let manifest_path = test_data_root().join("good_target_env.toml");
354        build(&manifest_path, &[], TargetChecking::Check, None)
355            .await
356            .unwrap();
357    }
358
359    #[tokio::test]
360    async fn fails_if_target_env_does_not_match() {
361        let manifest_path = test_data_root().join("bad_target_env.toml");
362        let err = build(&manifest_path, &[], TargetChecking::Check, None)
363            .await
364            .expect_err("should have failed")
365            .to_string();
366
367        // build prints validation errors rather than returning them to top level
368        // (because there could be multiple errors) - see has_meaningful_error_if_target_env_does_not_match
369        assert!(
370            err.contains("one or more was incompatible with one or more of the deployment targets")
371        );
372    }
373
374    #[tokio::test]
375    async fn has_meaningful_error_if_target_env_does_not_match() {
376        let manifest_file = test_data_root().join("bad_target_env.toml");
377        let manifest = spin_manifest::manifest_from_file(&manifest_file).unwrap();
378        let application = spin_environments::ApplicationToValidate::new(
379            manifest.clone(),
380            manifest_file.parent().unwrap(),
381        )
382        .await
383        .context("unable to load application for checking against deployment targets")
384        .unwrap();
385
386        let target_validation = spin_environments::validate_application_against_environment_ids(
387            &application,
388            &manifest.application.targets,
389            None,
390            manifest_file.parent().unwrap(),
391        )
392        .await
393        .context("unable to check if the application is compatible with deployment targets")
394        .unwrap();
395
396        assert_eq!(1, target_validation.errors().len());
397
398        let err = target_validation.errors()[0].to_string();
399
400        assert!(err.contains("can't run in environment wasi-minimal"));
401        assert!(err.contains("world wasi:cli/command@0.2.0"));
402        assert!(err.contains("requires imports named"));
403        assert!(err.contains("wasi:cli/stdout"));
404    }
405
406    fn dummy_buildinfo(id: &str) -> ComponentBuildInfo {
407        dummy_build_info_deps(id, &[])
408    }
409
410    fn dummy_build_info_dep(id: &str, dep_on: &str) -> ComponentBuildInfo {
411        dummy_build_info_deps(id, &[dep_on])
412    }
413
414    fn dummy_build_info_deps(id: &str, dep_on: &[&str]) -> ComponentBuildInfo {
415        ComponentBuildInfo {
416            id: id.into(),
417            source: Some(v2::ComponentSource::Local(format!("{id}.wasm"))),
418            build: None,
419            dependencies: depends_on(dep_on),
420        }
421    }
422
423    fn depends_on(paths: &[&str]) -> v2::ComponentDependencies {
424        let mut deps = vec![];
425        for (index, path) in paths.iter().enumerate() {
426            let dep_name =
427                spin_serde::DependencyName::Plain(format!("dummy{index}").try_into().unwrap());
428            let dep = v2::ComponentDependency::Local {
429                path: path.into(),
430                export: None,
431            };
432            deps.push((dep_name, dep));
433        }
434        v2::ComponentDependencies {
435            inner: deps.into_iter().collect(),
436        }
437    }
438
439    /// Asserts that id `before` comes before id `after` in collection `cs`
440    fn assert_before(cs: &[ComponentBuildInfo], before: &str, after: &str) {
441        assert!(
442            cs.iter().position(|c| c.id == before).unwrap()
443                < cs.iter().position(|c| c.id == after).unwrap()
444        );
445    }
446
447    #[test]
448    fn if_no_dependencies_then_all_build() {
449        let (cs, had_cycle) = sort(vec![dummy_buildinfo("1"), dummy_buildinfo("2")]);
450        assert_eq!(2, cs.len());
451        assert!(cs.iter().any(|c| c.id == "1"));
452        assert!(cs.iter().any(|c| c.id == "2"));
453        assert!(!had_cycle);
454    }
455
456    #[test]
457    fn dependencies_build_before_consumers() {
458        let (cs, had_cycle) = sort(vec![
459            dummy_buildinfo("1"),
460            dummy_build_info_dep("2", "3.wasm"),
461            dummy_buildinfo("3"),
462            dummy_build_info_dep("4", "1.wasm"),
463        ]);
464        assert_eq!(4, cs.len());
465        assert_before(&cs, "1", "4");
466        assert_before(&cs, "3", "2");
467        assert!(!had_cycle);
468    }
469
470    #[test]
471    fn multiple_dependencies_build_before_consumers() {
472        let (cs, had_cycle) = sort(vec![
473            dummy_buildinfo("1"),
474            dummy_build_info_dep("2", "3.wasm"),
475            dummy_buildinfo("3"),
476            dummy_build_info_dep("4", "1.wasm"),
477            dummy_build_info_dep("5", "3.wasm"),
478            dummy_build_info_deps("6", &["3.wasm", "2.wasm"]),
479            dummy_buildinfo("7"),
480        ]);
481        assert_eq!(7, cs.len());
482        assert_before(&cs, "1", "4");
483        assert_before(&cs, "3", "2");
484        assert_before(&cs, "3", "5");
485        assert_before(&cs, "3", "6");
486        assert_before(&cs, "2", "6");
487        assert!(!had_cycle);
488    }
489
490    #[test]
491    fn circular_dependencies_dont_prevent_build() {
492        let (cs, had_cycle) = sort(vec![
493            dummy_buildinfo("1"),
494            dummy_build_info_dep("2", "3.wasm"),
495            dummy_build_info_dep("3", "2.wasm"),
496            dummy_build_info_dep("4", "1.wasm"),
497        ]);
498        assert_eq!(4, cs.len());
499        assert!(cs.iter().any(|c| c.id == "1"));
500        assert!(cs.iter().any(|c| c.id == "2"));
501        assert!(cs.iter().any(|c| c.id == "3"));
502        assert!(cs.iter().any(|c| c.id == "4"));
503        assert!(had_cycle);
504    }
505
506    #[test]
507    fn non_path_dependencies_do_not_prevent_sorting() {
508        let mut depends_on_remote = dummy_buildinfo("2");
509        depends_on_remote.dependencies.inner.insert(
510            spin_serde::DependencyName::Plain("remote".to_owned().try_into().unwrap()),
511            v2::ComponentDependency::Version("1.2.3".to_owned()),
512        );
513
514        let mut depends_on_local_and_remote = dummy_build_info_dep("4", "1.wasm");
515        depends_on_local_and_remote.dependencies.inner.insert(
516            spin_serde::DependencyName::Plain("remote".to_owned().try_into().unwrap()),
517            v2::ComponentDependency::Version("1.2.3".to_owned()),
518        );
519
520        let (cs, _) = sort(vec![
521            dummy_buildinfo("1"),
522            depends_on_remote,
523            dummy_buildinfo("3"),
524            depends_on_local_and_remote,
525        ]);
526
527        assert_eq!(4, cs.len());
528        assert_before(&cs, "1", "4");
529    }
530
531    #[test]
532    fn non_path_sources_do_not_prevent_sorting() {
533        let mut remote_source = dummy_build_info_dep("2", "3.wasm");
534        remote_source.source = Some(v2::ComponentSource::Remote {
535            url: "far://away".into(),
536            digest: "loadsa-hex".into(),
537        });
538
539        let (cs, _) = sort(vec![
540            dummy_buildinfo("1"),
541            remote_source,
542            dummy_buildinfo("3"),
543            dummy_build_info_dep("4", "1.wasm"),
544        ]);
545
546        assert_eq!(4, cs.len());
547        assert_before(&cs, "1", "4");
548    }
549
550    #[test]
551    fn dependencies_on_non_manifest_components_do_not_prevent_sorting() {
552        let (cs, had_cycle) = sort(vec![
553            dummy_buildinfo("1"),
554            dummy_build_info_deps("2", &["3.wasm", "crikey.wasm"]),
555            dummy_buildinfo("3"),
556            dummy_build_info_dep("4", "1.wasm"),
557        ]);
558        assert_eq!(4, cs.len());
559        assert_before(&cs, "1", "4");
560        assert_before(&cs, "3", "2");
561        assert!(!had_cycle);
562    }
563}