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::{Context, Result, anyhow, bail};
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
19const LAST_BUILD_PROFILE_FILE: &str = "last-build.txt";
20const LAST_BUILD_ANON_VALUE: &str = "<anonymous>";
21
22/// If present, run the build command of each component.
23pub async fn build(
24    manifest_file: &Path,
25    profile: Option<&str>,
26    component_ids: &[String],
27    target_checks: TargetChecking,
28    wit_generation: GenerateDependencyWits,
29    cache_root: Option<PathBuf>,
30) -> Result<()> {
31    let build_info = component_build_configs(manifest_file, profile)
32        .await
33        .with_context(|| {
34            format!(
35                "Cannot read manifest file from {}",
36                quoted_path(manifest_file)
37            )
38        })?;
39    let app_dir = parent_dir(manifest_file)?;
40
41    let components_to_build = components_to_build(component_ids, build_info.components())?;
42
43    if wit_generation.generate() {
44        let wit_gen_errs = regenerate_wits(&components_to_build, &app_dir).await;
45        if !wit_gen_errs.is_empty() {
46            terminal::warn!(
47                "One or more components specified dependencies for which Spin couldn't generate import interfaces."
48            );
49            eprintln!(
50                "If these components rely on Spin-generated interfaces they may fail to build."
51            );
52            eprintln!(
53                "Otherwise, to skip interface generation, use the --skip-generate-wits flag."
54            );
55            eprintln!("Error details:");
56            for (component, err) in wit_gen_errs {
57                terminal::einfo!("{component}:", "{err:#}");
58            }
59        }
60    }
61
62    let build_result = build_components(components_to_build, &app_dir);
63
64    // Emit any required warnings now, so that they don't bury any errors.
65    if let Some(e) = build_info.load_error() {
66        // The manifest had errors. We managed to attempt a build anyway, but we want to
67        // let the user know about them.
68        terminal::warn!(
69            "The manifest has errors not related to the Wasm component build. Error details:\n{e:#}"
70        );
71        // Checking deployment targets requires a healthy manifest (because trigger types etc.),
72        // if any of these were specified, warn they are being skipped.
73        let should_have_checked_targets =
74            target_checks.check() && build_info.has_deployment_targets();
75        if should_have_checked_targets {
76            terminal::warn!(
77                "The manifest error(s) prevented Spin from checking the deployment targets."
78            );
79        }
80    }
81
82    // If the build failed, exit with an error at this point.
83    build_result?;
84
85    if let Err(e) = save_last_build_profile(&app_dir, profile) {
86        tracing::warn!("Failed to save build profile: {e:?}");
87    }
88
89    let Some(manifest) = build_info.manifest() else {
90        // We can't proceed to checking (because that needs a full healthy manifest), and we've
91        // already emitted any necessary warning, so quit.
92        return Ok(());
93    };
94
95    if target_checks.check() {
96        let application = spin_environments::ApplicationToValidate::new(
97            manifest.clone(),
98            profile,
99            manifest_file.parent().unwrap(),
100        )
101        .await
102        .context("unable to load application for checking against deployment targets")?;
103        let target_validation = spin_environments::validate_application_against_environment_ids(
104            &application,
105            build_info.deployment_targets(),
106            cache_root.clone(),
107            &app_dir,
108        )
109        .await
110        .context("unable to check if the application is compatible with deployment targets")?;
111
112        if !target_validation.is_ok() {
113            for error in target_validation.errors() {
114                terminal::error!("{error}");
115            }
116            anyhow::bail!(
117                "All components built successfully, but one or more was incompatible with one or more of the deployment targets."
118            );
119        }
120    }
121
122    Ok(())
123}
124
125/// Run all component build commands, using the default options (build all
126/// components, perform target checking). We run a "default build" in several
127/// places and this centralises the logic of what such a "default build" means.
128pub async fn build_default(
129    manifest_file: &Path,
130    profile: Option<&str>,
131    cache_root: Option<PathBuf>,
132) -> Result<()> {
133    build(
134        manifest_file,
135        profile,
136        &[],
137        TargetChecking::Check,
138        GenerateDependencyWits::Generate,
139        cache_root,
140    )
141    .await
142}
143
144fn components_to_build(
145    component_ids: &[String],
146    components: Vec<ComponentBuildInfo>,
147) -> anyhow::Result<Vec<ComponentBuildInfo>> {
148    let components_to_build = if component_ids.is_empty() {
149        components
150    } else {
151        let all_ids: HashSet<_> = components.iter().map(|c| &c.id).collect();
152        let unknown_component_ids: Vec<_> = component_ids
153            .iter()
154            .filter(|id| !all_ids.contains(id))
155            .map(|s| s.as_str())
156            .collect();
157
158        if !unknown_component_ids.is_empty() {
159            bail!("Unknown component(s) {}", unknown_component_ids.join(", "));
160        }
161
162        components
163            .into_iter()
164            .filter(|c| component_ids.contains(&c.id))
165            .collect()
166    };
167
168    Ok(components_to_build)
169}
170
171#[must_use]
172async fn regenerate_wits(
173    components_to_build: &[ComponentBuildInfo],
174    app_root: &Path,
175) -> Vec<(String, anyhow::Error)> {
176    let mut errors = vec![];
177
178    for component in components_to_build {
179        let component_dir = match component.build.as_ref().and_then(|b| b.workdir.as_ref()) {
180            None => app_root.to_owned(),
181            Some(d) => app_root.join(d),
182        };
183        let dest_file = component_dir.join("spin-dependencies.wit");
184        let extract_result = spin_dependency_wit::extract_wits_into(
185            component.dependencies.inner.iter(),
186            app_root,
187            dest_file,
188        )
189        .await;
190        if let Err(e) = extract_result {
191            errors.push((component.id.clone(), e));
192        }
193    }
194
195    errors
196}
197
198fn build_components(
199    components_to_build: Vec<ComponentBuildInfo>,
200    app_dir: &Path,
201) -> anyhow::Result<()> {
202    if components_to_build.iter().all(|c| c.build.is_none()) {
203        println!("None of the components have a build command.");
204        println!(
205            "For information on specifying a build command, see https://spinframework.dev/build#setting-up-for-spin-build."
206        );
207        return Ok(());
208    }
209
210    // If dependencies are being built as part of `spin build`, we would like
211    // them to be rebuilt earlier (e.g. so that consumers using the binary as a source
212    // of type information see the latest interface).
213    let (components_to_build, has_cycle) = sort(components_to_build);
214
215    if has_cycle {
216        tracing::debug!(
217            "There is a dependency cycle among components. Spin cannot guarantee to build dependencies before consumers."
218        );
219    }
220
221    components_to_build
222        .into_iter()
223        .map(|c| build_component(c, app_dir))
224        .collect::<Result<Vec<_>, _>>()?;
225
226    terminal::step!("Finished", "building all Spin components");
227    Ok(())
228}
229
230/// Run the build command of the component.
231fn build_component(build_info: ComponentBuildInfo, app_dir: &Path) -> Result<()> {
232    match build_info.build {
233        Some(b) => {
234            let command_count = b.commands().len();
235
236            if command_count > 1 {
237                terminal::step!(
238                    "Building",
239                    "component {} ({} commands)",
240                    build_info.id,
241                    command_count
242                );
243            }
244
245            for (index, command) in b.commands().enumerate() {
246                if command_count > 1 {
247                    terminal::step!(
248                        "Running build step",
249                        "{}/{} for component {} with '{}'",
250                        index + 1,
251                        command_count,
252                        build_info.id,
253                        command
254                    );
255                } else {
256                    terminal::step!("Building", "component {} with `{}`", build_info.id, command);
257                }
258
259                let workdir = construct_workdir(app_dir, b.workdir.as_ref())?;
260                if b.workdir.is_some() {
261                    println!("Working directory: {}", quoted_path(&workdir));
262                }
263
264                let exit_status = Exec::shell(command)
265                    .cwd(workdir)
266                    .stdout(Redirection::None)
267                    .stderr(Redirection::None)
268                    .stdin(Redirection::None)
269                    .popen()
270                    .map_err(|err| {
271                        anyhow!(
272                            "Cannot spawn build process '{:?}' for component {}: {}",
273                            &b.command,
274                            build_info.id,
275                            err
276                        )
277                    })?
278                    .wait()?;
279
280                if !exit_status.success() {
281                    bail!(
282                        "Build command for component {} failed with status {:?}",
283                        build_info.id,
284                        exit_status,
285                    );
286                }
287            }
288
289            Ok(())
290        }
291        _ => Ok(()),
292    }
293}
294
295/// Constructs the absolute working directory in which to run the build command.
296fn construct_workdir(app_dir: &Path, workdir: Option<impl AsRef<Path>>) -> Result<PathBuf> {
297    let mut cwd = app_dir.to_owned();
298
299    if let Some(workdir) = workdir {
300        // Using `Path::has_root` as `is_relative` and `is_absolute` have
301        // surprising behavior on Windows, see:
302        // https://doc.rust-lang.org/std/path/struct.Path.html#method.is_absolute
303        if workdir.as_ref().has_root() {
304            bail!("The workdir specified in the application file must be relative.");
305        }
306        cwd.push(workdir);
307    }
308
309    Ok(cwd)
310}
311
312#[derive(Clone)]
313struct SortableBuildInfo {
314    source: Option<String>,
315    local_dependency_paths: Vec<String>,
316    build_info: ComponentBuildInfo,
317}
318
319impl From<&ComponentBuildInfo> for SortableBuildInfo {
320    fn from(value: &ComponentBuildInfo) -> Self {
321        fn local_dep_path(dep: &v2::ComponentDependency) -> Option<String> {
322            match dep {
323                v2::ComponentDependency::Local { path, .. } => Some(path.display().to_string()),
324                _ => None,
325            }
326        }
327
328        let source = match value.source.as_ref() {
329            Some(spin_manifest::schema::v2::ComponentSource::Local(path)) => Some(path.clone()),
330            _ => None,
331        };
332        let local_dependency_paths = value
333            .dependencies
334            .inner
335            .values()
336            .filter_map(local_dep_path)
337            .collect();
338
339        Self {
340            source,
341            local_dependency_paths,
342            build_info: value.clone(),
343        }
344    }
345}
346
347impl std::hash::Hash for SortableBuildInfo {
348    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
349        self.build_info.id.hash(state);
350        self.source.hash(state);
351        self.local_dependency_paths.hash(state);
352    }
353}
354
355impl PartialEq for SortableBuildInfo {
356    fn eq(&self, other: &Self) -> bool {
357        self.build_info.id == other.build_info.id
358            && self.source == other.source
359            && self.local_dependency_paths == other.local_dependency_paths
360    }
361}
362
363impl Eq for SortableBuildInfo {}
364
365/// Topo sort by local path dependency. Second result is if there was a cycle.
366fn sort(components: Vec<ComponentBuildInfo>) -> (Vec<ComponentBuildInfo>, bool) {
367    let sortables = components
368        .iter()
369        .map(SortableBuildInfo::from)
370        .collect::<Vec<_>>();
371    let mut sorter = topological_sort::TopologicalSort::<SortableBuildInfo>::new();
372
373    for s in &sortables {
374        sorter.insert(s.clone());
375    }
376
377    for s1 in &sortables {
378        for dep in &s1.local_dependency_paths {
379            for s2 in &sortables {
380                if s2.source.as_ref().is_some_and(|src| src == dep) {
381                    // s1 depends on s2
382                    sorter.add_link(topological_sort::DependencyLink {
383                        prec: s2.clone(),
384                        succ: s1.clone(),
385                    });
386                }
387            }
388        }
389    }
390
391    let result = sorter.map(|s| s.build_info).collect::<Vec<_>>();
392
393    // We shouldn't refuse to build if a cycle occurs, so return the original order to allow
394    // stuff to proceed.  (We could be smarter about this, but really it's a pathological situation
395    // and we don't need to bust a gut over it.)
396    if result.len() == components.len() {
397        (result, false)
398    } else {
399        (components, true)
400    }
401}
402
403/// Saves the build profile to the "last build profile" file.
404pub fn save_last_build_profile(app_dir: &Path, profile: Option<&str>) -> anyhow::Result<()> {
405    let app_stash_dir = app_dir.join(".spin");
406    let last_build_profile_file = app_stash_dir.join(LAST_BUILD_PROFILE_FILE);
407
408    // This way, if the user never uses build profiles, they won't see a
409    // weird savefile that they have no idea what it is.
410    if profile.is_none() && !last_build_profile_file.exists() {
411        return Ok(());
412    }
413
414    std::fs::create_dir_all(&app_stash_dir)?;
415    std::fs::write(
416        &last_build_profile_file,
417        profile.unwrap_or(LAST_BUILD_ANON_VALUE),
418    )?;
419
420    Ok(())
421}
422
423/// Reads the last build profile from the "last build profile" file.
424pub fn read_last_build_profile(app_dir: &Path) -> anyhow::Result<Option<String>> {
425    let app_stash_dir = app_dir.join(".spin");
426    let last_build_profile_file = app_stash_dir.join(LAST_BUILD_PROFILE_FILE);
427    if !last_build_profile_file.exists() {
428        return Ok(None);
429    }
430
431    let last_build_str = std::fs::read_to_string(&last_build_profile_file)?;
432
433    if last_build_str == LAST_BUILD_ANON_VALUE {
434        Ok(None)
435    } else {
436        Ok(Some(last_build_str))
437    }
438}
439
440/// Prints a warning to stderr if the given profile is not the same
441/// as the most recent build in the given application directory.
442pub fn warn_if_not_latest_build(manifest_path: &Path, profile: Option<&str>) {
443    let Some(app_dir) = manifest_path.parent() else {
444        return;
445    };
446
447    let latest_build = match read_last_build_profile(app_dir) {
448        Ok(profile) => profile,
449        Err(e) => {
450            tracing::warn!(
451                "Failed to read last build profile: using anonymous profile. Error was {e:?}"
452            );
453            None
454        }
455    };
456
457    if profile != latest_build.as_deref() {
458        let profile_opt = match profile {
459            Some(p) => format!(" --profile {p}"),
460            None => "".to_string(),
461        };
462        terminal::warn!(
463            "You built a different profile more recently than the one you are running. If the app appears to be behaving like an older version then run `spin up --build{profile_opt}`."
464        );
465    }
466}
467
468/// Specifies target environment checking behaviour
469pub enum TargetChecking {
470    /// The build should check that all components are compatible with all target environments.
471    Check,
472    /// The build should not check target environments.
473    Skip,
474}
475
476impl TargetChecking {
477    /// Should the build check target environments?
478    fn check(&self) -> bool {
479        matches!(self, Self::Check)
480    }
481}
482
483/// Specifies dependency WIT generation behaviour
484pub enum GenerateDependencyWits {
485    /// The build should generate WITs for component dependencies.
486    Generate,
487    /// The build should not generate WITs.
488    Skip,
489}
490
491impl GenerateDependencyWits {
492    /// Should the build generate dependency WITs?
493    fn generate(&self) -> bool {
494        matches!(self, Self::Generate)
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    fn test_data_root() -> PathBuf {
503        let crate_dir = env!("CARGO_MANIFEST_DIR");
504        PathBuf::from(crate_dir).join("tests")
505    }
506
507    #[tokio::test]
508    async fn can_load_even_if_trigger_invalid() {
509        let bad_trigger_file = test_data_root().join("bad_trigger.toml");
510        build(
511            &bad_trigger_file,
512            None,
513            &[],
514            TargetChecking::Skip,
515            GenerateDependencyWits::Skip,
516            None,
517        )
518        .await
519        .unwrap();
520    }
521
522    #[tokio::test]
523    async fn succeeds_if_target_env_matches() {
524        let manifest_path = test_data_root().join("good_target_env.toml");
525        build(
526            &manifest_path,
527            None,
528            &[],
529            TargetChecking::Check,
530            GenerateDependencyWits::Skip,
531            None,
532        )
533        .await
534        .unwrap();
535    }
536
537    #[tokio::test]
538    async fn fails_if_target_env_does_not_match() {
539        let manifest_path = test_data_root().join("bad_target_env.toml");
540        let err = build(
541            &manifest_path,
542            None,
543            &[],
544            TargetChecking::Check,
545            GenerateDependencyWits::Skip,
546            None,
547        )
548        .await
549        .expect_err("should have failed")
550        .to_string();
551
552        // build prints validation errors rather than returning them to top level
553        // (because there could be multiple errors) - see has_meaningful_error_if_target_env_does_not_match
554        assert!(
555            err.contains("one or more was incompatible with one or more of the deployment targets")
556        );
557    }
558
559    #[tokio::test]
560    async fn has_meaningful_error_if_target_env_does_not_match() {
561        let manifest_file = test_data_root().join("bad_target_env.toml");
562        let mut manifest = spin_manifest::manifest_from_file(&manifest_file).unwrap();
563        spin_manifest::normalize::normalize_manifest(&mut manifest, None).unwrap();
564        let application = spin_environments::ApplicationToValidate::new(
565            manifest.clone(),
566            None,
567            manifest_file.parent().unwrap(),
568        )
569        .await
570        .context("unable to load application for checking against deployment targets")
571        .unwrap();
572
573        let target_validation = spin_environments::validate_application_against_environment_ids(
574            &application,
575            spin_environments::Targets {
576                default: &manifest.application.targets,
577                overrides: std::collections::HashMap::new(),
578            },
579            None,
580            manifest_file.parent().unwrap(),
581        )
582        .await
583        .context("unable to check if the application is compatible with deployment targets")
584        .unwrap();
585
586        assert_eq!(1, target_validation.errors().len());
587
588        let err = target_validation.errors()[0].to_string();
589
590        assert!(err.contains("can't run in environment wasi-minimal"));
591        assert!(err.contains("world wasi:cli/command@0.2.0"));
592        assert!(err.contains("requires imports named"));
593        assert!(err.contains("wasi:cli/stdout"));
594    }
595
596    fn dummy_buildinfo(id: &str) -> ComponentBuildInfo {
597        dummy_build_info_deps(id, &[])
598    }
599
600    fn dummy_build_info_dep(id: &str, dep_on: &str) -> ComponentBuildInfo {
601        dummy_build_info_deps(id, &[dep_on])
602    }
603
604    fn dummy_build_info_deps(id: &str, dep_on: &[&str]) -> ComponentBuildInfo {
605        ComponentBuildInfo {
606            id: id.into(),
607            source: Some(v2::ComponentSource::Local(format!("{id}.wasm"))),
608            build: None,
609            dependencies: depends_on(dep_on),
610            targets: None,
611        }
612    }
613
614    fn depends_on(paths: &[&str]) -> v2::ComponentDependencies {
615        let mut deps = vec![];
616        for (index, path) in paths.iter().enumerate() {
617            let dep_name =
618                spin_serde::DependencyName::Plain(format!("dummy{index}").try_into().unwrap());
619            let dep = v2::ComponentDependency::Local {
620                path: path.into(),
621                export: None,
622                inherit_configuration: None,
623            };
624            deps.push((dep_name, dep));
625        }
626        v2::ComponentDependencies {
627            inner: deps.into_iter().collect(),
628        }
629    }
630
631    /// Asserts that id `before` comes before id `after` in collection `cs`
632    fn assert_before(cs: &[ComponentBuildInfo], before: &str, after: &str) {
633        assert!(
634            cs.iter().position(|c| c.id == before).unwrap()
635                < cs.iter().position(|c| c.id == after).unwrap()
636        );
637    }
638
639    #[test]
640    fn if_no_dependencies_then_all_build() {
641        let (cs, had_cycle) = sort(vec![dummy_buildinfo("1"), dummy_buildinfo("2")]);
642        assert_eq!(2, cs.len());
643        assert!(cs.iter().any(|c| c.id == "1"));
644        assert!(cs.iter().any(|c| c.id == "2"));
645        assert!(!had_cycle);
646    }
647
648    #[test]
649    fn dependencies_build_before_consumers() {
650        let (cs, had_cycle) = sort(vec![
651            dummy_buildinfo("1"),
652            dummy_build_info_dep("2", "3.wasm"),
653            dummy_buildinfo("3"),
654            dummy_build_info_dep("4", "1.wasm"),
655        ]);
656        assert_eq!(4, cs.len());
657        assert_before(&cs, "1", "4");
658        assert_before(&cs, "3", "2");
659        assert!(!had_cycle);
660    }
661
662    #[test]
663    fn multiple_dependencies_build_before_consumers() {
664        let (cs, had_cycle) = sort(vec![
665            dummy_buildinfo("1"),
666            dummy_build_info_dep("2", "3.wasm"),
667            dummy_buildinfo("3"),
668            dummy_build_info_dep("4", "1.wasm"),
669            dummy_build_info_dep("5", "3.wasm"),
670            dummy_build_info_deps("6", &["3.wasm", "2.wasm"]),
671            dummy_buildinfo("7"),
672        ]);
673        assert_eq!(7, cs.len());
674        assert_before(&cs, "1", "4");
675        assert_before(&cs, "3", "2");
676        assert_before(&cs, "3", "5");
677        assert_before(&cs, "3", "6");
678        assert_before(&cs, "2", "6");
679        assert!(!had_cycle);
680    }
681
682    #[test]
683    fn circular_dependencies_dont_prevent_build() {
684        let (cs, had_cycle) = sort(vec![
685            dummy_buildinfo("1"),
686            dummy_build_info_dep("2", "3.wasm"),
687            dummy_build_info_dep("3", "2.wasm"),
688            dummy_build_info_dep("4", "1.wasm"),
689        ]);
690        assert_eq!(4, cs.len());
691        assert!(cs.iter().any(|c| c.id == "1"));
692        assert!(cs.iter().any(|c| c.id == "2"));
693        assert!(cs.iter().any(|c| c.id == "3"));
694        assert!(cs.iter().any(|c| c.id == "4"));
695        assert!(had_cycle);
696    }
697
698    #[test]
699    fn non_path_dependencies_do_not_prevent_sorting() {
700        let mut depends_on_remote = dummy_buildinfo("2");
701        depends_on_remote.dependencies.inner.insert(
702            spin_serde::DependencyName::Plain("remote".to_owned().try_into().unwrap()),
703            v2::ComponentDependency::Version("1.2.3".to_owned()),
704        );
705
706        let mut depends_on_local_and_remote = dummy_build_info_dep("4", "1.wasm");
707        depends_on_local_and_remote.dependencies.inner.insert(
708            spin_serde::DependencyName::Plain("remote".to_owned().try_into().unwrap()),
709            v2::ComponentDependency::Version("1.2.3".to_owned()),
710        );
711
712        let (cs, _) = sort(vec![
713            dummy_buildinfo("1"),
714            depends_on_remote,
715            dummy_buildinfo("3"),
716            depends_on_local_and_remote,
717        ]);
718
719        assert_eq!(4, cs.len());
720        assert_before(&cs, "1", "4");
721    }
722
723    #[test]
724    fn non_path_sources_do_not_prevent_sorting() {
725        let mut remote_source = dummy_build_info_dep("2", "3.wasm");
726        remote_source.source = Some(v2::ComponentSource::Remote {
727            url: "far://away".into(),
728            digest: "loadsa-hex".into(),
729        });
730
731        let (cs, _) = sort(vec![
732            dummy_buildinfo("1"),
733            remote_source,
734            dummy_buildinfo("3"),
735            dummy_build_info_dep("4", "1.wasm"),
736        ]);
737
738        assert_eq!(4, cs.len());
739        assert_before(&cs, "1", "4");
740    }
741
742    #[test]
743    fn dependencies_on_non_manifest_components_do_not_prevent_sorting() {
744        let (cs, had_cycle) = sort(vec![
745            dummy_buildinfo("1"),
746            dummy_build_info_deps("2", &["3.wasm", "crikey.wasm"]),
747            dummy_buildinfo("3"),
748            dummy_build_info_dep("4", "1.wasm"),
749        ]);
750        assert_eq!(4, cs.len());
751        assert_before(&cs, "1", "4");
752        assert_before(&cs, "3", "2");
753        assert!(!had_cycle);
754    }
755}