1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result, anyhow, bail, ensure};
4use futures::{StreamExt, future::try_join_all};
5use reqwest::Url;
6use spin_common::{paths::parent_dir, sloth, ui::quoted_path};
7use spin_expressions::Resolver;
8use spin_locked_app::{
9 locked::{
10 self, ContentPath, ContentRef, LockedApp, LockedComponent, LockedComponentDependency,
11 LockedComponentSource, LockedTrigger,
12 },
13 values::{ValuesMap, ValuesMapBuilder},
14};
15use spin_manifest::schema::v2::{self, AppManifest, KebabId, WasiFilesMount};
16use spin_outbound_networking_config::allowed_hosts::{AllowedHostConfig, AllowedHostsConfig};
17use spin_serde::DependencyName;
18use std::collections::BTreeMap;
19use tokio::{io::AsyncWriteExt, sync::Semaphore};
20
21use crate::{FilesMountStrategy, cache::Cache};
22
23mod trigger_components;
24
25#[derive(Debug)]
26pub struct LocalLoader {
27 app_root: PathBuf,
28 files_mount_strategy: FilesMountStrategy,
29 file_loading_permits: std::sync::Arc<Semaphore>,
30 wasm_loader: WasmLoader,
31 profile: Option<String>,
32}
33
34impl LocalLoader {
35 pub async fn new(
36 app_root: &Path,
37 files_mount_strategy: FilesMountStrategy,
38 profile: Option<&str>,
39 cache_root: Option<PathBuf>,
40 ) -> Result<Self> {
41 let app_root = safe_canonicalize(app_root)
42 .with_context(|| format!("Invalid manifest dir `{}`", app_root.display()))?;
43 let file_loading_permits =
44 std::sync::Arc::new(Semaphore::new(crate::MAX_FILE_LOADING_CONCURRENCY));
45 Ok(Self {
46 app_root: app_root.clone(),
47 files_mount_strategy,
48 file_loading_permits: file_loading_permits.clone(),
50 wasm_loader: WasmLoader::new(app_root, cache_root, Some(file_loading_permits)).await?,
51 profile: profile.map(|s| s.to_owned()),
52 })
53 }
54
55 pub async fn load_file(&self, path: impl AsRef<Path>) -> Result<LockedApp> {
58 let path = path.as_ref();
60 let manifest = spin_manifest::manifest_from_file(path).with_context(|| {
61 format!(
62 "Failed to read Spin app manifest from {}",
63 quoted_path(path)
64 )
65 })?;
66 let mut locked = self
67 .load_manifest(manifest, self.profile())
68 .await
69 .with_context(|| format!("Failed to load Spin app from {}", quoted_path(path)))?;
70
71 locked
73 .metadata
74 .insert("origin".into(), file_url(path)?.into());
75
76 if let Some(profile) = self.profile.as_ref() {
78 locked
79 .metadata
80 .insert("profile".into(), profile.as_str().into());
81 }
82
83 Ok(locked)
84 }
85
86 pub(crate) async fn load_manifest(
88 &self,
89 mut manifest: AppManifest,
90 profile: Option<&str>,
91 ) -> Result<LockedApp> {
92 spin_manifest::normalize::normalize_manifest(&mut manifest, profile)?;
93
94 manifest.validate_dependencies()?;
95
96 let AppManifest {
97 spin_manifest_version: _,
98 application,
99 variables,
100 triggers,
101 components,
102 } = manifest;
103
104 let metadata = locked_metadata(application, triggers.keys().cloned())?;
105
106 let variables = variables
107 .into_iter()
108 .map(|(name, v)| Ok((name.to_string(), locked_variable(v)?)))
109 .collect::<Result<BTreeMap<_, _>>>()?;
110 let resolver = Resolver::new(variables.clone())?;
111
112 let triggers = try_join_all(triggers.into_iter().flat_map(|(trigger_type, configs)| {
113 configs
114 .into_iter()
115 .map(|trigger| {
116 let trigger_type_2 = trigger_type.clone();
117 let id = trigger.id.clone();
118 async move {
119 self.load_trigger(&id, &trigger_type_2, trigger)
120 .await
121 .with_context(|| {
122 format!("Failed to load `{}` trigger `{}`", trigger_type_2, id)
123 })
124 }
125 })
126 .collect::<Vec<_>>()
127 }))
128 .await?;
129
130 let sloth_guard = warn_if_component_load_slothful();
131
132 let components = try_join_all(components.into_iter().map(|(id, c)| {
134 let resolver = &resolver;
135 async move {
136 self.load_component(&id, c, resolver)
137 .await
138 .with_context(|| format!("Failed to load component `{id}`"))
139 }
140 }))
141 .await?;
142
143 let host_requirements = spin_locked_app::values::ValuesMap::new();
144
145 let mut must_understand = vec![];
146 if !host_requirements.is_empty() {
147 must_understand.push(spin_locked_app::locked::MustUnderstand::HostRequirements);
148 }
149 if components.iter().any(|c| !c.host_requirements.is_empty()) {
150 must_understand
151 .push(spin_locked_app::locked::MustUnderstand::ComponentHostRequirements);
152 }
153
154 drop(sloth_guard);
155
156 let locked = LockedApp {
157 spin_lock_version: Default::default(),
158 metadata,
159 must_understand,
160 host_requirements,
161 variables,
162 triggers,
163 components,
164 };
165
166 let locked = trigger_components::reassign_trigger_deps(locked);
167
168 Ok(locked)
169 }
170
171 async fn load_trigger(
172 &self,
173 id: &str,
174 trigger_type: &str,
175 trigger: v2::Trigger,
176 ) -> Result<LockedTrigger> {
177 fn reference_id(spec: v2::ComponentSpec) -> toml::Value {
178 let v2::ComponentSpec::Reference(id) = spec else {
179 unreachable!("should have already been normalized");
180 };
181 id.as_ref().into()
182 }
183
184 let mut config = trigger.config;
185
186 if let Some(id) = trigger.component.map(reference_id) {
187 config.insert("component".into(), id);
188 }
189
190 let locked_deps = if !trigger.dependencies.is_empty() {
191 self.load_trigger_dependencies(id, trigger.dependencies.iter())
192 .await?
193 } else {
194 BTreeMap::new()
195 };
196
197 Ok(LockedTrigger {
198 id: trigger.id,
199 trigger_type: trigger_type.to_string(),
200 trigger_config: config.try_into()?,
201 trigger_dependencies: locked_deps,
202 })
203 }
204
205 async fn load_component(
207 &self,
208 id: &KebabId,
209 component: v2::Component,
210 resolver: &Resolver,
211 ) -> Result<LockedComponent> {
212 let allowed_outbound_hosts = component
213 .normalized_allowed_outbound_hosts()
214 .context("`allowed_http_hosts` is malformed")?;
215 AllowedHostsConfig::validate(&allowed_outbound_hosts, resolver)
216 .context("`allowed_outbound_hosts` is malformed")?;
217
218 let component_requires_service_chaining = requires_service_chaining(&component);
219
220 let metadata = ValuesMapBuilder::new()
221 .string("description", component.description)
222 .string_array("allowed_outbound_hosts", allowed_outbound_hosts)
223 .string_array("key_value_stores", component.key_value_stores)
224 .string_array("databases", component.sqlite_databases)
225 .string_array("ai_models", component.ai_models)
226 .serializable("build", component.build)?
227 .take();
228
229 let source = self
230 .load_component_source(id, component.source.clone())
231 .await
232 .with_context(|| format!("Failed to load Wasm source {}", component.source))?;
233
234 let dependencies = self
235 .load_component_dependencies(id, &component.dependencies)
236 .await?;
237
238 let env = component.environment.into_iter().collect();
239
240 let files = if component.files.is_empty() {
241 vec![]
242 } else {
243 match &self.files_mount_strategy {
244 FilesMountStrategy::Copy(files_mount_root) => {
245 let component_mount_root = files_mount_root.join(id.as_ref());
246 try_join_all(component.files.iter().map(|f| {
248 self.copy_file_mounts(f, &component_mount_root, &component.exclude_files)
249 }))
250 .await?;
251
252 vec![ContentPath {
254 content: file_content_ref(component_mount_root)?,
255 path: "/".into(),
256 }]
257 }
258 FilesMountStrategy::Direct => {
259 ensure!(
260 component.exclude_files.is_empty(),
261 "Cannot load a component with `exclude_files` using --direct-mounts"
262 );
263 let mut files = vec![];
264 for mount in &component.files {
265 files.push(self.resolve_direct_mount(mount).await?);
267 }
268 files
269 }
270 }
271 };
272
273 let config = component
274 .variables
275 .into_iter()
276 .map(|(k, v)| (k.into(), v))
277 .collect();
278
279 let mut host_requirements = ValuesMapBuilder::new();
280 if component_requires_service_chaining {
281 host_requirements.string(
282 spin_locked_app::locked::SERVICE_CHAINING_KEY,
283 spin_locked_app::locked::HOST_REQ_REQUIRED,
284 );
285 }
286 let host_requirements = host_requirements.build();
287
288 Ok(LockedComponent {
289 id: id.as_ref().into(),
290 metadata,
291 source,
292 env,
293 files,
294 config,
295 dependencies,
296 trigger_dependencies: Default::default(), host_requirements,
298 })
299 }
300
301 async fn load_component_dependencies(
302 &self,
303 id: &KebabId,
304 dependencies: &v2::ComponentDependencies,
305 ) -> Result<BTreeMap<DependencyName, LockedComponentDependency>> {
306 Ok(try_join_all(dependencies.inner.iter().map(
307 |(dependency_name, dependency)| async move {
308 let locked_dependency = self
309 .load_component_dependency(dependency_name.clone(), dependency.clone())
310 .await
311 .with_context(|| {
312 format!(
313 "Failed to load component dependency `{dependency_name}` for `{id}`"
314 )
315 })?;
316
317 anyhow::Ok((dependency_name.clone(), locked_dependency))
318 },
319 ))
320 .await?
321 .into_iter()
322 .collect())
323 }
324
325 async fn load_component_dependency(
326 &self,
327 dependency_name: DependencyName,
328 dependency: v2::ComponentDependency,
329 ) -> Result<LockedComponentDependency> {
330 self.wasm_loader
331 .load_component_dependency(&dependency_name, &dependency)
332 .await
333 }
334
335 async fn load_trigger_dependencies(
336 &self,
337 id: &str,
338 dependencies: impl Iterator<Item = (&String, &v2::TriggerDependencies)>,
339 ) -> Result<BTreeMap<String, Vec<LockedComponentDependency>>> {
340 let mut loaded = BTreeMap::new();
341
342 for (role, deps) in dependencies {
343 let locked_deps = self.load_trigger_dependencies_vec(id, &deps.0).await?;
344 loaded.insert(role.clone(), locked_deps);
345 }
346
347 Ok(loaded)
348 }
349
350 async fn load_trigger_dependencies_vec(
351 &self,
352 id: &str,
353 dependencies: &[v2::TriggerDependency],
354 ) -> Result<Vec<LockedComponentDependency>> {
355 Ok(
356 try_join_all(dependencies.iter().map(|dependency| async move {
357 let locked_dependency = self
358 .load_trigger_dependency(dependency.clone())
359 .await
360 .with_context(|| {
361 format!("Failed to load trigger dependency `{dependency:?}` for `{id}`")
362 })?;
363
364 anyhow::Ok(locked_dependency)
365 }))
366 .await?
367 .into_iter()
368 .collect(),
369 )
370 }
371
372 async fn load_trigger_dependency(
373 &self,
374 dependency: v2::TriggerDependency,
375 ) -> Result<LockedComponentDependency> {
376 self.wasm_loader.load_trigger_dependency(&dependency).await
377 }
378
379 async fn load_component_source(
382 &self,
383 component_id: &KebabId,
384 source: v2::ComponentSource,
385 ) -> Result<LockedComponentSource> {
386 let path = self
387 .wasm_loader
388 .load_component_source(component_id.as_ref(), &source)
389 .await?;
390 Ok(LockedComponentSource {
391 content_type: "application/wasm".into(),
392 content: file_content_ref(path)?,
393 })
394 }
395
396 async fn copy_file_mounts(
398 &self,
399 mount: &WasiFilesMount,
400 dest_root: &Path,
401 exclude_files: &[String],
402 ) -> Result<()> {
403 match mount {
404 WasiFilesMount::Pattern(pattern) => {
405 self.copy_glob_or_path(pattern, dest_root, exclude_files)
406 .await
407 }
408 WasiFilesMount::Placement {
409 source,
410 destination,
411 } => {
412 let src = Path::new(source);
413 let dest = dest_root.join(destination.trim_start_matches('/'));
414 self.copy_file_or_directory(src, &dest, destination, exclude_files)
415 .await
416 }
417 }
418 }
419
420 async fn copy_glob_or_path(
422 &self,
423 glob_or_path: &str,
424 dest_root: &Path,
425 exclude_files: &[String],
426 ) -> Result<()> {
427 if glob_or_path == ".." || glob_or_path.ends_with("/..") {
428 bail!(
429 "A file pattern can't end in a parent directory path (..)\nIf you want to include a directory, use source-destination form, or a glob pattern ending in **/*.\nLearn more: https://spinframework.dev/writing-apps#including-files-with-components"
430 );
431 }
432 if glob_or_path == "." || glob_or_path.ends_with("/.") {
433 bail!(
434 "A file pattern can't end in a current directory path (.)\nIf you want to include a directory, use source-destination form, or a glob pattern ending in **/*.\nLearn more: https://spinframework.dev/writing-apps#including-files-with-components"
435 );
436 }
437
438 if glob_or_path == "*" {
439 tracing::warn!(
440 alert_in_dev = true,
441 "A component is including the entire application directory as asset files. This is unlikely to be what you want.\nIf this is what you want, use the pattern \"./*\" to avoid this warning.\nLearn more: https://spinframework.dev/writing-apps#including-files-with-components\n"
442 );
443 }
444 if glob_or_path == "**/*" {
445 tracing::warn!(
446 alert_in_dev = true,
447 "A component is including the entire application directory tree as asset files. This is unlikely to be what you want.\nIf this is what you want, use the pattern \"./**/*\" to avoid this warning.\nLearn more: https://spinframework.dev/writing-apps#including-files-with-components\n"
448 );
449 }
450
451 let path = self.app_root.join(glob_or_path);
452 if path.exists() {
453 let dest = dest_root.join(glob_or_path);
454 if path.is_dir() {
455 let pattern = path.join("**/*");
457 self.copy_glob(&pattern, &self.app_root, &dest, exclude_files)
458 .await?;
459 } else {
460 self.copy_single_file(&path, &dest, glob_or_path).await?;
462 }
463 } else if looks_like_glob_pattern(glob_or_path) {
464 self.copy_glob(&path, &self.app_root, dest_root, exclude_files)
466 .await?;
467 } else {
468 bail!("{glob_or_path:?} does not exist and doesn't appear to be a glob pattern");
469 }
470 Ok(())
471 }
472
473 async fn copy_file_or_directory(
475 &self,
476 src: &Path,
477 dest: &Path,
478 guest_dest: &str,
479 exclude_files: &[String],
480 ) -> Result<()> {
481 let src_path = self.app_root.join(src);
482 let meta = crate::fs::metadata(&src_path)
483 .await
484 .map_err(|e| explain_file_mount_source_error(e, src))?;
485 if meta.is_dir() {
486 let pattern = src_path.join("**/*");
488 self.copy_glob(&pattern, &src_path, dest, exclude_files)
489 .await?;
490 } else {
491 self.copy_single_file(&src_path, dest, guest_dest).await?;
493 }
494 Ok(())
495 }
496
497 async fn copy_glob(
499 &self,
500 pattern: &Path,
501 src_prefix: &Path,
502 dest_root: &Path,
503 exclude_files: &[String],
504 ) -> Result<()> {
505 let pattern = pattern
506 .to_str()
507 .with_context(|| format!("invalid (non-utf8) file pattern {pattern:?}"))?;
508
509 let paths = glob::glob(pattern)
510 .with_context(|| format!("Failed to resolve glob pattern {pattern:?}"))?;
511
512 let exclude_patterns = exclude_files
513 .iter()
514 .map(|pattern| {
515 glob::Pattern::new(pattern)
516 .with_context(|| format!("Invalid exclude_files glob pattern {pattern:?}"))
517 })
518 .collect::<Result<Vec<_>>>()?;
519
520 crate::fs::create_dir_all(dest_root)
521 .await
522 .with_context(|| {
523 format!(
524 "Failed to create parent directory {}",
525 quoted_path(&dest_root)
526 )
527 })?;
528
529 for path_res in paths {
530 let src = path_res?;
531 if !src.is_file() {
532 continue;
533 }
534
535 let Ok(app_root_path) = src.strip_prefix(&self.app_root) else {
536 bail!(
537 "{pattern} cannot be mapped because it is outside the application directory. Files must be within the application directory."
538 );
539 };
540
541 if exclude_patterns
542 .iter()
543 .any(|pattern| pattern.matches_path(app_root_path))
544 {
545 tracing::debug!(
546 "File {app_root_path:?} excluded by exclude_files {exclude_files:?}"
547 );
548 continue;
549 }
550
551 let relative_path = src.strip_prefix(src_prefix)?;
552 let dest = dest_root.join(relative_path);
553 self.copy_single_file(&src, &dest, &relative_path.to_string_lossy())
554 .await?;
555 }
556 Ok(())
557 }
558
559 async fn copy_single_file(&self, src: &Path, dest: &Path, guest_dest: &str) -> Result<()> {
561 src.strip_prefix(&self.app_root)?;
563 if let FilesMountStrategy::Copy(files_mount_root) = &self.files_mount_strategy {
565 dest.strip_prefix(files_mount_root)?;
566 } else {
567 unreachable!();
568 }
569
570 let _loading_permit = self.file_loading_permits.acquire().await?;
571 let dest_parent = parent_dir(dest)?;
572 crate::fs::create_dir_all(&dest_parent)
573 .await
574 .with_context(|| {
575 format!(
576 "Failed to create parent directory {}",
577 quoted_path(&dest_parent)
578 )
579 })?;
580 crate::fs::copy(src, dest)
581 .await
582 .or_else(|e| Self::failed_to_copy_single_file_error(src, dest, guest_dest, e))?;
583 tracing::debug!("Copied {src:?} to {dest:?}");
584 Ok(())
585 }
586
587 fn failed_to_copy_single_file_error<T>(
588 src: &Path,
589 dest: &Path,
590 guest_dest: &str,
591 e: anyhow::Error,
592 ) -> anyhow::Result<T> {
593 let src_text = quoted_path(src);
594 let dest_text = quoted_path(dest);
595 let base_msg = format!("Failed to copy {src_text} to working path {dest_text}");
596
597 if let Some(io_error) = e.downcast_ref::<std::io::Error>()
598 && (Self::is_directory_like(guest_dest)
599 || io_error.kind() == std::io::ErrorKind::NotFound)
600 {
601 return Err(anyhow::anyhow!(
602 r#""{guest_dest}" is not a valid destination file name"#
603 ))
604 .context(base_msg);
605 }
606
607 Err(e).with_context(|| format!("{base_msg} (for destination path \"{guest_dest}\")"))
608 }
609
610 fn is_directory_like(guest_path: &str) -> bool {
613 guest_path.ends_with('/') || guest_path.ends_with('.') || guest_path.ends_with("..")
614 }
615
616 async fn resolve_direct_mount(&self, mount: &WasiFilesMount) -> Result<ContentPath> {
619 let (src, dest) = match mount {
620 WasiFilesMount::Pattern(pattern) => (pattern, pattern),
621 WasiFilesMount::Placement {
622 source,
623 destination,
624 } => (source, destination),
625 };
626 let path = self.app_root.join(src);
627 if !path.is_dir() {
628 bail!(
629 "Only directory mounts are supported with `--direct-mounts`; {src:?} is not a directory."
630 );
631 }
632 Ok(ContentPath {
633 content: file_content_ref(src)?,
634 path: dest.into(),
635 })
636 }
637
638 fn profile(&self) -> Option<&str> {
639 self.profile.as_deref()
640 }
641}
642
643fn explain_file_mount_source_error(e: anyhow::Error, src: &Path) -> anyhow::Error {
644 if let Some(io_error) = e.downcast_ref::<std::io::Error>()
645 && io_error.kind() == std::io::ErrorKind::NotFound
646 {
647 return anyhow::anyhow!("File or directory {} does not exist", quoted_path(src));
648 }
649 e.context(format!("invalid file mount source {}", quoted_path(src)))
650}
651
652#[cfg(feature = "async-io")]
653async fn verified_download(
654 url: &str,
655 digest: &str,
656 dest: &Path,
657 convention: crate::http::DestinationConvention,
658) -> Result<()> {
659 crate::http::verified_download(url, digest, dest, convention)
660 .await
661 .with_context(|| format!("Error fetching source URL {url:?}"))
662}
663
664#[cfg(not(feature = "async-io"))]
665async fn verified_download(
666 _url: &str,
667 _digest: &str,
668 _dest: &Path,
669 _convention: crate::http::DestinationConvention,
670) -> Result<()> {
671 panic!("async-io feature is required for downloading Wasm sources")
672}
673
674fn safe_canonicalize(path: &Path) -> std::io::Result<PathBuf> {
675 use path_absolutize::Absolutize;
676 Ok(path.absolutize()?.into_owned())
677}
678
679fn locked_metadata(
680 details: v2::AppDetails,
681 trigger_types: impl Iterator<Item = String>,
682) -> Result<ValuesMap> {
683 let mut builder = ValuesMapBuilder::new();
684 builder
685 .string("name", details.name)
686 .string("version", details.version)
687 .string("description", details.description)
688 .string_array("authors", details.authors)
689 .serializable("triggers", &details.trigger_global_configs)?;
690
691 let types = trigger_types.collect::<Vec<_>>();
694 if types.len() == 1 {
695 let trigger_type = types.into_iter().next().unwrap();
696 let mut single_trigger = details
697 .trigger_global_configs
698 .get(&trigger_type)
699 .cloned()
700 .unwrap_or_default();
701 single_trigger.insert("type".into(), trigger_type.into());
702 builder.serializable("trigger", single_trigger).unwrap();
703 }
704
705 Ok(builder.build())
706}
707
708fn locked_variable(variable: v2::Variable) -> Result<locked::Variable> {
709 ensure!(
710 variable.required ^ variable.default.is_some(),
711 "must be `required` OR have a `default`"
712 );
713 Ok(locked::Variable {
714 description: variable.description,
715 default: variable.default.clone(),
716 secret: variable.secret,
717 })
718}
719
720#[derive(Debug)]
721pub struct WasmLoader {
723 app_root: PathBuf,
724 cache: Cache,
725 file_loading_permits: std::sync::Arc<Semaphore>,
726}
727
728impl WasmLoader {
729 pub async fn new(
731 app_root: PathBuf,
732 cache_root: Option<PathBuf>,
733 file_loading_permits: Option<std::sync::Arc<Semaphore>>,
734 ) -> Result<Self> {
735 let file_loading_permits = file_loading_permits.unwrap_or_else(|| {
736 std::sync::Arc::new(Semaphore::new(crate::MAX_FILE_LOADING_CONCURRENCY))
737 });
738 Ok(Self {
739 app_root,
740 cache: Cache::new(cache_root).await?,
741 file_loading_permits,
742 })
743 }
744
745 pub async fn load_component_source(
748 &self,
749 component_id: &str,
750 source: &v2::ComponentSource,
751 ) -> Result<PathBuf> {
752 let content = match source {
753 v2::ComponentSource::Local(path) => self.app_root.join(path),
754 v2::ComponentSource::Remote { url, digest } => {
755 self.load_http_source(url, digest).await?
756 }
757 v2::ComponentSource::Registry {
758 registry,
759 package,
760 version,
761 } => {
762 let version = semver::Version::parse(version).with_context(|| format!("Component {component_id} specifies an invalid semantic version ({version:?}) for its package version"))?;
763 let version_req = format!("={version}").parse().expect("version");
764
765 self.load_registry_source(registry.as_ref(), package, &version_req)
766 .await?
767 }
768 };
769 Ok(content)
770 }
771
772 async fn load_http_source(&self, url: &str, digest: &str) -> Result<PathBuf> {
775 ensure!(
776 digest.starts_with("sha256:"),
777 "invalid `digest` {digest:?}; must start with 'sha256:'"
778 );
779 let path = if let Ok(cached_path) = self.cache.wasm_file(digest) {
780 cached_path
781 } else {
782 let _loading_permit = self.file_loading_permits.acquire().await?;
783
784 self.cache.ensure_dirs().await?;
785 let dest = self.cache.wasm_path(digest);
786 verified_download(
787 url,
788 digest,
789 &dest,
790 crate::http::DestinationConvention::ContentIndexed,
791 )
792 .await
793 .with_context(|| format!("Error fetching source URL {url:?}"))?;
794 dest
795 };
796 Ok(path)
797 }
798
799 async fn load_registry_source(
800 &self,
801 registry: Option<&wasm_pkg_client::Registry>,
802 package: &wasm_pkg_client::PackageRef,
803 version: &semver::VersionReq,
804 ) -> Result<PathBuf> {
805 let mut client_config = wasm_pkg_client::Config::global_defaults().await?;
806
807 if let Some(registry) = registry.cloned() {
808 let mapping = wasm_pkg_client::RegistryMapping::Registry(registry);
809 client_config.set_package_registry_override(package.clone(), mapping);
810 }
811 let pkg_loader = wasm_pkg_client::Client::new(client_config);
812
813 let mut releases = pkg_loader.list_all_versions(package).await.map_err(|e| {
814 if matches!(e, wasm_pkg_client::Error::NoRegistryForNamespace(_)) && registry.is_none() {
815 anyhow!("No default registry specified for wasm-pkg-loader. Create a default config, or set `registry` for package {package:?}")
816 } else {
817 e.into()
818 }
819 })?;
820
821 releases.sort();
822
823 let release_version = releases
824 .iter()
825 .rev()
826 .find(|release| version.matches(&release.version) && !release.yanked)
827 .with_context(|| format!("No matching version found for {package} {version}",))?;
828
829 let release = pkg_loader
830 .get_release(package, &release_version.version)
831 .await?;
832
833 let digest = match &release.content_digest {
834 wasm_pkg_client::ContentDigest::Sha256 { hex } => format!("sha256:{hex}"),
835 };
836
837 let path = if let Ok(cached_path) = self.cache.wasm_file(&digest) {
838 cached_path
839 } else {
840 let mut stm = pkg_loader.stream_content(package, &release).await?;
841
842 self.cache.ensure_dirs().await?;
843 let dest = self.cache.wasm_path(&digest);
844
845 let mut file = tokio::fs::File::create(&dest).await?;
846 while let Some(block) = stm.next().await {
847 let bytes = block.context("Failed to get content from registry")?;
848 file.write_all(&bytes)
849 .await
850 .context("Failed to save registry content to cache")?;
851 }
852
853 dest
854 };
855
856 Ok(path)
857 }
858
859 pub async fn load_component_dependency(
861 &self,
862 dependency_name: &DependencyName,
863 dependency: &v2::ComponentDependency,
864 ) -> Result<locked::LockedComponentDependency> {
865 let inherit = locked_inherit(dependency);
866
867 let (content, export) = self
868 .load_dependency_content(dependency_name, dependency)
869 .await?;
870
871 Ok(locked::LockedComponentDependency {
872 source: locked::LockedComponentSource {
873 content_type: "application/wasm".into(),
874 content: file_content_ref(content)?,
875 },
876 export,
877 inherit,
878 })
879 }
880
881 pub async fn load_trigger_dependency(
883 &self,
884 dependency: &v2::TriggerDependency,
885 ) -> Result<locked::LockedComponentDependency> {
886 let as_component_dep = match dependency.clone() {
887 v2::TriggerDependency::Package {
888 version,
889 registry,
890 package,
891 inherit_configuration,
892 } => v2::ComponentDependency::Package {
893 version,
894 registry,
895 package: Some(package),
896 export: None,
897 inherit_configuration,
898 },
899 v2::TriggerDependency::Local {
900 path,
901 inherit_configuration,
902 } => v2::ComponentDependency::Local {
903 path,
904 export: None,
905 inherit_configuration,
906 },
907 v2::TriggerDependency::HTTP {
908 url,
909 digest,
910 inherit_configuration,
911 } => v2::ComponentDependency::HTTP {
912 url,
913 digest,
914 export: None,
915 inherit_configuration,
916 },
917 v2::TriggerDependency::AppComponent {
918 component,
919 inherit_configuration,
920 } => v2::ComponentDependency::AppComponent {
921 component,
922 export: None,
923 inherit_configuration,
924 },
925 };
926
927 let inherit = locked_inherit(&as_component_dep);
928
929 let fake_dep_name =
930 DependencyName::Plain(KebabId::try_from("to-do-fix-fix-fix".to_string()).unwrap());
931
932 let (content, export) = self
933 .load_dependency_content(&fake_dep_name, &as_component_dep)
934 .await?;
935
936 Ok(locked::LockedComponentDependency {
937 source: locked::LockedComponentSource {
938 content_type: "application/wasm".into(),
939 content: file_content_ref(content)?,
940 },
941 export,
942 inherit,
943 })
944 }
945
946 pub async fn load_dependency_content(
948 &self,
949 dependency_name: &DependencyName,
950 dependency: &v2::ComponentDependency,
951 ) -> Result<(PathBuf, Option<String>)> {
952 match dependency.clone() {
953 v2::ComponentDependency::Version(version) => {
954 let version = semver::VersionReq::parse(&version).with_context(|| format!("Component dependency {dependency_name:?} specifies an invalid semantic version requirement ({version:?}) for its package version"))?;
955
956 let package = dependency_name.package().unwrap();
960
961 let content = self.load_registry_source(None, package, &version).await?;
962 Ok((content, None))
963 }
964 v2::ComponentDependency::Package {
965 version,
966 registry,
967 package,
968 export,
969 ..
970 } => {
971 let version = semver::VersionReq::parse(&version).with_context(|| format!("Component dependency {dependency_name:?} specifies an invalid semantic version requirement ({version:?}) for its package version"))?;
972
973 let package = match package {
974 Some(package) => {
975 package.parse().with_context(|| format!("Component dependency {dependency_name:?} specifies an invalid package name ({package:?})"))?
976 }
977 None => {
978 dependency_name
982 .package()
983 .cloned()
984 .unwrap()
985 }
986 };
987
988 let registry = match registry {
989 Some(registry) => {
990 registry
991 .parse()
992 .map(Some)
993 .with_context(|| format!("Component dependency {dependency_name:?} specifies an invalid registry name ({registry:?})"))?
994 }
995 None => None,
996 };
997
998 let content = self
999 .load_registry_source(registry.as_ref(), &package, &version)
1000 .await?;
1001 Ok((content, export))
1002 }
1003 v2::ComponentDependency::Local { path, export, .. } => {
1004 let content = self.app_root.join(path);
1005 Ok((content, export))
1006 }
1007 v2::ComponentDependency::HTTP {
1008 url,
1009 digest,
1010 export,
1011 ..
1012 } => {
1013 let content = self.load_http_source(&url, &digest).await?;
1014 Ok((content, export))
1015 }
1016 v2::ComponentDependency::AppComponent { .. } => {
1017 panic!("Internal error: component ID dependency was not resolved to a source");
1018 }
1019 }
1020 }
1021}
1022
1023fn locked_inherit(dependency: &v2::ComponentDependency) -> locked::InheritConfiguration {
1024 match dependency.inherit_configuration() {
1025 Some(v2::InheritConfiguration::All(true)) => locked::InheritConfiguration::All,
1026 Some(v2::InheritConfiguration::Some(keys)) => {
1027 locked::InheritConfiguration::Some(keys.clone())
1028 }
1029 Some(v2::InheritConfiguration::All(false)) | None => {
1030 locked::InheritConfiguration::Some(vec![])
1031 }
1032 }
1033}
1034
1035fn looks_like_glob_pattern(s: impl AsRef<str>) -> bool {
1036 let s = s.as_ref();
1037 glob::Pattern::escape(s) != s
1038}
1039
1040fn file_content_ref(path: impl AsRef<Path>) -> Result<ContentRef> {
1041 Ok(ContentRef {
1042 source: Some(file_url(path)?),
1043 ..Default::default()
1044 })
1045}
1046
1047fn file_url(path: impl AsRef<Path>) -> Result<String> {
1048 let path = path.as_ref();
1049 let abs_path = safe_canonicalize(path)
1050 .with_context(|| format!("Couldn't resolve `{}`", path.display()))?;
1051 Ok(Url::from_file_path(abs_path).unwrap().to_string())
1052}
1053
1054pub fn requires_service_chaining(component: &spin_manifest::schema::v2::Component) -> bool {
1057 component
1058 .normalized_allowed_outbound_hosts()
1059 .unwrap_or_default()
1060 .iter()
1061 .any(|h| is_chaining_host(h))
1062}
1063
1064fn is_chaining_host(pattern: &str) -> bool {
1065 AllowedHostConfig::parse(pattern).is_ok_and(|config| config.is_for_service_chaining())
1066}
1067
1068const SLOTH_WARNING_DELAY_MILLIS: u64 = 1250;
1069
1070fn warn_if_component_load_slothful() -> sloth::SlothGuard {
1071 let message = "Loading Wasm components is taking a few seconds...";
1072 sloth::warn_if_slothful(SLOTH_WARNING_DELAY_MILLIS, format!("{message}\n"))
1073}
1074
1075#[cfg(test)]
1076mod test {
1077 use super::*;
1078
1079 async fn load_test_case(
1080 testcase_dir: &str,
1081 manifest_file: &str,
1082 ) -> anyhow::Result<(tempfile::TempDir, LockedApp)> {
1083 let app_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1084 .join("tests")
1085 .join(testcase_dir);
1086 let wd = tempfile::tempdir()?;
1087 let loader = LocalLoader::new(
1088 &app_root,
1089 FilesMountStrategy::Copy(wd.path().to_owned()),
1090 None,
1091 None,
1092 )
1093 .await?;
1094 let locked_app = loader.load_file(app_root.join(manifest_file)).await;
1095 locked_app.map(|locked| (wd, locked))
1096 }
1097
1098 #[tokio::test]
1099 async fn bad_destination_filename_is_explained() -> anyhow::Result<()> {
1100 let err = load_test_case("file-errors", "bad.toml")
1101 .await
1102 .expect_err("loader should not have succeeded");
1103 let err_ctx = format!("{err:#}");
1104 assert!(
1105 err_ctx.contains(r#""/" is not a valid destination file name"#),
1106 "expected error to show destination file name but got {err_ctx}",
1107 );
1108 Ok(())
1109 }
1110
1111 fn trigger_by_route<'a>(locked: &'a LockedApp, route: &str) -> &'a LockedTrigger {
1112 fn route_of(trigger: &LockedTrigger) -> &str {
1113 trigger
1114 .trigger_config
1115 .get("route")
1116 .and_then(|v| v.as_str())
1117 .unwrap()
1118 }
1119 locked
1120 .triggers
1121 .iter()
1122 .find(|t| route_of(t) == route)
1123 .unwrap()
1124 }
1125
1126 fn component_for_route<'a>(locked: &'a LockedApp, route: &str) -> &'a LockedComponent {
1127 let component_id = component_id(trigger_by_route(locked, route));
1128 locked
1129 .components
1130 .iter()
1131 .find(|c| c.id == component_id)
1132 .unwrap()
1133 }
1134
1135 fn component_id(trigger: &LockedTrigger) -> &str {
1136 trigger
1137 .trigger_config
1138 .get("component")
1139 .and_then(|v| v.as_str())
1140 .unwrap()
1141 }
1142
1143 fn component_trigger_deps_for_route<'a>(
1144 locked: &'a LockedApp,
1145 route: &str,
1146 key: &str,
1147 ) -> &'a Vec<LockedComponentDependency> {
1148 let component = component_for_route(locked, route);
1149 component
1150 .trigger_dependencies
1151 .get(key)
1152 .expect("should have had trigger deps for key")
1153 }
1154
1155 #[tokio::test]
1156 async fn unenriched_lockfile_is_unchanged() {
1157 let (_wd, locked_app) = load_test_case("extra-components", "vanilla.toml")
1158 .await
1159 .unwrap();
1160 assert_eq!(3, locked_app.triggers.len());
1161 assert_eq!(2, locked_app.components.len());
1162 }
1163
1164 #[tokio::test]
1165 async fn enriched_lockfile_only_one_trigger_per_component_no_changes() {
1166 let (_wd, locked_app) = load_test_case("extra-components", "inoffensive.toml")
1167 .await
1168 .unwrap();
1169 assert_eq!(2, locked_app.triggers.len());
1170 assert_eq!("a", component_id(trigger_by_route(&locked_app, "/a")));
1171 assert_eq!("b", component_id(trigger_by_route(&locked_app, "/b")));
1172 assert_eq!(5, locked_app.components.len());
1173 }
1174
1175 #[tokio::test]
1176 async fn enriched_lockfile_multiple_enriched_triggers_per_component_get_split() {
1177 let (_wd, locked_app) = load_test_case("extra-components", "three-to-one.toml")
1178 .await
1179 .unwrap();
1180 assert_eq!(4, locked_app.triggers.len());
1181 assert_ne!("a", component_id(trigger_by_route(&locked_app, "/a1")));
1183 assert!(
1184 component_for_route(&locked_app, "/a1")
1185 .source
1186 .content
1187 .source
1188 .as_ref()
1189 .unwrap()
1190 .ends_with("/a.dummy.wasm.txt")
1191 );
1192 assert_ne!("a", component_id(trigger_by_route(&locked_app, "/a2")));
1193 assert!(
1194 component_for_route(&locked_app, "/a3")
1195 .source
1196 .content
1197 .source
1198 .as_ref()
1199 .unwrap()
1200 .ends_with("/a.dummy.wasm.txt")
1201 );
1202 assert_ne!("a", component_id(trigger_by_route(&locked_app, "/a3")));
1203 assert!(
1204 component_for_route(&locked_app, "/a3")
1205 .source
1206 .content
1207 .source
1208 .as_ref()
1209 .unwrap()
1210 .ends_with("/a.dummy.wasm.txt")
1211 );
1212 assert_eq!("b", component_id(trigger_by_route(&locked_app, "/b")));
1214 assert_eq!(8, locked_app.components.len());
1216 }
1217
1218 fn content_ref_to_file_name(content_ref: &ContentRef) -> String {
1221 let url = content_ref.source.as_ref().unwrap();
1222 let url = Url::parse(url).unwrap();
1223 let file_path = PathBuf::from(url.path());
1224 file_path.file_name().unwrap().to_string_lossy().to_string()
1225 }
1226
1227 #[tokio::test]
1228 async fn enriched_lockfile_captures_composition_graph_in_split_component() {
1229 let (_wd, locked_app) = load_test_case("extra-components", "three-to-one.toml")
1230 .await
1231 .unwrap();
1232 assert_eq!(4, locked_app.triggers.len());
1233
1234 let a1_mw = component_trigger_deps_for_route(&locked_app, "/a1", "middleware");
1235 assert_eq!(2, a1_mw.len());
1236 assert_eq!(
1237 "a.dummy.wasm.txt",
1238 content_ref_to_file_name(&a1_mw[0].source.content)
1239 );
1240 assert_eq!(
1241 "b.dummy.wasm.txt",
1242 content_ref_to_file_name(&a1_mw[1].source.content)
1243 );
1244
1245 let a2_mw = component_trigger_deps_for_route(&locked_app, "/a2", "middleware");
1246 assert_eq!(2, a2_mw.len());
1247 assert_eq!(
1248 "b.dummy.wasm.txt",
1249 content_ref_to_file_name(&a2_mw[0].source.content)
1250 );
1251 assert_eq!(
1252 "c.dummy.wasm.txt",
1253 content_ref_to_file_name(&a2_mw[1].source.content)
1254 );
1255
1256 let a3_mw = component_trigger_deps_for_route(&locked_app, "/a3", "middleware");
1257 assert_eq!(3, a3_mw.len());
1258 assert_eq!(
1259 "c.dummy.wasm.txt",
1260 content_ref_to_file_name(&a3_mw[0].source.content)
1261 );
1262 assert_eq!(
1263 "b.dummy.wasm.txt",
1264 content_ref_to_file_name(&a3_mw[1].source.content)
1265 );
1266 assert_eq!(
1267 "a.dummy.wasm.txt",
1268 content_ref_to_file_name(&a3_mw[2].source.content)
1269 );
1270
1271 let b_mw = component_trigger_deps_for_route(&locked_app, "/b", "middleware");
1273 assert_eq!(2, b_mw.len());
1274 assert_eq!(
1275 "a.dummy.wasm.txt",
1276 content_ref_to_file_name(&b_mw[0].source.content)
1277 );
1278 assert_eq!(
1279 "c.dummy.wasm.txt",
1280 content_ref_to_file_name(&b_mw[1].source.content)
1281 );
1282 }
1283
1284 #[tokio::test]
1285 async fn deps_moved_off_trigger() {
1286 let (_wd, locked_app) = load_test_case("extra-components", "three-to-one.toml")
1287 .await
1288 .unwrap();
1289 assert_eq!(4, locked_app.triggers.len());
1290
1291 for t in &locked_app.triggers {
1292 assert!(t.trigger_dependencies.is_empty());
1293 }
1294 }
1295}