1#![deny(missing_docs)]
2
3mod 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
19pub 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 if let Some(e) = build_info.load_error() {
40 terminal::warn!("The manifest has errors not related to the Wasm component build. Error details:\n{e:#}");
43 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 build_result?;
56
57 let Some(manifest) = build_info.manifest() else {
58 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
90pub 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 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
146fn 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
211fn 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 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
281fn 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 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 if result.len() == components.len() {
313 (result, false)
314 } else {
315 (components, true)
316 }
317}
318
319pub enum TargetChecking {
321 Check,
323 Skip,
325}
326
327impl TargetChecking {
328 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 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 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}