Skip to main content

spin_compose/
lib.rs

1use anyhow::Context;
2use indexmap::IndexMap;
3use itertools::Itertools;
4use semver::Version;
5use spin_app::locked::InheritConfiguration as LockedInheritConfiguration;
6use spin_common::{ui::quoted_path, url::parse_file_url};
7use spin_serde::{DependencyName, KebabId};
8use std::collections::BTreeMap;
9use thiserror::Error;
10use wac_graph::types::{Package, SubtypeChecker, WorldId};
11use wac_graph::{CompositionGraph, NodeId};
12
13pub use spin_capabilities::InheritConfiguration;
14
15/// Composes a Spin AppComponent using the dependencies specified in the
16/// component's dependencies section.
17///
18/// To compose the dependent component with its dependencies, the composer will
19/// first prepare the dependencies by maximally matching depenedency names to
20/// import names and register dependency components with the composition graph
21/// with the `deny-all` adapter applied if the set of configurations to inherit
22/// is the empty set. Once this mapping of import names to dependency infos is
23/// constructed the composer will build the instantiation arguments for the
24/// dependent component by ensuring that the export type of the dependency is a
25/// subtype of the import type of the dependent component. If the dependency has
26/// an export name specified, the composer will use that export name to satisfy
27/// the import. If the dependency does not have an export name specified, the
28/// composer will use an export of import name to satisfy the import. The
29/// composer will then alias the export of the dependency to the import of the
30/// dependent component. Finally, the composer will export all exports from the
31/// dependent component to its dependents. The composer will then encode the
32/// composition graph into a byte array and return it.
33pub async fn compose<
34    L: ComponentSourceLoader,
35    Fut: std::future::Future<Output = Result<Vec<u8>, ComposeError>>,
36>(
37    loader: &L,
38    component: &L::Component,
39    apply_trigger_deps: impl Fn(Vec<u8>) -> Fut,
40) -> Result<Vec<u8>, ComposeError> {
41    Composer::new(loader)
42        .compose(component, apply_trigger_deps)
43        .await
44}
45
46/// A Spin component dependency. This abstracts over the metadata associated with the
47/// dependency. The abstraction allows both manifest and lockfile types to participate in composition.
48#[async_trait::async_trait]
49pub trait DependencyLike {
50    fn inherit(&self) -> InheritConfiguration;
51    fn export(&self) -> &Option<String>;
52}
53
54/// A Spin component. This abstracts over the list of dependencies for the component.
55/// The abstraction allows both manifest and lockfile types to participate in composition.
56#[async_trait::async_trait]
57pub trait ComponentLike {
58    type Dependency: DependencyLike;
59
60    fn dependencies(
61        &self,
62    ) -> impl std::iter::ExactSizeIterator<Item = (&DependencyName, &Self::Dependency)>;
63    fn id(&self) -> &str;
64}
65
66#[async_trait::async_trait]
67impl ComponentLike for spin_app::locked::LockedComponent {
68    type Dependency = spin_app::locked::LockedComponentDependency;
69
70    fn dependencies(
71        &self,
72    ) -> impl std::iter::ExactSizeIterator<Item = (&DependencyName, &Self::Dependency)> {
73        self.dependencies.iter()
74    }
75
76    fn id(&self) -> &str {
77        &self.id
78    }
79}
80
81#[async_trait::async_trait]
82impl DependencyLike for spin_app::locked::LockedComponentDependency {
83    fn inherit(&self) -> InheritConfiguration {
84        match &self.inherit {
85            LockedInheritConfiguration::All => InheritConfiguration::All,
86            LockedInheritConfiguration::Some(cfgs) => InheritConfiguration::Some(cfgs.clone()),
87        }
88    }
89
90    fn export(&self) -> &Option<String> {
91        &self.export
92    }
93}
94
95/// This trait is used to load component source code from a locked component source across various embdeddings.
96#[async_trait::async_trait]
97pub trait ComponentSourceLoader {
98    type Component: ComponentLike<Dependency = Self::Dependency>;
99    type Dependency: DependencyLike;
100    type Source;
101    async fn load_component_source(&self, source: &Self::Component) -> anyhow::Result<Vec<u8>>;
102    async fn load_dependency_source(&self, source: &Self::Dependency) -> anyhow::Result<Vec<u8>>;
103    async fn load_source(&self, source: &Self::Source) -> anyhow::Result<Vec<u8>>;
104}
105
106/// A ComponentSourceLoader that loads component sources from the filesystem.
107pub struct ComponentSourceLoaderFs;
108
109#[async_trait::async_trait]
110impl ComponentSourceLoader for ComponentSourceLoaderFs {
111    type Component = spin_app::locked::LockedComponent;
112    type Dependency = spin_app::locked::LockedComponentDependency;
113    type Source = spin_app::locked::LockedComponentSource;
114
115    async fn load_component_source(&self, source: &Self::Component) -> anyhow::Result<Vec<u8>> {
116        Self::load_from_locked_source(&source.source).await
117    }
118
119    async fn load_dependency_source(&self, source: &Self::Dependency) -> anyhow::Result<Vec<u8>> {
120        Self::load_from_locked_source(&source.source).await
121    }
122
123    async fn load_source(&self, source: &Self::Source) -> anyhow::Result<Vec<u8>> {
124        Self::load_from_locked_source(source).await
125    }
126}
127
128impl ComponentSourceLoaderFs {
129    async fn load_from_locked_source(
130        source: &spin_app::locked::LockedComponentSource,
131    ) -> anyhow::Result<Vec<u8>> {
132        let source = source
133            .content
134            .source
135            .as_ref()
136            .context("LockedComponentSource missing source field")?;
137
138        let path = parse_file_url(source)?;
139
140        let bytes: Vec<u8> = tokio::fs::read(&path).await.with_context(|| {
141            format!(
142                "failed to read component source from disk at path {}",
143                quoted_path(&path)
144            )
145        })?;
146
147        let component = spin_componentize::componentize_if_necessary(&bytes)
148            .with_context(|| format!("failed to componentize {}", quoted_path(&path)))?;
149
150        Ok(component.into())
151    }
152}
153
154/// Represents an error that can occur when composing dependencies.
155#[derive(Debug, Error)]
156pub enum ComposeError {
157    /// A dependency name does not match any import names.
158    #[error(
159        "dependency '{dependency_name}' doesn't match any imports of component '{component_id}'"
160    )]
161    UnmatchedDependencyName {
162        component_id: String,
163        dependency_name: DependencyName,
164    },
165    /// A component has dependency conflicts.
166    #[error("component '{component_id}' has dependency conflicts: {}", format_conflicts(.conflicts))]
167    DependencyConflicts {
168        component_id: String,
169        conflicts: Vec<(String, Vec<DependencyName>)>,
170    },
171    /// Dependency doesn't contain an export to satisfy the import.
172    #[error(
173        "dependency '{dependency_name}' doesn't export '{export_name}' to satisfy import '{import_name}'"
174    )]
175    MissingExport {
176        dependency_name: DependencyName,
177        export_name: String,
178        import_name: String,
179    },
180    /// An error occurred when building the composition graph
181    #[error("an error occurred when preparing dependencies")]
182    PrepareError(#[source] anyhow::Error),
183    /// An error occurred while encoding the composition graph.
184    #[error("failed to encode composition graph: {0}")]
185    EncodeError(#[source] anyhow::Error),
186}
187
188fn format_conflicts(conflicts: &[(String, Vec<DependencyName>)]) -> String {
189    conflicts
190        .iter()
191        .map(|(import_name, dependency_names)| {
192            format!(
193                "import '{}' satisfied by dependencies: '{}'",
194                import_name,
195                dependency_names.iter().join(", ")
196            )
197        })
198        .join("; ")
199}
200
201struct Composer<'a, L> {
202    graph: CompositionGraph,
203    loader: &'a L,
204}
205
206impl<'a, L: ComponentSourceLoader> Composer<'a, L> {
207    async fn compose<Fut: std::future::Future<Output = Result<Vec<u8>, ComposeError>>>(
208        mut self,
209        component: &L::Component,
210        apply_trigger_deps: impl Fn(Vec<u8>) -> Fut,
211    ) -> Result<Vec<u8>, ComposeError> {
212        let source = self
213            .loader
214            .load_component_source(component)
215            .await
216            .map_err(ComposeError::PrepareError)?;
217
218        let fulfilled_source = if component.dependencies().len() == 0 {
219            source
220        } else {
221            let (world_id, instantiation_id) = self
222                .register_package(component.id(), None, source)
223                .map_err(ComposeError::PrepareError)?;
224
225            let prepared = self.prepare_dependencies(world_id, component).await?;
226
227            let arguments = self
228                .build_instantiation_arguments(world_id, prepared)
229                .await?;
230
231            for (argument_name, argument) in arguments {
232                self.graph
233                    .set_instantiation_argument(instantiation_id, &argument_name, argument)
234                    .map_err(|e| ComposeError::PrepareError(e.into()))?;
235            }
236
237            self.export_dependents_exports(world_id, instantiation_id)
238                .map_err(ComposeError::PrepareError)?;
239
240            self.graph
241                .encode(Default::default())
242                .map_err(|e| ComposeError::EncodeError(e.into()))?
243        };
244
245        let with_extras = apply_trigger_deps(fulfilled_source).await?;
246
247        Ok(with_extras)
248    }
249
250    fn new(loader: &'a L) -> Self {
251        Self {
252            graph: CompositionGraph::new(),
253            loader,
254        }
255    }
256
257    // This function takes the dependencies specified by the locked component
258    // and builds a mapping of import names to dependency infos which contains
259    // information about the registered dependency into the composition graph.
260    // Additionally if conflicts are detected (where an import name can be
261    // satisfied by multiple dependencies) the set of conflicts is returned as
262    // an error.
263    async fn prepare_dependencies(
264        &mut self,
265        world_id: WorldId,
266        component: &L::Component,
267    ) -> Result<IndexMap<String, DependencyInfo>, ComposeError> {
268        let imports = self.graph.types()[world_id].imports.clone();
269
270        let import_keys = imports.keys().cloned().collect::<Vec<_>>();
271
272        let mut mappings: BTreeMap<String, Vec<DependencyInfo>> = BTreeMap::new();
273
274        for (dependency_name, dependency) in component.dependencies() {
275            let mut matched = Vec::new();
276
277            for import_name in &import_keys {
278                if matches_import(dependency_name, import_name)
279                    .map_err(ComposeError::PrepareError)?
280                {
281                    matched.push(import_name.clone());
282                }
283            }
284
285            if matched.is_empty() {
286                return Err(ComposeError::UnmatchedDependencyName {
287                    component_id: component.id().to_owned(),
288                    dependency_name: dependency_name.clone(),
289                });
290            }
291
292            let info = self
293                .register_dependency(dependency_name.clone(), dependency)
294                .await
295                .map_err(ComposeError::PrepareError)?;
296
297            // Insert the expanded dependency name into the map detecting duplicates
298            for import_name in matched {
299                mappings
300                    .entry(import_name.to_string())
301                    .or_default()
302                    .push(info.clone());
303            }
304        }
305
306        let (conflicts, prepared): (Vec<_>, Vec<_>) =
307            mappings.into_iter().partition(|(_, infos)| infos.len() > 1);
308
309        if !conflicts.is_empty() {
310            return Err(ComposeError::DependencyConflicts {
311                component_id: component.id().to_owned(),
312                conflicts: conflicts
313                    .into_iter()
314                    .map(|(import_name, infos)| {
315                        (
316                            import_name,
317                            infos.into_iter().map(|info| info.manifest_name).collect(),
318                        )
319                    })
320                    .collect(),
321            });
322        }
323
324        Ok(prepared
325            .into_iter()
326            .map(|(import_name, mut infos)| {
327                assert_eq!(infos.len(), 1);
328                (import_name, infos.remove(0))
329            })
330            .collect())
331    }
332
333    // This function takes the set of prepared dependences and builds a mapping
334    // of import name to the node in the composition graph used to satisfy the
335    // import. If an export could not be found or the export is not comptaible
336    // with the type of the import, an error is returned.
337    async fn build_instantiation_arguments(
338        &mut self,
339        world_id: WorldId,
340        dependencies: IndexMap<String, DependencyInfo>,
341    ) -> Result<IndexMap<String, NodeId>, ComposeError> {
342        let mut cache = Default::default();
343        let mut checker = SubtypeChecker::new(&mut cache);
344
345        let mut arguments = IndexMap::new();
346
347        for (import_name, dependency_info) in dependencies {
348            let (export_name, export_ty) = match dependency_info.export_name {
349                Some(export_name) => {
350                    let Some(export_ty) = self.graph.types()[dependency_info.world_id]
351                        .exports
352                        .get(&export_name)
353                    else {
354                        return Err(ComposeError::MissingExport {
355                            dependency_name: dependency_info.manifest_name,
356                            export_name,
357                            import_name: import_name.clone(),
358                        });
359                    };
360
361                    (export_name, export_ty)
362                }
363                None => {
364                    let Some(export_ty) = self.graph.types()[dependency_info.world_id]
365                        .exports
366                        .get(&import_name)
367                    else {
368                        return Err(ComposeError::MissingExport {
369                            dependency_name: dependency_info.manifest_name,
370                            export_name: import_name.clone(),
371                            import_name: import_name.clone(),
372                        });
373                    };
374
375                    (import_name.clone(), export_ty)
376                }
377            };
378
379            let import_ty = self.graph.types()[world_id]
380                .imports
381                .get(&import_name)
382                .unwrap();
383
384            // Ensure that export_ty is a subtype of import_ty
385            checker.is_subtype(
386                *export_ty,
387                self.graph.types(),
388                *import_ty,
389                self.graph.types(),
390            ).with_context(|| {
391                format!(
392                    "dependency '{dependency_name}' exports '{export_name}' which is not compatible with import '{import_name}'",
393                    dependency_name = dependency_info.manifest_name,
394                )
395            })
396            .map_err(ComposeError::PrepareError)?;
397
398            let export_id = self
399                .graph
400                .alias_instance_export(dependency_info.instantiation_id, &export_name)
401                .map_err(|e| ComposeError::PrepareError(e.into()))?;
402
403            assert!(arguments.insert(import_name, export_id).is_none());
404        }
405
406        Ok(arguments)
407    }
408
409    // This function registers a dependency with the composition graph.
410    // Additionally if the locked component specifies that configuration
411    // inheritance is disabled, the `deny-all` adapter is applied to the
412    // dependency.
413    async fn register_dependency(
414        &mut self,
415        dependency_name: DependencyName,
416        dependency: &L::Dependency,
417    ) -> anyhow::Result<DependencyInfo> {
418        let mut dependency_source = self.loader.load_dependency_source(dependency).await?;
419
420        let package_name = match &dependency_name {
421            DependencyName::Package(name) => name.package.to_string(),
422            DependencyName::Plain(name) => name.to_string(),
423        };
424
425        dependency_source =
426            spin_capabilities::apply_deny_adapter(&dependency_source, dependency.inherit())?;
427
428        let (world_id, instantiation_id) =
429            self.register_package(&package_name, None, dependency_source)?;
430
431        Ok(DependencyInfo {
432            manifest_name: dependency_name,
433            instantiation_id,
434            world_id,
435            export_name: dependency.export().clone(),
436        })
437    }
438
439    fn register_package(
440        &mut self,
441        name: &str,
442        version: Option<&Version>,
443        source: impl Into<Vec<u8>>,
444    ) -> anyhow::Result<(WorldId, NodeId)> {
445        let package = Package::from_bytes(name, version, source, self.graph.types_mut())?;
446        let world_id = package.ty();
447        let package_id = self.graph.register_package(package)?;
448        let instantiation_id = self.graph.instantiate(package_id);
449
450        Ok((world_id, instantiation_id))
451    }
452
453    fn export_dependents_exports(
454        &mut self,
455        world_id: WorldId,
456        instantiation_id: NodeId,
457    ) -> anyhow::Result<()> {
458        // Export all exports from the root component
459        for export_name in self.graph.types()[world_id]
460            .exports
461            .keys()
462            .cloned()
463            .collect::<Vec<_>>()
464        {
465            let export_id = self
466                .graph
467                .alias_instance_export(instantiation_id, &export_name)?;
468
469            self.graph.export(export_id, &export_name)?;
470        }
471
472        Ok(())
473    }
474}
475
476#[derive(Clone)]
477struct DependencyInfo {
478    // The name of the dependency as it appears in the component's dependencies section.
479    // This is used to correlate errors when composing back to what was specified in the
480    // manifest.
481    manifest_name: DependencyName,
482    // The instantiation id for the dependency node.
483    instantiation_id: NodeId,
484    // The world id for the dependency node.
485    world_id: WorldId,
486    // Name of optional export to use to satisfy the dependency.
487    export_name: Option<String>,
488}
489enum ImportName {
490    Plain(KebabId),
491    Package {
492        package: String,
493        interface: String,
494        version: Option<Version>,
495    },
496}
497
498impl std::str::FromStr for ImportName {
499    type Err = anyhow::Error;
500
501    fn from_str(s: &str) -> Result<Self, Self::Err> {
502        if s.contains([':', '/']) {
503            let (package, rest) = s
504                .split_once('/')
505                .with_context(|| format!("invalid import name: {s}"))?;
506
507            let (interface, version) = match rest.split_once('@') {
508                Some((interface, version)) => {
509                    let version = Version::parse(version)
510                        .with_context(|| format!("invalid version in import name: {s}"))?;
511
512                    (interface, Some(version))
513                }
514                None => (rest, None),
515            };
516
517            Ok(Self::Package {
518                package: package.to_string(),
519                interface: interface.to_string(),
520                version,
521            })
522        } else {
523            Ok(Self::Plain(
524                s.to_string()
525                    .try_into()
526                    .map_err(|e| anyhow::anyhow!("{e}"))?,
527            ))
528        }
529    }
530}
531
532/// Returns true if the dependency name matches the provided import name string.
533fn matches_import(dependency_name: &DependencyName, import_name: &str) -> anyhow::Result<bool> {
534    let import_name = import_name.parse::<ImportName>()?;
535
536    match (dependency_name, import_name) {
537        (DependencyName::Plain(dependency_name), ImportName::Plain(import_name)) => {
538            // Plain names only match if they are equal.
539            Ok(dependency_name == &import_name)
540        }
541        (
542            DependencyName::Package(dependency_name),
543            ImportName::Package {
544                package: import_package,
545                interface: import_interface,
546                version: import_version,
547            },
548        ) => {
549            if import_package != dependency_name.package.to_string() {
550                return Ok(false);
551            }
552
553            if let Some(interface) = dependency_name.interface.as_ref()
554                && import_interface != interface.as_ref()
555            {
556                return Ok(false);
557            }
558
559            if let Some(version) = dependency_name.version.as_ref()
560                && import_version != Some(version.clone())
561            {
562                return Ok(false);
563            }
564
565            Ok(true)
566        }
567        (_, _) => {
568            // All other combinations of dependency and import names cannot match.
569            Ok(false)
570        }
571    }
572}
573
574#[cfg(test)]
575mod test {
576    use super::*;
577
578    #[test]
579    fn test_matches_import() {
580        for (dep_name, import_names) in [
581            ("foo:bar/baz@0.1.0", vec!["foo:bar/baz@0.1.0"]),
582            ("foo:bar/baz", vec!["foo:bar/baz@0.1.0", "foo:bar/baz"]),
583            ("foo:bar", vec!["foo:bar/baz@0.1.0", "foo:bar/baz"]),
584            ("foo:bar@0.1.0", vec!["foo:bar/baz@0.1.0"]),
585            ("foo-bar", vec!["foo-bar"]),
586        ] {
587            let dep_name: DependencyName = dep_name.parse().unwrap();
588            for import_name in import_names {
589                assert!(matches_import(&dep_name, import_name).unwrap());
590            }
591        }
592
593        for (dep_name, import_names) in [
594            ("foo:bar/baz@0.1.0", vec!["foo:bar/baz"]),
595            ("foo:bar/baz", vec!["foo:bar/bub", "foo:bar/bub@0.1.0"]),
596            ("foo:bar", vec!["foo:bub/bib"]),
597            ("foo:bar@0.1.0", vec!["foo:bar/baz"]),
598            ("foo:bar/baz", vec!["foo:bar/baz-bub", "foo-bar"]),
599        ] {
600            let dep_name: DependencyName = dep_name.parse().unwrap();
601            for import_name in import_names {
602                assert!(!matches_import(&dep_name, import_name).unwrap());
603            }
604        }
605    }
606}