1#![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
24pub const APP_NAME_KEY: MetadataKey = MetadataKey::new("name");
26pub const APP_VERSION_KEY: MetadataKey = MetadataKey::new("version");
28pub const APP_DESCRIPTION_KEY: MetadataKey = MetadataKey::new("description");
30pub const OCI_IMAGE_DIGEST_KEY: MetadataKey = MetadataKey::new("oci_image_digest");
32
33pub type ValidatorFn = dyn Fn(&App, &[&str]) -> anyhow::Result<()>;
36
37#[derive(Debug, Clone)]
39pub struct App {
40 id: Arc<str>,
41 locked: Arc<LockedApp>,
42}
43
44impl App {
45 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 pub fn id(&self) -> &str {
56 &self.id
57 }
58
59 pub fn id_shared(&self) -> Arc<str> {
61 self.id.clone()
62 }
63
64 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 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 pub fn variables(&self) -> impl Iterator<Item = (&String, &Variable)> {
89 self.locked.variables.iter()
90 }
91
92 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 pub fn get_component(&self, component_id: &str) -> Option<AppComponent<'_>> {
103 self.components()
104 .find(|component| component.locked.id == component_id)
105 }
106
107 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 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 trigger_configs.get(trigger_type).cloned()
135 } else if self.locked.metadata["trigger"]["type"] == trigger_type {
136 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 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 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 pub fn ensure_needs_only(&self, supported: &[&str]) -> std::result::Result<(), String> {
173 self.locked.ensure_needs_only(supported)
174 }
175
176 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 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
219pub struct AppComponent<'a> {
221 pub app: &'a App,
223 pub locked: &'a LockedComponent,
225}
226
227impl<'a> AppComponent<'a> {
228 pub fn id(&self) -> &str {
230 &self.locked.id
231 }
232
233 pub fn source(&self) -> &LockedComponentSource {
235 &self.locked.source
236 }
237
238 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 pub fn files(&self) -> std::slice::Iter<ContentPath> {
249 self.locked.files.iter()
250 }
251
252 pub fn get_metadata<T: Deserialize<'a>>(&self, key: MetadataKey<T>) -> Result<Option<T>> {
258 self.locked.metadata.get_typed(key)
259 }
260
261 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 pub fn config(&self) -> impl Iterator<Item = (&String, &String)> {
274 self.locked.config.iter()
275 }
276}
277
278pub struct AppTrigger<'a> {
280 pub app: &'a App,
282 locked: &'a LockedTrigger,
283}
284
285impl<'a> AppTrigger<'a> {
286 pub fn id(&self) -> &'a str {
288 &self.locked.id
289 }
290
291 pub fn trigger_type(&self) -> &'a str {
293 &self.locked.trigger_type
294 }
295
296 pub fn typed_config<Config: Deserialize<'a>>(&self) -> Result<Config> {
298 Ok(Config::deserialize(&self.locked.trigger_config)?)
299 }
300
301 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
324pub 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}