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 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}