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_inherit_configuration(manifest)?;
17    normalize_dependency_component_refs(manifest)?;
18    Ok(())
19}
20
21fn normalize_inline_components(manifest: &mut AppManifest) {
22    // Normalize inline components
23    let components = &mut manifest.components;
24
25    for trigger in manifest.triggers.values_mut().flatten() {
26        let trigger_id = &trigger.id;
27
28        let component_specs = trigger
29            .component
30            .iter_mut()
31            .chain(
32                trigger
33                    .components
34                    .values_mut()
35                    .flat_map(|specs| specs.0.iter_mut()),
36            )
37            .collect::<Vec<_>>();
38        let multiple_components = component_specs.len() > 1;
39
40        let mut counter = 1;
41        for spec in component_specs {
42            if !matches!(spec, ComponentSpec::Inline(_)) {
43                continue;
44            };
45
46            let inline_id = {
47                // Try a "natural" component ID...
48                let mut id = KebabId::try_from(format!("{trigger_id}-component"));
49                // ...falling back to a counter-based component ID
50                if multiple_components
51                    || id.is_err()
52                    || components.contains_key(id.as_ref().unwrap())
53                {
54                    id = Ok(loop {
55                        let id = KebabId::try_from(format!("inline-component{counter}")).unwrap();
56                        if !components.contains_key(&id) {
57                            break id;
58                        }
59                        counter += 1;
60                    });
61                }
62                id.unwrap()
63            };
64
65            // Replace the inline component with a reference...
66            let inline_spec = std::mem::replace(spec, ComponentSpec::Reference(inline_id.clone()));
67            let ComponentSpec::Inline(component) = inline_spec else {
68                unreachable!();
69            };
70            // ...moving the inline component into the top-level components map.
71            components.insert(inline_id.clone(), *component);
72        }
73    }
74}
75
76fn normalize_trigger_ids(manifest: &mut AppManifest) {
77    let mut trigger_ids = manifest
78        .triggers
79        .values()
80        .flatten()
81        .cloned()
82        .map(|t| t.id)
83        .collect::<HashSet<_>>();
84    for (trigger_type, triggers) in &mut manifest.triggers {
85        let mut counter = 1;
86        for trigger in triggers {
87            if !trigger.id.is_empty() {
88                continue;
89            }
90            // Try to assign a "natural" ID to this trigger
91            if let Some(ComponentSpec::Reference(component_id)) = &trigger.component {
92                let candidate_id = format!("{component_id}-{trigger_type}-trigger");
93                if !trigger_ids.contains(&candidate_id) {
94                    trigger.id.clone_from(&candidate_id);
95                    trigger_ids.insert(candidate_id);
96                    continue;
97                }
98            }
99            // Fall back to assigning a counter-based trigger ID
100            trigger.id = loop {
101                let id = format!("{trigger_type}-trigger{counter}");
102                if !trigger_ids.contains(&id) {
103                    trigger_ids.insert(id.clone());
104                    break id;
105                }
106                counter += 1;
107            }
108        }
109    }
110}
111
112fn apply_profile_overrides(manifest: &mut AppManifest, profile: Option<&str>) {
113    let Some(profile) = profile else {
114        return;
115    };
116
117    for (_, component) in &mut manifest.components {
118        let Some(overrides) = component.profile.get(profile) else {
119            continue;
120        };
121
122        if let Some(profile_build) = overrides.build.as_ref() {
123            match component.build.as_mut() {
124                None => {
125                    component.build = Some(crate::schema::v2::ComponentBuildConfig {
126                        command: profile_build.command.clone(),
127                        workdir: None,
128                        watch: vec![],
129                    })
130                }
131                Some(build) => {
132                    build.command = profile_build.command.clone();
133                }
134            }
135        }
136
137        if let Some(source) = overrides.source.as_ref() {
138            component.source = source.clone();
139        }
140
141        component.environment.extend(overrides.environment.clone());
142
143        component
144            .dependencies
145            .inner
146            .extend(overrides.dependencies.inner.clone());
147    }
148}
149
150use crate::schema::v2::{Component, ComponentDependency, ComponentSource, InheritConfiguration};
151
152/// Validates that `dependencies_inherit_configuration` and per-dependency
153/// `inherit_configuration` are not used simultaneously, then normalizes the
154/// component-level field into per-dependency `inherit_configuration` values.
155fn normalize_dependency_inherit_configuration(manifest: &mut AppManifest) -> anyhow::Result<()> {
156    for (component_id, component) in &mut manifest.components {
157        let component_level = component.dependencies_inherit_configuration;
158
159        let has_per_dep = component
160            .dependencies
161            .inner
162            .values()
163            .any(|dep| dep.inherit_configuration().is_some());
164
165        if component_level.is_some() && has_per_dep {
166            anyhow::bail!(
167                "Component `{component_id}` specifies both `dependencies_inherit_configuration` \
168                 and per-dependency `inherit_configuration`. These are mutually exclusive; \
169                 use one or the other."
170            );
171        }
172
173        if component_level == Some(true) {
174            let inherit = InheritConfiguration::All(true);
175            for dep in component.dependencies.inner.values_mut() {
176                dep.set_inherit_configuration(inherit.clone());
177            }
178            component.dependencies_inherit_configuration = None;
179        }
180    }
181
182    Ok(())
183}
184
185fn normalize_dependency_component_refs(manifest: &mut AppManifest) -> anyhow::Result<()> {
186    // `clone` a snapshot, because we are about to mutate collection elements,
187    // and the borrow checker gets mad at us if we try to index into the collection
188    // while that's happening.
189    let components = manifest.components.clone();
190
191    for (depender_id, component) in &mut manifest.components {
192        for dependency in component.dependencies.inner.values_mut() {
193            if let ComponentDependency::AppComponent {
194                component: depended_on_id,
195                export,
196                inherit_configuration,
197            } = dependency
198            {
199                let depended_on = components
200                    .get(depended_on_id)
201                    .with_context(|| format!("dependency ID {depended_on_id} does not exist"))?;
202                ensure_is_acceptable_dependency(depended_on, depended_on_id, depender_id)?;
203                *dependency = component_source_to_dependency(
204                    &depended_on.source,
205                    export.clone(),
206                    inherit_configuration.clone(),
207                );
208            }
209        }
210    }
211
212    Ok(())
213}
214
215fn component_source_to_dependency(
216    source: &ComponentSource,
217    export: Option<String>,
218    inherit_configuration: Option<InheritConfiguration>,
219) -> ComponentDependency {
220    match source {
221        ComponentSource::Local(path) => ComponentDependency::Local {
222            path: PathBuf::from(path),
223            export,
224            inherit_configuration,
225        },
226        ComponentSource::Remote { url, digest } => ComponentDependency::HTTP {
227            url: url.clone(),
228            digest: digest.clone(),
229            export,
230            inherit_configuration,
231        },
232        ComponentSource::Registry {
233            registry,
234            package,
235            version,
236        } => ComponentDependency::Package {
237            version: version.clone(),
238            registry: registry.as_ref().map(|r| r.to_string()),
239            package: Some(package.to_string()),
240            export,
241            inherit_configuration,
242        },
243    }
244}
245
246/// If a dependency has things like files or KV stores or network access...
247/// those won't apply when it's composed, and that's likely to be surprising,
248/// and developers hate surprises.
249fn ensure_is_acceptable_dependency(
250    component: &Component,
251    depended_on_id: &KebabId,
252    depender_id: &KebabId,
253) -> anyhow::Result<()> {
254    let mut surprises = vec![];
255
256    // Explicitly discard fields we don't need to check (do *not* .. them away). This
257    // way, the compiler will give us a heads up if a new field is added so we can
258    // decide whether or not we need to check it.
259    #[allow(deprecated)]
260    let Component {
261        source: _,
262        description: _,
263        variables,
264        environment,
265        files,
266        exclude_files: _,
267        allowed_http_hosts,
268        allowed_outbound_hosts,
269        key_value_stores,
270        sqlite_databases,
271        ai_models,
272        targets: _,
273        build: _,
274        tool: _,
275        dependencies_inherit_configuration: _,
276        dependencies,
277        profile: _,
278    } = component;
279
280    if !ai_models.is_empty() {
281        surprises.push("ai_models");
282    }
283    if !allowed_http_hosts.is_empty() {
284        surprises.push("allowed_http_hosts");
285    }
286    if !allowed_outbound_hosts.is_empty() {
287        surprises.push("allowed_outbound_hosts");
288    }
289    if !dependencies.inner.is_empty() {
290        surprises.push("dependencies");
291    }
292    if !environment.is_empty() {
293        surprises.push("environment");
294    }
295    if !files.is_empty() {
296        surprises.push("files");
297    }
298    if !key_value_stores.is_empty() {
299        surprises.push("key_value_stores");
300    }
301    if !sqlite_databases.is_empty() {
302        surprises.push("sqlite_databases");
303    }
304    if !variables.is_empty() {
305        surprises.push("variables");
306    }
307
308    if surprises.is_empty() {
309        Ok(())
310    } else {
311        anyhow::bail!(
312            "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: {}",
313            surprises.join(", ")
314        );
315    }
316}
317
318#[cfg(test)]
319mod test {
320    use super::*;
321
322    use crate::schema::v2::InheritConfiguration;
323    use serde::Deserialize;
324    use toml::toml;
325
326    fn package_name(name: &str) -> spin_serde::DependencyName {
327        let dpn = spin_serde::DependencyPackageName::try_from(name.to_string()).unwrap();
328        spin_serde::DependencyName::Package(dpn)
329    }
330
331    #[test]
332    fn can_resolve_dependency_on_file_source() {
333        let mut manifest = AppManifest::deserialize(toml! {
334            spin_manifest_version = 2
335
336            [application]
337            name = "dummy"
338
339            [[trigger.dummy]]
340            component = "a"
341
342            [component.a]
343            source = "a.wasm"
344            [component.a.dependencies]
345            "b:b" = { component = "b" }
346
347            [component.b]
348            source = "b.wasm"
349        })
350        .unwrap();
351
352        normalize_manifest(&mut manifest, None).unwrap();
353
354        let dep = manifest
355            .components
356            .get("a")
357            .unwrap()
358            .dependencies
359            .inner
360            .get(&package_name("b:b"))
361            .unwrap();
362
363        let ComponentDependency::Local {
364            path,
365            export,
366            inherit_configuration,
367        } = dep
368        else {
369            panic!("should have normalised to local dep");
370        };
371
372        assert_eq!(&PathBuf::from("b.wasm"), path);
373        assert_eq!(&None, export);
374        assert!(inherit_configuration.is_none());
375    }
376
377    #[test]
378    fn can_resolve_dependency_on_http_source() {
379        let mut manifest = AppManifest::deserialize(toml! {
380            spin_manifest_version = 2
381
382            [application]
383            name = "dummy"
384
385            [[trigger.dummy]]
386            component = "a"
387
388            [component.a]
389            source = "a.wasm"
390            [component.a.dependencies]
391            "b:b" = { component = "b", export = "c:d/e" }
392
393            [component.b]
394            source = { url = "http://example.com/b.wasm", digest = "12345" }
395        })
396        .unwrap();
397
398        normalize_manifest(&mut manifest, None).unwrap();
399
400        let dep = manifest
401            .components
402            .get("a")
403            .unwrap()
404            .dependencies
405            .inner
406            .get(&package_name("b:b"))
407            .unwrap();
408
409        let ComponentDependency::HTTP {
410            url,
411            digest,
412            export,
413            inherit_configuration,
414        } = dep
415        else {
416            panic!("should have normalised to HTTP dep");
417        };
418
419        assert_eq!("http://example.com/b.wasm", url);
420        assert_eq!("12345", digest);
421        assert_eq!("c:d/e", export.as_ref().unwrap());
422        assert!(inherit_configuration.is_none());
423    }
424
425    #[test]
426    fn can_resolve_dependency_on_package() {
427        let mut manifest = AppManifest::deserialize(toml! {
428            spin_manifest_version = 2
429
430            [application]
431            name = "dummy"
432
433            [[trigger.dummy]]
434            component = "a"
435
436            [component.a]
437            source = "a.wasm"
438            [component.a.dependencies]
439            "b:b" = { component = "b" }
440
441            [component.b]
442            source = { package = "bb:bb", version = "1.2.3", registry = "reginalds-registry.reg" }
443        })
444        .unwrap();
445
446        normalize_manifest(&mut manifest, None).unwrap();
447
448        let dep = manifest
449            .components
450            .get("a")
451            .unwrap()
452            .dependencies
453            .inner
454            .get(&package_name("b:b"))
455            .unwrap();
456
457        let ComponentDependency::Package {
458            version,
459            registry,
460            package,
461            export,
462            inherit_configuration,
463        } = dep
464        else {
465            panic!("should have normalised to package dep");
466        };
467
468        assert_eq!("1.2.3", version);
469        assert_eq!("reginalds-registry.reg", registry.as_ref().unwrap());
470        assert_eq!("bb:bb", package.as_ref().unwrap());
471        assert_eq!(&None, export);
472        assert!(inherit_configuration.is_none());
473    }
474
475    #[test]
476    fn can_resolve_dependency_with_inherit() {
477        let mut manifest = AppManifest::deserialize(toml! {
478            spin_manifest_version = 2
479
480            [application]
481            name = "dummy"
482
483            [[trigger.dummy]]
484            component = "a"
485
486            [component.a]
487            source = "a.wasm"
488            [component.a.dependencies]
489            "b:b" = { component = "b", inherit_configuration = true }
490
491            [component.b]
492            source = "b.wasm"
493        })
494        .unwrap();
495
496        normalize_manifest(&mut manifest, None).unwrap();
497
498        let dep = manifest
499            .components
500            .get("a")
501            .unwrap()
502            .dependencies
503            .inner
504            .get(&package_name("b:b"))
505            .unwrap();
506
507        let ComponentDependency::Local {
508            path,
509            export,
510            inherit_configuration,
511        } = dep
512        else {
513            panic!("should have normalised to local dep");
514        };
515
516        assert_eq!(&PathBuf::from("b.wasm"), path);
517        assert_eq!(&None, export);
518        assert!(matches!(
519            inherit_configuration,
520            Some(InheritConfiguration::All(true))
521        ));
522    }
523
524    #[test]
525    fn can_resolve_dependency_with_inherit_some() {
526        let mut manifest = AppManifest::deserialize(toml! {
527            spin_manifest_version = 2
528
529            [application]
530            name = "dummy"
531
532            [[trigger.dummy]]
533            component = "a"
534
535            [component.a]
536            source = "a.wasm"
537            [component.a.dependencies]
538            "b:b" = { component = "b", inherit_configuration = ["ai_models", "allowed_outbound_hosts"] }
539
540            [component.b]
541            source = "b.wasm"
542        })
543        .unwrap();
544
545        normalize_manifest(&mut manifest, None).unwrap();
546
547        let dep = manifest
548            .components
549            .get("a")
550            .unwrap()
551            .dependencies
552            .inner
553            .get(&package_name("b:b"))
554            .unwrap();
555
556        let ComponentDependency::Local {
557            path,
558            export,
559            inherit_configuration,
560        } = dep
561        else {
562            panic!("should have normalised to local dep");
563        };
564
565        assert_eq!(&PathBuf::from("b.wasm"), path);
566        assert_eq!(&None, export);
567        let Some(InheritConfiguration::Some(keys)) = inherit_configuration else {
568            panic!("should have inherit_configuration = Some([...])");
569        };
570        assert_eq!(
571            &vec![
572                "ai_models".to_string(),
573                "allowed_outbound_hosts".to_string()
574            ],
575            keys
576        );
577    }
578}