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(&self, supported: &[&str]) -> std::result::Result<(), String> {
173        self.locked.ensure_needs_only(supported)
174    }
175
176    /// Scrubs the locked app to only contain the given list of components
177    /// Introspects the LockedApp to find and selectively retain the triggers that correspond to those components
178    fn retain_components(
179        self,
180        retained_components: &[&str],
181        validators: &[&ValidatorFn],
182    ) -> Result<LockedApp> {
183        self.validate_retained_components_exist(retained_components)?;
184        for validator in validators {
185            validator(&self, retained_components).map_err(Error::ValidationError)?;
186        }
187        let (component_ids, trigger_ids): (HashSet<String>, HashSet<String>) = self
188            .triggers()
189            .filter_map(|t| match t.component() {
190                Ok(comp) if retained_components.contains(&comp.id()) => {
191                    Some((comp.id().to_owned(), t.id().to_owned()))
192                }
193                _ => None,
194            })
195            .collect();
196        let mut locked = Arc::unwrap_or_clone(self.locked);
197        locked.components.retain(|c| component_ids.contains(&c.id));
198        locked.triggers.retain(|t| trigger_ids.contains(&t.id));
199        Ok(locked)
200    }
201
202    /// Validates that all components specified to be retained actually exist in the app
203    fn validate_retained_components_exist(&self, retained_components: &[&str]) -> Result<()> {
204        let app_components = self
205            .components()
206            .map(|c| c.id().to_string())
207            .collect::<HashSet<_>>();
208        for c in retained_components {
209            if !app_components.contains(*c) {
210                return Err(Error::ValidationError(anyhow::anyhow!(
211                    "Specified component \"{c}\" not found in application"
212                )));
213            }
214        }
215        Ok(())
216    }
217}
218
219/// An `AppComponent` holds configuration for a Spin application component.
220pub struct AppComponent<'a> {
221    /// The app this component belongs to.
222    pub app: &'a App,
223    /// The locked component.
224    pub locked: &'a LockedComponent,
225}
226
227impl<'a> AppComponent<'a> {
228    /// Returns this component's app-unique ID.
229    pub fn id(&self) -> &str {
230        &self.locked.id
231    }
232
233    /// Returns this component's Wasm component or module source.
234    pub fn source(&self) -> &LockedComponentSource {
235        &self.locked.source
236    }
237
238    /// Returns an iterator of environment variable (key, value) pairs.
239    pub fn environment(&self) -> impl IntoIterator<Item = (&str, &str)> {
240        self.locked
241            .env
242            .iter()
243            .map(|(k, v)| (k.as_str(), v.as_str()))
244    }
245
246    /// Returns an iterator of [`ContentPath`]s for this component's configured
247    /// "directory mounts".
248    pub fn files(&self) -> std::slice::Iter<ContentPath> {
249        self.locked.files.iter()
250    }
251
252    /// Deserializes typed metadata for this component.
253    ///
254    /// Returns `Ok(None)` if there is no metadata for the given `key` and an
255    /// `Err` only if there _is_ a value for the `key` but the typed
256    /// deserialization failed.
257    pub fn get_metadata<T: Deserialize<'a>>(&self, key: MetadataKey<T>) -> Result<Option<T>> {
258        self.locked.metadata.get_typed(key)
259    }
260
261    /// Deserializes typed metadata for this component.
262    ///
263    /// Like [`AppComponent::get_metadata`], but returns an error if there is
264    /// no metadata for the given `key`.
265    pub fn require_metadata<'this, T: Deserialize<'this>>(
266        &'this self,
267        key: MetadataKey<T>,
268    ) -> Result<T> {
269        self.locked.metadata.require_typed(key)
270    }
271
272    /// Returns an iterator of custom config values for this component.
273    pub fn config(&self) -> impl Iterator<Item = (&String, &String)> {
274        self.locked.config.iter()
275    }
276}
277
278/// An `AppTrigger` holds configuration for a Spin application trigger.
279pub struct AppTrigger<'a> {
280    /// The app this trigger belongs to.
281    pub app: &'a App,
282    locked: &'a LockedTrigger,
283}
284
285impl<'a> AppTrigger<'a> {
286    /// Returns this trigger's app-unique ID.
287    pub fn id(&self) -> &'a str {
288        &self.locked.id
289    }
290
291    /// Returns the Trigger's type.
292    pub fn trigger_type(&self) -> &'a str {
293        &self.locked.trigger_type
294    }
295
296    /// Deserializes this trigger's configuration into a typed value.
297    pub fn typed_config<Config: Deserialize<'a>>(&self) -> Result<Config> {
298        Ok(Config::deserialize(&self.locked.trigger_config)?)
299    }
300
301    /// Returns a reference to the [`AppComponent`] configured for this trigger.
302    ///
303    /// This is a convenience wrapper that looks up the component based on the
304    /// 'component' metadata value which is conventionally a component ID.
305    pub fn component(&self) -> Result<AppComponent<'a>> {
306        let id = &self.locked.id;
307        let common_config: CommonTriggerConfig = self.typed_config()?;
308        let component_id = common_config.component.ok_or_else(|| {
309            Error::MetadataError(format!("trigger {id:?} missing 'component' config field"))
310        })?;
311        self.app.get_component(&component_id).ok_or_else(|| {
312            Error::MetadataError(format!(
313                "missing component {component_id:?} configured for trigger {id:?}"
314            ))
315        })
316    }
317}
318
319#[derive(Deserialize)]
320struct CommonTriggerConfig {
321    component: Option<String>,
322}
323
324/// Scrubs the locked app to only contain the given list of components
325/// Introspects the LockedApp to find and selectively retain the triggers that correspond to those components
326pub fn retain_components(
327    locked: LockedApp,
328    components: &[&str],
329    validators: &[&ValidatorFn],
330) -> Result<LockedApp> {
331    App::new("unused", locked).retain_components(components, validators)
332}
333
334#[cfg(test)]
335mod test {
336    use spin_factors_test::build_locked_app;
337
338    use super::*;
339
340    fn does_nothing_validator(_: &App, _: &[&str]) -> anyhow::Result<()> {
341        Ok(())
342    }
343
344    #[tokio::test]
345    async fn test_retain_components_filtering_for_only_component_works() {
346        let manifest = toml::toml! {
347            spin_manifest_version = 2
348
349            [application]
350            name = "test-app"
351
352            [[trigger.test-trigger]]
353            component = "empty"
354
355            [component.empty]
356            source = "does-not-exist.wasm"
357        };
358        let mut locked_app = build_locked_app(&manifest).await.unwrap();
359        locked_app = retain_components(locked_app, &["empty"], &[&does_nothing_validator]).unwrap();
360        let components = locked_app
361            .components
362            .iter()
363            .map(|c| c.id.to_string())
364            .collect::<HashSet<_>>();
365        assert!(components.contains("empty"));
366        assert!(components.len() == 1);
367    }
368}