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