Skip to main content

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            spin_environments::Targets {
389                default: &manifest.application.targets,
390                overrides: std::collections::HashMap::new(),
391            },
392            None,
393            manifest_file.parent().unwrap(),
394        )
395        .await
396        .context("unable to check if the application is compatible with deployment targets")
397        .unwrap();
398
399        assert_eq!(1, target_validation.errors().len());
400
401        let err = target_validation.errors()[0].to_string();
402
403        assert!(err.contains("can't run in environment wasi-minimal"));
404        assert!(err.contains("world wasi:cli/command@0.2.0"));
405        assert!(err.contains("requires imports named"));
406        assert!(err.contains("wasi:cli/stdout"));
407    }
408
409    fn dummy_buildinfo(id: &str) -> ComponentBuildInfo {
410        dummy_build_info_deps(id, &[])
411    }
412
413    fn dummy_build_info_dep(id: &str, dep_on: &str) -> ComponentBuildInfo {
414        dummy_build_info_deps(id, &[dep_on])
415    }
416
417    fn dummy_build_info_deps(id: &str, dep_on: &[&str]) -> ComponentBuildInfo {
418        ComponentBuildInfo {
419            id: id.into(),
420            source: Some(v2::ComponentSource::Local(format!("{id}.wasm"))),
421            build: None,
422            dependencies: depends_on(dep_on),
423            targets: None,
424        }
425    }
426
427    fn depends_on(paths: &[&str]) -> v2::ComponentDependencies {
428        let mut deps = vec![];
429        for (index, path) in paths.iter().enumerate() {
430            let dep_name =
431                spin_serde::DependencyName::Plain(format!("dummy{index}").try_into().unwrap());
432            let dep = v2::ComponentDependency::Local {
433                path: path.into(),
434                export: None,
435            };
436            deps.push((dep_name, dep));
437        }
438        v2::ComponentDependencies {
439            inner: deps.into_iter().collect(),
440        }
441    }
442
443    /// Asserts that id `before` comes before id `after` in collection `cs`
444    fn assert_before(cs: &[ComponentBuildInfo], before: &str, after: &str) {
445        assert!(
446            cs.iter().position(|c| c.id == before).unwrap()
447                < cs.iter().position(|c| c.id == after).unwrap()
448        );
449    }
450
451    #[test]
452    fn if_no_dependencies_then_all_build() {
453        let (cs, had_cycle) = sort(vec![dummy_buildinfo("1"), dummy_buildinfo("2")]);
454        assert_eq!(2, cs.len());
455        assert!(cs.iter().any(|c| c.id == "1"));
456        assert!(cs.iter().any(|c| c.id == "2"));
457        assert!(!had_cycle);
458    }
459
460    #[test]
461    fn dependencies_build_before_consumers() {
462        let (cs, had_cycle) = sort(vec![
463            dummy_buildinfo("1"),
464            dummy_build_info_dep("2", "3.wasm"),
465            dummy_buildinfo("3"),
466            dummy_build_info_dep("4", "1.wasm"),
467        ]);
468        assert_eq!(4, cs.len());
469        assert_before(&cs, "1", "4");
470        assert_before(&cs, "3", "2");
471        assert!(!had_cycle);
472    }
473
474    #[test]
475    fn multiple_dependencies_build_before_consumers() {
476        let (cs, had_cycle) = sort(vec![
477            dummy_buildinfo("1"),
478            dummy_build_info_dep("2", "3.wasm"),
479            dummy_buildinfo("3"),
480            dummy_build_info_dep("4", "1.wasm"),
481            dummy_build_info_dep("5", "3.wasm"),
482            dummy_build_info_deps("6", &["3.wasm", "2.wasm"]),
483            dummy_buildinfo("7"),
484        ]);
485        assert_eq!(7, cs.len());
486        assert_before(&cs, "1", "4");
487        assert_before(&cs, "3", "2");
488        assert_before(&cs, "3", "5");
489        assert_before(&cs, "3", "6");
490        assert_before(&cs, "2", "6");
491        assert!(!had_cycle);
492    }
493
494    #[test]
495    fn circular_dependencies_dont_prevent_build() {
496        let (cs, had_cycle) = sort(vec![
497            dummy_buildinfo("1"),
498            dummy_build_info_dep("2", "3.wasm"),
499            dummy_build_info_dep("3", "2.wasm"),
500            dummy_build_info_dep("4", "1.wasm"),
501        ]);
502        assert_eq!(4, cs.len());
503        assert!(cs.iter().any(|c| c.id == "1"));
504        assert!(cs.iter().any(|c| c.id == "2"));
505        assert!(cs.iter().any(|c| c.id == "3"));
506        assert!(cs.iter().any(|c| c.id == "4"));
507        assert!(had_cycle);
508    }
509
510    #[test]
511    fn non_path_dependencies_do_not_prevent_sorting() {
512        let mut depends_on_remote = dummy_buildinfo("2");
513        depends_on_remote.dependencies.inner.insert(
514            spin_serde::DependencyName::Plain("remote".to_owned().try_into().unwrap()),
515            v2::ComponentDependency::Version("1.2.3".to_owned()),
516        );
517
518        let mut depends_on_local_and_remote = dummy_build_info_dep("4", "1.wasm");
519        depends_on_local_and_remote.dependencies.inner.insert(
520            spin_serde::DependencyName::Plain("remote".to_owned().try_into().unwrap()),
521            v2::ComponentDependency::Version("1.2.3".to_owned()),
522        );
523
524        let (cs, _) = sort(vec![
525            dummy_buildinfo("1"),
526            depends_on_remote,
527            dummy_buildinfo("3"),
528            depends_on_local_and_remote,
529        ]);
530
531        assert_eq!(4, cs.len());
532        assert_before(&cs, "1", "4");
533    }
534
535    #[test]
536    fn non_path_sources_do_not_prevent_sorting() {
537        let mut remote_source = dummy_build_info_dep("2", "3.wasm");
538        remote_source.source = Some(v2::ComponentSource::Remote {
539            url: "far://away".into(),
540            digest: "loadsa-hex".into(),
541        });
542
543        let (cs, _) = sort(vec![
544            dummy_buildinfo("1"),
545            remote_source,
546            dummy_buildinfo("3"),
547            dummy_build_info_dep("4", "1.wasm"),
548        ]);
549
550        assert_eq!(4, cs.len());
551        assert_before(&cs, "1", "4");
552    }
553
554    #[test]
555    fn dependencies_on_non_manifest_components_do_not_prevent_sorting() {
556        let (cs, had_cycle) = sort(vec![
557            dummy_buildinfo("1"),
558            dummy_build_info_deps("2", &["3.wasm", "crikey.wasm"]),
559            dummy_buildinfo("3"),
560            dummy_build_info_dep("4", "1.wasm"),
561        ]);
562        assert_eq!(4, cs.len());
563        assert_before(&cs, "1", "4");
564        assert_before(&cs, "3", "2");
565        assert!(!had_cycle);
566    }
567}