Skip to main content

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        targets: _,
191        build: _,
192        tool: _,
193        dependencies_inherit_configuration: _,
194        dependencies,
195    } = component;
196
197    if !ai_models.is_empty() {
198        surprises.push("ai_models");
199    }
200    if !allowed_http_hosts.is_empty() {
201        surprises.push("allowed_http_hosts");
202    }
203    if !allowed_outbound_hosts.is_empty() {
204        surprises.push("allowed_outbound_hosts");
205    }
206    if !dependencies.inner.is_empty() {
207        surprises.push("dependencies");
208    }
209    if !environment.is_empty() {
210        surprises.push("environment");
211    }
212    if !files.is_empty() {
213        surprises.push("files");
214    }
215    if !key_value_stores.is_empty() {
216        surprises.push("key_value_stores");
217    }
218    if !sqlite_databases.is_empty() {
219        surprises.push("sqlite_databases");
220    }
221    if !variables.is_empty() {
222        surprises.push("variables");
223    }
224
225    if surprises.is_empty() {
226        Ok(())
227    } else {
228        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(", "));
229    }
230}
231
232#[cfg(test)]
233mod test {
234    use super::*;
235
236    use serde::Deserialize;
237    use toml::toml;
238
239    fn package_name(name: &str) -> spin_serde::DependencyName {
240        let dpn = spin_serde::DependencyPackageName::try_from(name.to_string()).unwrap();
241        spin_serde::DependencyName::Package(dpn)
242    }
243
244    #[test]
245    fn can_resolve_dependency_on_file_source() {
246        let mut manifest = AppManifest::deserialize(toml! {
247            spin_manifest_version = 2
248
249            [application]
250            name = "dummy"
251
252            [[trigger.dummy]]
253            component = "a"
254
255            [component.a]
256            source = "a.wasm"
257            [component.a.dependencies]
258            "b:b" = { component = "b" }
259
260            [component.b]
261            source = "b.wasm"
262        })
263        .unwrap();
264
265        normalize_manifest(&mut manifest).unwrap();
266
267        let dep = manifest
268            .components
269            .get("a")
270            .unwrap()
271            .dependencies
272            .inner
273            .get(&package_name("b:b"))
274            .unwrap();
275
276        let ComponentDependency::Local { path, export } = dep else {
277            panic!("should have normalised to local dep");
278        };
279
280        assert_eq!(&PathBuf::from("b.wasm"), path);
281        assert_eq!(&None, export);
282    }
283
284    #[test]
285    fn can_resolve_dependency_on_http_source() {
286        let mut manifest = AppManifest::deserialize(toml! {
287            spin_manifest_version = 2
288
289            [application]
290            name = "dummy"
291
292            [[trigger.dummy]]
293            component = "a"
294
295            [component.a]
296            source = "a.wasm"
297            [component.a.dependencies]
298            "b:b" = { component = "b", export = "c:d/e" }
299
300            [component.b]
301            source = { url = "http://example.com/b.wasm", digest = "12345" }
302        })
303        .unwrap();
304
305        normalize_manifest(&mut manifest).unwrap();
306
307        let dep = manifest
308            .components
309            .get("a")
310            .unwrap()
311            .dependencies
312            .inner
313            .get(&package_name("b:b"))
314            .unwrap();
315
316        let ComponentDependency::HTTP {
317            url,
318            digest,
319            export,
320        } = dep
321        else {
322            panic!("should have normalised to HTTP dep");
323        };
324
325        assert_eq!("http://example.com/b.wasm", url);
326        assert_eq!("12345", digest);
327        assert_eq!("c:d/e", export.as_ref().unwrap());
328    }
329
330    #[test]
331    fn can_resolve_dependency_on_package() {
332        let mut manifest = AppManifest::deserialize(toml! {
333            spin_manifest_version = 2
334
335            [application]
336            name = "dummy"
337
338            [[trigger.dummy]]
339            component = "a"
340
341            [component.a]
342            source = "a.wasm"
343            [component.a.dependencies]
344            "b:b" = { component = "b" }
345
346            [component.b]
347            source = { package = "bb:bb", version = "1.2.3", registry = "reginalds-registry.reg" }
348        })
349        .unwrap();
350
351        normalize_manifest(&mut manifest).unwrap();
352
353        let dep = manifest
354            .components
355            .get("a")
356            .unwrap()
357            .dependencies
358            .inner
359            .get(&package_name("b:b"))
360            .unwrap();
361
362        let ComponentDependency::Package {
363            version,
364            registry,
365            package,
366            export,
367        } = dep
368        else {
369            panic!("should have normalised to HTTP dep");
370        };
371
372        assert_eq!("1.2.3", version);
373        assert_eq!("reginalds-registry.reg", registry.as_ref().unwrap());
374        assert_eq!("bb:bb", package.as_ref().unwrap());
375        assert_eq!(&None, export);
376    }
377}