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