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 std::{
11 collections::HashSet,
12 path::{Path, PathBuf},
13};
14use subprocess::{Exec, Redirection};
15
16use crate::manifest::component_build_configs;
17
18pub async fn build(
20 manifest_file: &Path,
21 component_ids: &[String],
22 target_checks: TargetChecking,
23 cache_root: Option<PathBuf>,
24) -> Result<()> {
25 let build_info = component_build_configs(manifest_file)
26 .await
27 .with_context(|| {
28 format!(
29 "Cannot read manifest file from {}",
30 quoted_path(manifest_file)
31 )
32 })?;
33 let app_dir = parent_dir(manifest_file)?;
34
35 let build_result = build_components(component_ids, build_info.components(), &app_dir);
36
37 if let Some(e) = build_info.load_error() {
39 terminal::warn!("The manifest has errors not related to the Wasm component build. Error details:\n{e:#}");
42 let should_have_checked_targets =
45 target_checks.check() && build_info.has_deployment_targets();
46 if should_have_checked_targets {
47 terminal::warn!(
48 "The manifest error(s) prevented Spin from checking the deployment targets."
49 );
50 }
51 }
52
53 build_result?;
55
56 let Some(manifest) = build_info.manifest() else {
57 return Ok(());
60 };
61
62 if target_checks.check() {
63 let application = spin_environments::ApplicationToValidate::new(
64 manifest.clone(),
65 manifest_file.parent().unwrap(),
66 )
67 .await
68 .context("unable to load application for checking against deployment targets")?;
69 let target_validation = spin_environments::validate_application_against_environment_ids(
70 &application,
71 build_info.deployment_targets(),
72 cache_root.clone(),
73 &app_dir,
74 )
75 .await
76 .context("unable to check if the application is compatible with deployment targets")?;
77
78 if !target_validation.is_ok() {
79 for error in target_validation.errors() {
80 terminal::error!("{error}");
81 }
82 anyhow::bail!("All components built successfully, but one or more was incompatible with one or more of the deployment targets.");
83 }
84 }
85
86 Ok(())
87}
88
89pub async fn build_default(manifest_file: &Path, cache_root: Option<PathBuf>) -> Result<()> {
93 build(manifest_file, &[], TargetChecking::Check, cache_root).await
94}
95
96fn build_components(
97 component_ids: &[String],
98 components: Vec<ComponentBuildInfo>,
99 app_dir: &Path,
100) -> Result<(), anyhow::Error> {
101 let components_to_build = if component_ids.is_empty() {
102 components
103 } else {
104 let all_ids: HashSet<_> = components.iter().map(|c| &c.id).collect();
105 let unknown_component_ids: Vec<_> = component_ids
106 .iter()
107 .filter(|id| !all_ids.contains(id))
108 .map(|s| s.as_str())
109 .collect();
110
111 if !unknown_component_ids.is_empty() {
112 bail!("Unknown component(s) {}", unknown_component_ids.join(", "));
113 }
114
115 components
116 .into_iter()
117 .filter(|c| component_ids.contains(&c.id))
118 .collect()
119 };
120
121 if components_to_build.iter().all(|c| c.build.is_none()) {
122 println!("None of the components have a build command.");
123 println!("For information on specifying a build command, see https://spinframework.dev/build#setting-up-for-spin-build.");
124 return Ok(());
125 }
126
127 components_to_build
128 .into_iter()
129 .map(|c| build_component(c, app_dir))
130 .collect::<Result<Vec<_>, _>>()?;
131
132 terminal::step!("Finished", "building all Spin components");
133 Ok(())
134}
135
136fn build_component(build_info: ComponentBuildInfo, app_dir: &Path) -> Result<()> {
138 match build_info.build {
139 Some(b) => {
140 let command_count = b.commands().len();
141
142 if command_count > 1 {
143 terminal::step!(
144 "Building",
145 "component {} ({} commands)",
146 build_info.id,
147 command_count
148 );
149 }
150
151 for (index, command) in b.commands().enumerate() {
152 if command_count > 1 {
153 terminal::step!(
154 "Running build step",
155 "{}/{} for component {} with '{}'",
156 index + 1,
157 command_count,
158 build_info.id,
159 command
160 );
161 } else {
162 terminal::step!("Building", "component {} with `{}`", build_info.id, command);
163 }
164
165 let workdir = construct_workdir(app_dir, b.workdir.as_ref())?;
166 if b.workdir.is_some() {
167 println!("Working directory: {}", quoted_path(&workdir));
168 }
169
170 let exit_status = Exec::shell(command)
171 .cwd(workdir)
172 .stdout(Redirection::None)
173 .stderr(Redirection::None)
174 .stdin(Redirection::None)
175 .popen()
176 .map_err(|err| {
177 anyhow!(
178 "Cannot spawn build process '{:?}' for component {}: {}",
179 &b.command,
180 build_info.id,
181 err
182 )
183 })?
184 .wait()?;
185
186 if !exit_status.success() {
187 bail!(
188 "Build command for component {} failed with status {:?}",
189 build_info.id,
190 exit_status,
191 );
192 }
193 }
194
195 Ok(())
196 }
197 _ => Ok(()),
198 }
199}
200
201fn construct_workdir(app_dir: &Path, workdir: Option<impl AsRef<Path>>) -> Result<PathBuf> {
203 let mut cwd = app_dir.to_owned();
204
205 if let Some(workdir) = workdir {
206 if workdir.as_ref().has_root() {
210 bail!("The workdir specified in the application file must be relative.");
211 }
212 cwd.push(workdir);
213 }
214
215 Ok(cwd)
216}
217
218pub enum TargetChecking {
220 Check,
222 Skip,
224}
225
226impl TargetChecking {
227 fn check(&self) -> bool {
229 matches!(self, Self::Check)
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 fn test_data_root() -> PathBuf {
238 let crate_dir = env!("CARGO_MANIFEST_DIR");
239 PathBuf::from(crate_dir).join("tests")
240 }
241
242 #[tokio::test]
243 async fn can_load_even_if_trigger_invalid() {
244 let bad_trigger_file = test_data_root().join("bad_trigger.toml");
245 build(&bad_trigger_file, &[], TargetChecking::Skip, None)
246 .await
247 .unwrap();
248 }
249
250 #[tokio::test]
251 async fn succeeds_if_target_env_matches() {
252 let manifest_path = test_data_root().join("good_target_env.toml");
253 build(&manifest_path, &[], TargetChecking::Check, None)
254 .await
255 .unwrap();
256 }
257
258 #[tokio::test]
259 async fn fails_if_target_env_does_not_match() {
260 let manifest_path = test_data_root().join("bad_target_env.toml");
261 let err = build(&manifest_path, &[], TargetChecking::Check, None)
262 .await
263 .expect_err("should have failed")
264 .to_string();
265
266 assert!(
269 err.contains("one or more was incompatible with one or more of the deployment targets")
270 );
271 }
272
273 #[tokio::test]
274 async fn has_meaningful_error_if_target_env_does_not_match() {
275 let manifest_file = test_data_root().join("bad_target_env.toml");
276 let manifest = spin_manifest::manifest_from_file(&manifest_file).unwrap();
277 let application = spin_environments::ApplicationToValidate::new(
278 manifest.clone(),
279 manifest_file.parent().unwrap(),
280 )
281 .await
282 .context("unable to load application for checking against deployment targets")
283 .unwrap();
284
285 let target_validation = spin_environments::validate_application_against_environment_ids(
286 &application,
287 &manifest.application.targets,
288 None,
289 manifest_file.parent().unwrap(),
290 )
291 .await
292 .context("unable to check if the application is compatible with deployment targets")
293 .unwrap();
294
295 assert_eq!(1, target_validation.errors().len());
296
297 let err = target_validation.errors()[0].to_string();
298
299 assert!(err.contains("can't run in environment wasi-minimal"));
300 assert!(err.contains("world wasi:cli/command@0.2.0"));
301 assert!(err.contains("requires imports named"));
302 assert!(err.contains("wasi:cli/stdout"));
303 }
304}