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