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