spin_manifest/
normalize.rs

1//! Manifest normalization functions.
2
3use std::{collections::HashSet, path::PathBuf};
4
5use crate::schema::v2::{AppManifest, ComponentSpec, KebabId};
6use anyhow::Context;
7
8/// Normalizes some optional [`AppManifest`] features into a canonical form:
9/// - Inline components in trigger configs are moved into top-level
10///   components and replaced with a reference.
11/// - Any triggers without an ID are assigned a generated ID.
12pub fn normalize_manifest(manifest: &mut AppManifest) -> anyhow::Result<()> {
13    normalize_trigger_ids(manifest);
14    normalize_inline_components(manifest);
15    normalize_dependency_component_refs(manifest)?;
16    Ok(())
17}
18
19fn normalize_inline_components(manifest: &mut AppManifest) {
20    // Normalize inline components
21    let components = &mut manifest.components;
22
23    for trigger in manifest.triggers.values_mut().flatten() {
24        let trigger_id = &trigger.id;
25
26        let component_specs = trigger
27            .component
28            .iter_mut()
29            .chain(
30                trigger
31                    .components
32                    .values_mut()
33                    .flat_map(|specs| specs.0.iter_mut()),
34            )
35            .collect::<Vec<_>>();
36        let multiple_components = component_specs.len() > 1;
37
38        let mut counter = 1;
39        for spec in component_specs {
40            if !matches!(spec, ComponentSpec::Inline(_)) {
41                continue;
42            };
43
44            let inline_id = {
45                // Try a "natural" component ID...
46                let mut id = KebabId::try_from(format!("{trigger_id}-component"));
47                // ...falling back to a counter-based component ID
48                if multiple_components
49                    || id.is_err()
50                    || components.contains_key(id.as_ref().unwrap())
51                {
52                    id = Ok(loop {
53                        let id = KebabId::try_from(format!("inline-component{counter}")).unwrap();
54                        if !components.contains_key(&id) {
55                            break id;
56                        }
57                        counter += 1;
58                    });
59                }
60                id.unwrap()
61            };
62
63            // Replace the inline component with a reference...
64            let inline_spec = std::mem::replace(spec, ComponentSpec::Reference(inline_id.clone()));
65            let ComponentSpec::Inline(component) = inline_spec else {
66                unreachable!();
67            };
68            // ...moving the inline component into the top-level components map.
69            components.insert(inline_id.clone(), *component);
70        }
71    }
72}
73
74fn normalize_trigger_ids(manifest: &mut AppManifest) {
75    let mut trigger_ids = manifest
76        .triggers
77        .values()
78        .flatten()
79        .cloned()
80        .map(|t| t.id)
81        .collect::<HashSet<_>>();
82    for (trigger_type, triggers) in &mut manifest.triggers {
83        let mut counter = 1;
84        for trigger in triggers {
85            if !trigger.id.is_empty() {
86                continue;
87            }
88            // Try to assign a "natural" ID to this trigger
89            if let Some(ComponentSpec::Reference(component_id)) = &trigger.component {
90                let candidate_id = format!("{component_id}-{trigger_type}-trigger");
91                if !trigger_ids.contains(&candidate_id) {
92                    trigger.id.clone_from(&candidate_id);
93                    trigger_ids.insert(candidate_id);
94                    continue;
95                }
96            }
97            // Fall back to assigning a counter-based trigger ID
98            trigger.id = loop {
99                let id = format!("{trigger_type}-trigger{counter}");
100                if !trigger_ids.contains(&id) {
101                    trigger_ids.insert(id.clone());
102                    break id;
103                }
104                counter += 1;
105            }
106        }
107    }
108}
109
110use crate::schema::v2::{Component, ComponentDependency, ComponentSource};
111
112fn normalize_dependency_component_refs(manifest: &mut AppManifest) -> anyhow::Result<()> {
113    // `clone` a snapshot, because we are about to mutate collection elements,
114    // and the borrow checker gets mad at us if we try to index into the collection
115    // while that's happening.
116    let components = manifest.components.clone();
117
118    for (depender_id, component) in &mut manifest.components {
119        for dependency in component.dependencies.inner.values_mut() {
120            if let ComponentDependency::AppComponent {
121                component: depended_on_id,
122                export,
123            } = dependency
124            {
125                let depended_on = components
126                    .get(depended_on_id)
127                    .with_context(|| format!("dependency ID {depended_on_id} does not exist"))?;
128                ensure_is_acceptable_dependency(depended_on, depended_on_id, depender_id)?;
129                *dependency = component_source_to_dependency(&depended_on.source, export.clone());
130            }
131        }
132    }
133
134    Ok(())
135}
136
137fn component_source_to_dependency(
138    source: &ComponentSource,
139    export: Option<String>,
140) -> ComponentDependency {
141    match source {
142        ComponentSource::Local(path) => ComponentDependency::Local {
143            path: PathBuf::from(path),
144            export,
145        },
146        ComponentSource::Remote { url, digest } => ComponentDependency::HTTP {
147            url: url.clone(),
148            digest: digest.clone(),
149            export,
150        },
151        ComponentSource::Registry {
152            registry,
153            package,
154            version,
155        } => ComponentDependency::Package {
156            version: version.clone(),
157            registry: registry.as_ref().map(|r| r.to_string()),
158            package: Some(package.to_string()),
159            export,
160        },
161    }
162}
163
164/// If a dependency has things like files or KV stores or network access...
165/// those won't apply when it's composed, and that's likely to be surprising,
166/// and developers hate surprises.
167fn ensure_is_acceptable_dependency(
168    component: &Component,
169    depended_on_id: &KebabId,
170    depender_id: &KebabId,
171) -> anyhow::Result<()> {
172    let mut surprises = vec![];
173
174    // Explicitly discard fields we don't need to check (do *not* .. them away). This
175    // way, the compiler will give us a heads up if a new field is added so we can
176    // decide whether or not we need to check it.
177    #[allow(deprecated)]
178    let Component {
179        source: _,
180        description: _,
181        variables,
182        environment,
183        files,
184        exclude_files: _,
185        allowed_http_hosts,
186        allowed_outbound_hosts,
187        key_value_stores,
188        sqlite_databases,
189        ai_models,
190        build: _,
191        tool: _,
192        dependencies_inherit_configuration: _,
193        dependencies,
194    } = component;
195
196    if !ai_models.is_empty() {
197        surprises.push("ai_models");
198    }
199    if !allowed_http_hosts.is_empty() {
200        surprises.push("allowed_http_hosts");
201    }
202    if !allowed_outbound_hosts.is_empty() {
203        surprises.push("allowed_outbound_hosts");
204    }
205    if !dependencies.inner.is_empty() {
206        surprises.push("dependencies");
207    }
208    if !environment.is_empty() {
209        surprises.push("environment");
210    }
211    if !files.is_empty() {
212        surprises.push("files");
213    }
214    if !key_value_stores.is_empty() {
215        surprises.push("key_value_stores");
216    }
217    if !sqlite_databases.is_empty() {
218        surprises.push("sqlite_databases");
219    }
220    if !variables.is_empty() {
221        surprises.push("variables");
222    }
223
224    if surprises.is_empty() {
225        Ok(())
226    } else {
227        anyhow::bail!("Dependencies may not have their own resources or permissions. Component {depended_on_id} cannot be used as a dependency of {depender_id} because it specifies: {}", surprises.join(", "));
228    }
229}
230
231#[cfg(test)]
232mod test {
233    use super::*;
234
235    use serde::Deserialize;
236    use toml::toml;
237
238    fn package_name(name: &str) -> spin_serde::DependencyName {
239        let dpn = spin_serde::DependencyPackageName::try_from(name.to_string()).unwrap();
240        spin_serde::DependencyName::Package(dpn)
241    }
242
243    #[test]
244    fn can_resolve_dependency_on_file_source() {
245        let mut manifest = AppManifest::deserialize(toml! {
246            spin_manifest_version = 2
247
248            [application]
249            name = "dummy"
250
251            [[trigger.dummy]]
252            component = "a"
253
254            [component.a]
255            source = "a.wasm"
256            [component.a.dependencies]
257            "b:b" = { component = "b" }
258
259            [component.b]
260            source = "b.wasm"
261        })
262        .unwrap();
263
264        normalize_manifest(&mut manifest).unwrap();
265
266        let dep = manifest
267            .components
268            .get("a")
269            .unwrap()
270            .dependencies
271            .inner
272            .get(&package_name("b:b"))
273            .unwrap();
274
275        let ComponentDependency::Local { path, export } = dep else {
276            panic!("should have normalised to local dep");
277        };
278
279        assert_eq!(&PathBuf::from("b.wasm"), path);
280        assert_eq!(&None, export);
281    }
282
283    #[test]
284    fn can_resolve_dependency_on_http_source() {
285        let mut manifest = AppManifest::deserialize(toml! {
286            spin_manifest_version = 2
287
288            [application]
289            name = "dummy"
290
291            [[trigger.dummy]]
292            component = "a"
293
294            [component.a]
295            source = "a.wasm"
296            [component.a.dependencies]
297            "b:b" = { component = "b", export = "c:d/e" }
298
299            [component.b]
300            source = { url = "http://example.com/b.wasm", digest = "12345" }
301        })
302        .unwrap();
303
304        normalize_manifest(&mut manifest).unwrap();
305
306        let dep = manifest
307            .components
308            .get("a")
309            .unwrap()
310            .dependencies
311            .inner
312            .get(&package_name("b:b"))
313            .unwrap();
314
315        let ComponentDependency::HTTP {
316            url,
317            digest,
318            export,
319        } = dep
320        else {
321            panic!("should have normalised to HTTP dep");
322        };
323
324        assert_eq!("http://example.com/b.wasm", url);
325        assert_eq!("12345", digest);
326        assert_eq!("c:d/e", export.as_ref().unwrap());
327    }
328
329    #[test]
330    fn can_resolve_dependency_on_package() {
331        let mut manifest = AppManifest::deserialize(toml! {
332            spin_manifest_version = 2
333
334            [application]
335            name = "dummy"
336
337            [[trigger.dummy]]
338            component = "a"
339
340            [component.a]
341            source = "a.wasm"
342            [component.a.dependencies]
343            "b:b" = { component = "b" }
344
345            [component.b]
346            source = { package = "bb:bb", version = "1.2.3", registry = "reginalds-registry.reg" }
347        })
348        .unwrap();
349
350        normalize_manifest(&mut manifest).unwrap();
351
352        let dep = manifest
353            .components
354            .get("a")
355            .unwrap()
356            .dependencies
357            .inner
358            .get(&package_name("b:b"))
359            .unwrap();
360
361        let ComponentDependency::Package {
362            version,
363            registry,
364            package,
365            export,
366        } = dep
367        else {
368            panic!("should have normalised to HTTP dep");
369        };
370
371        assert_eq!("1.2.3", version);
372        assert_eq!("reginalds-registry.reg", registry.as_ref().unwrap());
373        assert_eq!("bb:bb", package.as_ref().unwrap());
374        assert_eq!(&None, export);
375    }
376}