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    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::{
150    Component, ComponentDependency, ComponentSource, InheritConfiguration, TriggerDependency,
151};
152
153/// Validates that `dependencies_inherit_configuration` and per-dependency
154/// `inherit_configuration` are not used simultaneously, then normalizes the
155/// component-level field into per-dependency `inherit_configuration` values.
156fn normalize_dependency_inherit_configuration(manifest: &mut AppManifest) -> anyhow::Result<()> {
157    for (component_id, component) in &mut manifest.components {
158        let component_level = component.dependencies_inherit_configuration;
159
160        let has_per_dep = component
161            .dependencies
162            .inner
163            .values()
164            .any(|dep| dep.inherit_configuration().is_some());
165
166        if component_level.is_some() && has_per_dep {
167            anyhow::bail!(
168                "Component `{component_id}` specifies both `dependencies_inherit_configuration` \
169                 and per-dependency `inherit_configuration`. These are mutually exclusive; \
170                 use one or the other."
171            );
172        }
173
174        if component_level == Some(true) {
175            let inherit = InheritConfiguration::All(true);
176            for dep in component.dependencies.inner.values_mut() {
177                dep.set_inherit_configuration(inherit.clone());
178            }
179            component.dependencies_inherit_configuration = None;
180        }
181    }
182
183    Ok(())
184}
185
186fn normalize_dependency_component_refs(manifest: &mut AppManifest) -> anyhow::Result<()> {
187    // `clone` a snapshot, because we are about to mutate collection elements,
188    // and the borrow checker gets mad at us if we try to index into the collection
189    // while that's happening.
190    let components = manifest.components.clone();
191
192    for (depender_id, component) in &mut manifest.components {
193        for dependency in component.dependencies.inner.values_mut() {
194            if let ComponentDependency::AppComponent {
195                component: depended_on_id,
196                export,
197                inherit_configuration,
198            } = dependency
199            {
200                let depended_on = components
201                    .get(depended_on_id)
202                    .with_context(|| format!("dependency ID {depended_on_id} does not exist"))?;
203                ensure_is_acceptable_dependency(depended_on, depended_on_id, depender_id.as_ref())?;
204                *dependency = component_source_to_dependency(
205                    &depended_on.source,
206                    export.clone(),
207                    inherit_configuration.clone(),
208                );
209            }
210        }
211    }
212
213    for (_, triggers) in &mut manifest.triggers {
214        for trigger in triggers {
215            for (_, deps) in &mut trigger.dependencies {
216                for dependency in &mut deps.0 {
217                    if let TriggerDependency::AppComponent {
218                        component: depended_on_id,
219                        inherit_configuration,
220                    } = dependency
221                    {
222                        let depended_on = components.get(depended_on_id).with_context(|| {
223                            format!("dependency ID {depended_on_id} does not exist")
224                        })?;
225                        ensure_is_acceptable_dependency(depended_on, depended_on_id, &trigger.id)?;
226                        *dependency = component_source_to_trigger_dependency(
227                            &depended_on.source,
228                            inherit_configuration.clone(),
229                        );
230                    }
231                }
232            }
233        }
234    }
235
236    Ok(())
237}
238
239fn component_source_to_dependency(
240    source: &ComponentSource,
241    export: Option<String>,
242    inherit_configuration: Option<InheritConfiguration>,
243) -> ComponentDependency {
244    match source {
245        ComponentSource::Local(path) => ComponentDependency::Local {
246            path: PathBuf::from(path),
247            export,
248            inherit_configuration,
249        },
250        ComponentSource::Remote { url, digest } => ComponentDependency::HTTP {
251            url: url.clone(),
252            digest: digest.clone(),
253            export,
254            inherit_configuration,
255        },
256        ComponentSource::Registry {
257            registry,
258            package,
259            version,
260        } => ComponentDependency::Package {
261            version: version.clone(),
262            registry: registry.as_ref().map(|r| r.to_string()),
263            package: Some(package.to_string()),
264            export,
265            inherit_configuration,
266        },
267    }
268}
269
270fn component_source_to_trigger_dependency(
271    source: &ComponentSource,
272    inherit_configuration: Option<InheritConfiguration>,
273) -> TriggerDependency {
274    match source {
275        ComponentSource::Local(path) => TriggerDependency::Local {
276            path: PathBuf::from(path),
277            inherit_configuration,
278        },
279        ComponentSource::Remote { url, digest } => TriggerDependency::HTTP {
280            url: url.clone(),
281            digest: digest.clone(),
282            inherit_configuration,
283        },
284        ComponentSource::Registry {
285            registry,
286            package,
287            version,
288        } => TriggerDependency::Package {
289            version: version.clone(),
290            registry: registry.as_ref().map(|r| r.to_string()),
291            package: package.to_string(),
292            inherit_configuration,
293        },
294    }
295}
296
297/// If a dependency has things like files or KV stores or network access...
298/// those won't apply when it's composed, and that's likely to be surprising,
299/// and developers hate surprises.
300fn ensure_is_acceptable_dependency(
301    component: &Component,
302    depended_on_id: &KebabId,
303    depender_id: &str,
304) -> anyhow::Result<()> {
305    let mut surprises = vec![];
306
307    // Explicitly discard fields we don't need to check (do *not* .. them away). This
308    // way, the compiler will give us a heads up if a new field is added so we can
309    // decide whether or not we need to check it.
310    #[allow(deprecated)]
311    let Component {
312        source: _,
313        description: _,
314        variables,
315        environment,
316        files,
317        exclude_files: _,
318        allowed_http_hosts,
319        allowed_outbound_hosts,
320        key_value_stores,
321        sqlite_databases,
322        ai_models,
323        targets: _,
324        build: _,
325        tool: _,
326        dependencies_inherit_configuration: _,
327        dependencies,
328        profile: _,
329    } = component;
330
331    if !ai_models.is_empty() {
332        surprises.push("ai_models");
333    }
334    if !allowed_http_hosts.is_empty() {
335        surprises.push("allowed_http_hosts");
336    }
337    if !allowed_outbound_hosts.is_empty() {
338        surprises.push("allowed_outbound_hosts");
339    }
340    if !dependencies.inner.is_empty() {
341        surprises.push("dependencies");
342    }
343    if !environment.is_empty() {
344        surprises.push("environment");
345    }
346    if !files.is_empty() {
347        surprises.push("files");
348    }
349    if !key_value_stores.is_empty() {
350        surprises.push("key_value_stores");
351    }
352    if !sqlite_databases.is_empty() {
353        surprises.push("sqlite_databases");
354    }
355    if !variables.is_empty() {
356        surprises.push("variables");
357    }
358
359    if surprises.is_empty() {
360        Ok(())
361    } else {
362        anyhow::bail!(
363            "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: {}",
364            surprises.join(", ")
365        );
366    }
367}
368
369#[cfg(test)]
370mod test {
371    use super::*;
372
373    use crate::schema::v2::InheritConfiguration;
374    use serde::Deserialize;
375    use toml::toml;
376
377    fn package_name(name: &str) -> spin_serde::DependencyName {
378        let dpn = spin_serde::DependencyPackageName::try_from(name.to_string()).unwrap();
379        spin_serde::DependencyName::Package(dpn)
380    }
381
382    #[test]
383    fn can_resolve_dependency_on_file_source() {
384        let mut manifest = AppManifest::deserialize(toml! {
385            spin_manifest_version = 2
386
387            [application]
388            name = "dummy"
389
390            [[trigger.dummy]]
391            component = "a"
392
393            [component.a]
394            source = "a.wasm"
395            [component.a.dependencies]
396            "b:b" = { component = "b" }
397
398            [component.b]
399            source = "b.wasm"
400        })
401        .unwrap();
402
403        normalize_manifest(&mut manifest, None).unwrap();
404
405        let dep = manifest
406            .components
407            .get("a")
408            .unwrap()
409            .dependencies
410            .inner
411            .get(&package_name("b:b"))
412            .unwrap();
413
414        let ComponentDependency::Local {
415            path,
416            export,
417            inherit_configuration,
418        } = dep
419        else {
420            panic!("should have normalised to local dep");
421        };
422
423        assert_eq!(&PathBuf::from("b.wasm"), path);
424        assert_eq!(&None, export);
425        assert!(inherit_configuration.is_none());
426    }
427
428    #[test]
429    fn can_resolve_dependency_on_http_source() {
430        let mut manifest = AppManifest::deserialize(toml! {
431            spin_manifest_version = 2
432
433            [application]
434            name = "dummy"
435
436            [[trigger.dummy]]
437            component = "a"
438
439            [component.a]
440            source = "a.wasm"
441            [component.a.dependencies]
442            "b:b" = { component = "b", export = "c:d/e" }
443
444            [component.b]
445            source = { url = "http://example.com/b.wasm", digest = "12345" }
446        })
447        .unwrap();
448
449        normalize_manifest(&mut manifest, None).unwrap();
450
451        let dep = manifest
452            .components
453            .get("a")
454            .unwrap()
455            .dependencies
456            .inner
457            .get(&package_name("b:b"))
458            .unwrap();
459
460        let ComponentDependency::HTTP {
461            url,
462            digest,
463            export,
464            inherit_configuration,
465        } = dep
466        else {
467            panic!("should have normalised to HTTP dep");
468        };
469
470        assert_eq!("http://example.com/b.wasm", url);
471        assert_eq!("12345", digest);
472        assert_eq!("c:d/e", export.as_ref().unwrap());
473        assert!(inherit_configuration.is_none());
474    }
475
476    #[test]
477    fn can_resolve_dependency_on_package() {
478        let mut manifest = AppManifest::deserialize(toml! {
479            spin_manifest_version = 2
480
481            [application]
482            name = "dummy"
483
484            [[trigger.dummy]]
485            component = "a"
486
487            [component.a]
488            source = "a.wasm"
489            [component.a.dependencies]
490            "b:b" = { component = "b" }
491
492            [component.b]
493            source = { package = "bb:bb", version = "1.2.3", registry = "reginalds-registry.reg" }
494        })
495        .unwrap();
496
497        normalize_manifest(&mut manifest, None).unwrap();
498
499        let dep = manifest
500            .components
501            .get("a")
502            .unwrap()
503            .dependencies
504            .inner
505            .get(&package_name("b:b"))
506            .unwrap();
507
508        let ComponentDependency::Package {
509            version,
510            registry,
511            package,
512            export,
513            inherit_configuration,
514        } = dep
515        else {
516            panic!("should have normalised to package dep");
517        };
518
519        assert_eq!("1.2.3", version);
520        assert_eq!("reginalds-registry.reg", registry.as_ref().unwrap());
521        assert_eq!("bb:bb", package.as_ref().unwrap());
522        assert_eq!(&None, export);
523        assert!(inherit_configuration.is_none());
524    }
525
526    #[test]
527    fn can_resolve_dependency_with_inherit() {
528        let mut manifest = AppManifest::deserialize(toml! {
529            spin_manifest_version = 2
530
531            [application]
532            name = "dummy"
533
534            [[trigger.dummy]]
535            component = "a"
536
537            [component.a]
538            source = "a.wasm"
539            [component.a.dependencies]
540            "b:b" = { component = "b", inherit_configuration = true }
541
542            [component.b]
543            source = "b.wasm"
544        })
545        .unwrap();
546
547        normalize_manifest(&mut manifest, None).unwrap();
548
549        let dep = manifest
550            .components
551            .get("a")
552            .unwrap()
553            .dependencies
554            .inner
555            .get(&package_name("b:b"))
556            .unwrap();
557
558        let ComponentDependency::Local {
559            path,
560            export,
561            inherit_configuration,
562        } = dep
563        else {
564            panic!("should have normalised to local dep");
565        };
566
567        assert_eq!(&PathBuf::from("b.wasm"), path);
568        assert_eq!(&None, export);
569        assert!(matches!(
570            inherit_configuration,
571            Some(InheritConfiguration::All(true))
572        ));
573    }
574
575    #[test]
576    fn can_resolve_dependency_with_inherit_some() {
577        let mut manifest = AppManifest::deserialize(toml! {
578            spin_manifest_version = 2
579
580            [application]
581            name = "dummy"
582
583            [[trigger.dummy]]
584            component = "a"
585
586            [component.a]
587            source = "a.wasm"
588            [component.a.dependencies]
589            "b:b" = { component = "b", inherit_configuration = ["ai_models", "allowed_outbound_hosts"] }
590
591            [component.b]
592            source = "b.wasm"
593        })
594        .unwrap();
595
596        normalize_manifest(&mut manifest, None).unwrap();
597
598        let dep = manifest
599            .components
600            .get("a")
601            .unwrap()
602            .dependencies
603            .inner
604            .get(&package_name("b:b"))
605            .unwrap();
606
607        let ComponentDependency::Local {
608            path,
609            export,
610            inherit_configuration,
611        } = dep
612        else {
613            panic!("should have normalised to local dep");
614        };
615
616        assert_eq!(&PathBuf::from("b.wasm"), path);
617        assert_eq!(&None, export);
618        let Some(InheritConfiguration::Some(keys)) = inherit_configuration else {
619            panic!("should have inherit_configuration = Some([...])");
620        };
621        assert_eq!(
622            &vec![
623                "ai_models".to_string(),
624                "allowed_outbound_hosts".to_string()
625            ],
626            keys
627        );
628    }
629}