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(
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 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 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
223pub struct AppComponent<'a> {
225 pub app: &'a App,
227 pub locked: &'a LockedComponent,
229}
230
231impl<'a> AppComponent<'a> {
232 pub fn id(&self) -> &str {
234 &self.locked.id
235 }
236
237 pub fn source(&self) -> &LockedComponentSource {
239 &self.locked.source
240 }
241
242 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 pub fn files(&self) -> std::slice::Iter<ContentPath> {
253 self.locked.files.iter()
254 }
255
256 pub fn get_metadata<T: Deserialize<'a>>(&self, key: MetadataKey<T>) -> Result<Option<T>> {
262 self.locked.metadata.get_typed(key)
263 }
264
265 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 pub fn config(&self) -> impl Iterator<Item = (&String, &String)> {
278 self.locked.config.iter()
279 }
280}
281
282pub struct AppTrigger<'a> {
284 pub app: &'a App,
286 locked: &'a LockedTrigger,
287}
288
289impl<'a> AppTrigger<'a> {
290 pub fn id(&self) -> &'a str {
292 &self.locked.id
293 }
294
295 pub fn trigger_type(&self) -> &'a str {
297 &self.locked.trigger_type
298 }
299
300 pub fn typed_config<Config: Deserialize<'a>>(&self) -> Result<Config> {
302 Ok(Config::deserialize(&self.locked.trigger_config)?)
303 }
304
305 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
328pub 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}