Skip to main content

spin_compose/
lib.rs

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