spin_manifest/
normalize.rs1use std::{collections::HashSet, path::PathBuf};
4
5use crate::schema::v2::{AppManifest, ComponentSpec, KebabId};
6use anyhow::Context;
7
8pub 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 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 let mut id = KebabId::try_from(format!("{trigger_id}-component"));
47 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 let inline_spec = std::mem::replace(spec, ComponentSpec::Reference(inline_id.clone()));
65 let ComponentSpec::Inline(component) = inline_spec else {
66 unreachable!();
67 };
68 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 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 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 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
164fn 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 #[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}