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