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