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