spin_app/
lib.rs

1//! Spin internal application interfaces
2//!
3//! This crate contains interfaces to Spin application configuration to be used
4//! by crates that implement Spin execution environments: trigger executors and
5//! host components, in particular.
6
7#![deny(missing_docs)]
8
9use std::collections::HashSet;
10use std::sync::Arc;
11
12use serde::Deserialize;
13use serde_json::Value;
14use spin_locked_app::MetadataExt;
15
16use locked::{ContentPath, LockedApp, LockedComponent, LockedComponentSource, LockedTrigger};
17
18pub use spin_locked_app::locked;
19pub use spin_locked_app::values;
20pub use spin_locked_app::{Error, MetadataKey, Result};
21
22pub use locked::Variable;
23
24/// MetadataKey for extracting the application name.
25pub const APP_NAME_KEY: MetadataKey = MetadataKey::new("name");
26/// MetadataKey for extracting the application version.
27pub const APP_VERSION_KEY: MetadataKey = MetadataKey::new("version");
28/// MetadataKey for extracting the application description.
29pub const APP_DESCRIPTION_KEY: MetadataKey = MetadataKey::new("description");
30/// MetadataKey for extracting the OCI image digest.
31pub const OCI_IMAGE_DIGEST_KEY: MetadataKey = MetadataKey::new("oci_image_digest");
32
33/// Validation function type for ensuring that applications meet requirements
34/// even with components filtered out.
35pub type ValidatorFn = dyn Fn(&App, &[&str]) -> anyhow::Result<()>;
36
37/// An `App` holds loaded configuration for a Spin application.
38#[derive(Debug, Clone)]
39pub struct App {
40    id: Arc<str>,
41    locked: Arc<LockedApp>,
42}
43
44impl App {
45    /// Returns a new app for the given runtime-specific identifier and locked
46    /// app.
47    pub fn new(id: impl Into<Arc<str>>, locked: LockedApp) -> Self {
48        Self {
49            id: id.into(),
50            locked: Arc::new(locked),
51        }
52    }
53
54    /// Returns a runtime-specific identifier for this app.
55    pub fn id(&self) -> &str {
56        &self.id
57    }
58
59    /// Returns a runtime-specific identifier for this app.
60    pub fn id_shared(&self) -> Arc<str> {
61        self.id.clone()
62    }
63
64    /// Deserializes typed metadata for this app.
65    ///
66    /// Returns `Ok(None)` if there is no metadata for the given `key` and an
67    /// `Err` only if there _is_ a value for the `key` but the typed
68    /// deserialization failed.
69    pub fn get_metadata<'this, T: Deserialize<'this>>(
70        &'this self,
71        key: MetadataKey<T>,
72    ) -> Result<Option<T>> {
73        self.locked.get_metadata(key)
74    }
75
76    /// Deserializes typed metadata for this app.
77    ///
78    /// Like [`App::get_metadata`], but returns an error if there is
79    /// no metadata for the given `key`.
80    pub fn require_metadata<'this, T: Deserialize<'this>>(
81        &'this self,
82        key: MetadataKey<T>,
83    ) -> Result<T> {
84        self.locked.require_metadata(key)
85    }
86
87    /// Returns an iterator of custom config [`Variable`]s defined for this app.
88    pub fn variables(&self) -> impl Iterator<Item = (&String, &Variable)> {
89        self.locked.variables.iter()
90    }
91
92    /// Returns an iterator of [`AppComponent`]s defined for this app.
93    pub fn components(&self) -> impl ExactSizeIterator<Item = AppComponent<'_>> {
94        self.locked
95            .components
96            .iter()
97            .map(|locked| AppComponent { app: self, locked })
98    }
99
100    /// Returns the [`AppComponent`] with the given `component_id`, or `None`
101    /// if it doesn't exist.
102    pub fn get_component(&self, component_id: &str) -> Option<AppComponent<'_>> {
103        self.components()
104            .find(|component| component.locked.id == component_id)
105    }
106
107    /// Returns an iterator of [`AppTrigger`]s defined for this app.
108    pub fn triggers(&self) -> impl Iterator<Item = AppTrigger<'_>> + '_ {
109        self.locked
110            .triggers
111            .iter()
112            .map(|locked| AppTrigger { app: self, locked })
113    }
114
115    /// Returns the trigger metadata for a specific trigger type.
116    pub fn get_trigger_metadata<'this, T: Deserialize<'this>>(
117        &'this self,
118        trigger_type: &str,
119    ) -> Result<Option<T>> {
120        let Some(value) = self.get_trigger_metadata_value(trigger_type) else {
121            return Ok(None);
122        };
123        let metadata = T::deserialize(value).map_err(|err| {
124            Error::MetadataError(format!(
125                "invalid metadata value for {trigger_type:?}: {err:?}"
126            ))
127        })?;
128        Ok(Some(metadata))
129    }
130
131    fn get_trigger_metadata_value(&self, trigger_type: &str) -> Option<Value> {
132        if let Some(trigger_configs) = self.locked.metadata.get("triggers") {
133            // New-style: `{"triggers": {"<type>": {...}}}`
134            trigger_configs.get(trigger_type).cloned()
135        } else if self.locked.metadata["trigger"]["type"] == trigger_type {
136            // Old-style: `{"trigger": {"type": "<type>", ...}}`
137            let mut meta = self.locked.metadata["trigger"].clone();
138            meta.as_object_mut().unwrap().remove("type");
139            Some(meta)
140        } else {
141            None
142        }
143    }
144
145    /// Returns an iterator of [`AppTrigger`]s defined for this app with
146    /// the given `trigger_type`.
147    pub fn triggers_with_type<'a>(
148        &'a self,
149        trigger_type: &'a str,
150    ) -> impl Iterator<Item = AppTrigger<'a>> {
151        self.triggers()
152            .filter(move |trigger| trigger.locked.trigger_type == trigger_type)
153    }
154
155    /// Returns an iterator of trigger IDs and deserialized trigger configs for
156    /// the given `trigger_type`.
157    pub fn trigger_configs<'a, T: Deserialize<'a>>(
158        &'a self,
159        trigger_type: &'a str,
160    ) -> Result<impl IntoIterator<Item = (&'a str, T)>> {
161        self.triggers_with_type(trigger_type)
162            .map(|trigger| {
163                let config = trigger.typed_config::<T>()?;
164                Ok((trigger.id(), config))
165            })
166            .collect::<Result<Vec<_>>>()
167    }
168
169    /// Checks that the application does not have any host requirements
170    /// outside the supported set. The error case returns a comma-separated
171    /// list of unmet requirements.
172    pub fn ensure_needs_only(
173        &self,
174        trigger_type: &str,
175        supported: &[&str],
176    ) -> std::result::Result<(), String> {
177        self.locked.ensure_needs_only(trigger_type, supported)
178    }
179
180    /// Scrubs the locked app to only contain the given list of components
181    /// Introspects the LockedApp to find and selectively retain the triggers that correspond to those components
182    fn retain_components(
183        self,
184        retained_components: &[&str],
185        validators: &[&ValidatorFn],
186    ) -> Result<LockedApp> {
187        self.validate_retained_components_exist(retained_components)?;
188        for validator in validators {
189            validator(&self, retained_components).map_err(Error::ValidationError)?;
190        }
191        let (component_ids, trigger_ids): (HashSet<String>, HashSet<String>) = self
192            .triggers()
193            .filter_map(|t| match t.component() {
194                Ok(comp) if retained_components.contains(&comp.id()) => {
195                    Some((comp.id().to_owned(), t.id().to_owned()))
196                }
197                _ => None,
198            })
199            .collect();
200        let mut locked = Arc::unwrap_or_clone(self.locked);
201        locked.components.retain(|c| component_ids.contains(&c.id));
202        locked.triggers.retain(|t| trigger_ids.contains(&t.id));
203        Ok(locked)
204    }
205
206    /// Validates that all components specified to be retained actually exist in the app
207    fn validate_retained_components_exist(&self, retained_components: &[&str]) -> Result<()> {
208        let app_components = self
209            .components()
210            .map(|c| c.id().to_string())
211            .collect::<HashSet<_>>();
212        for c in retained_components {
213            if !app_components.contains(*c) {
214                return Err(Error::ValidationError(anyhow::anyhow!(
215                    "Specified component \"{c}\" not found in application"
216                )));
217            }
218        }
219        Ok(())
220    }
221}
222
223/// An `AppComponent` holds configuration for a Spin application component.
224pub struct AppComponent<'a> {
225    /// The app this component belongs to.
226    pub app: &'a App,
227    /// The locked component.
228    pub locked: &'a LockedComponent,
229}
230
231impl<'a> AppComponent<'a> {
232    /// Returns this component's app-unique ID.
233    pub fn id(&self) -> &str {
234        &self.locked.id
235    }
236
237    /// Returns this component's Wasm component or module source.
238    pub fn source(&self) -> &LockedComponentSource {
239        &self.locked.source
240    }
241
242    /// Returns an iterator of environment variable (key, value) pairs.
243    pub fn environment(&self) -> impl IntoIterator<Item = (&str, &str)> {
244        self.locked
245            .env
246            .iter()
247            .map(|(k, v)| (k.as_str(), v.as_str()))
248    }
249
250    /// Returns an iterator of [`ContentPath`]s for this component's configured
251    /// "directory mounts".
252    pub fn files(&self) -> std::slice::Iter<ContentPath> {
253        self.locked.files.iter()
254    }
255
256    /// Deserializes typed metadata for this component.
257    ///
258    /// Returns `Ok(None)` if there is no metadata for the given `key` and an
259    /// `Err` only if there _is_ a value for the `key` but the typed
260    /// deserialization failed.
261    pub fn get_metadata<T: Deserialize<'a>>(&self, key: MetadataKey<T>) -> Result<Option<T>> {
262        self.locked.metadata.get_typed(key)
263    }
264
265    /// Deserializes typed metadata for this component.
266    ///
267    /// Like [`AppComponent::get_metadata`], but returns an error if there is
268    /// no metadata for the given `key`.
269    pub fn require_metadata<'this, T: Deserialize<'this>>(
270        &'this self,
271        key: MetadataKey<T>,
272    ) -> Result<T> {
273        self.locked.metadata.require_typed(key)
274    }
275
276    /// Returns an iterator of custom config values for this component.
277    pub fn config(&self) -> impl Iterator<Item = (&String, &String)> {
278        self.locked.config.iter()
279    }
280}
281
282/// An `AppTrigger` holds configuration for a Spin application trigger.
283pub struct AppTrigger<'a> {
284    /// The app this trigger belongs to.
285    pub app: &'a App,
286    locked: &'a LockedTrigger,
287}
288
289impl<'a> AppTrigger<'a> {
290    /// Returns this trigger's app-unique ID.
291    pub fn id(&self) -> &'a str {
292        &self.locked.id
293    }
294
295    /// Returns the Trigger's type.
296    pub fn trigger_type(&self) -> &'a str {
297        &self.locked.trigger_type
298    }
299
300    /// Deserializes this trigger's configuration into a typed value.
301    pub fn typed_config<Config: Deserialize<'a>>(&self) -> Result<Config> {
302        Ok(Config::deserialize(&self.locked.trigger_config)?)
303    }
304
305    /// Returns a reference to the [`AppComponent`] configured for this trigger.
306    ///
307    /// This is a convenience wrapper that looks up the component based on the
308    /// 'component' metadata value which is conventionally a component ID.
309    pub fn component(&self) -> Result<AppComponent<'a>> {
310        let id = &self.locked.id;
311        let common_config: CommonTriggerConfig = self.typed_config()?;
312        let component_id = common_config.component.ok_or_else(|| {
313            Error::MetadataError(format!("trigger {id:?} missing 'component' config field"))
314        })?;
315        self.app.get_component(&component_id).ok_or_else(|| {
316            Error::MetadataError(format!(
317                "missing component {component_id:?} configured for trigger {id:?}"
318            ))
319        })
320    }
321}
322
323#[derive(Deserialize)]
324struct CommonTriggerConfig {
325    component: Option<String>,
326}
327
328/// Scrubs the locked app to only contain the given list of components
329/// Introspects the LockedApp to find and selectively retain the triggers that correspond to those components
330pub fn retain_components(
331    locked: LockedApp,
332    components: &[&str],
333    validators: &[&ValidatorFn],
334) -> Result<LockedApp> {
335    App::new("unused", locked).retain_components(components, validators)
336}
337
338#[cfg(test)]
339mod test {
340    use spin_factors_test::build_locked_app;
341
342    use super::*;
343
344    fn does_nothing_validator(_: &App, _: &[&str]) -> anyhow::Result<()> {
345        Ok(())
346    }
347
348    #[tokio::test]
349    async fn test_retain_components_filtering_for_only_component_works() {
350        let manifest = toml::toml! {
351            spin_manifest_version = 2
352
353            [application]
354            name = "test-app"
355
356            [[trigger.test-trigger]]
357            component = "empty"
358
359            [component.empty]
360            source = "does-not-exist.wasm"
361        };
362        let mut locked_app = build_locked_app(&manifest).await.unwrap();
363        locked_app = retain_components(locked_app, &["empty"], &[&does_nothing_validator]).unwrap();
364        let components = locked_app
365            .components
366            .iter()
367            .map(|c| c.id.to_string())
368            .collect::<HashSet<_>>();
369        assert!(components.contains("empty"));
370        assert!(components.len() == 1);
371    }
372}