spin_compose/
lib.rs

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