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