1use anyhow::{Context, anyhow};
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use spin_serde::{DependencyName, DependencyPackageName, FixedVersion, LowerSnakeId};
5pub use spin_serde::{KebabId, SnakeId};
6
7pub use super::common::{ComponentBuildConfig, ComponentSource, Variable, WasiFilesMount};
8use super::{json_schema, kebab_or_snake_case, one_or_many};
9
10pub(crate) type Map<K, V> = indexmap::IndexMap<K, V>;
11
12mod app_details;
13mod component;
14mod dependency;
15mod target_env;
16mod trigger;
17
18pub use app_details::AppDetails;
19pub use component::{
20 Component, ComponentDependencies, ComponentProfileBuildOverride, ComponentProfileOverride,
21};
22pub use dependency::{ComponentDependency, InheritConfiguration, TriggerDependency};
23pub use target_env::TargetEnvironmentRef;
24pub use trigger::{ComponentSpec, OneOrManyComponentSpecs, Trigger, TriggerDependencies};
25
26#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
28#[serde(deny_unknown_fields)]
29pub struct AppManifest {
30 #[schemars(with = "usize", range(min = 2, max = 2))]
32 pub spin_manifest_version: FixedVersion<2>,
33 pub application: AppDetails,
35 #[serde(default, skip_serializing_if = "Map::is_empty")]
41 pub variables: Map<LowerSnakeId, Variable>,
42 #[serde(rename = "trigger")]
49 #[serde(default)]
50 #[schemars(with = "json_schema::TriggerSchema")]
51 pub triggers: Map<String, Vec<Trigger>>,
52 #[serde(rename = "component")]
54 #[serde(default, skip_serializing_if = "Map::is_empty")]
55 pub components: Map<KebabId, Component>,
56}
57
58impl AppManifest {
59 pub fn validate_required_fields(&self) -> anyhow::Result<()> {
61 if self.triggers.is_empty() {
62 anyhow::bail!("The application must have at least one trigger");
63 }
64
65 Ok(())
66 }
67
68 pub fn validate_dependencies(&self) -> anyhow::Result<()> {
70 for (component_id, component) in &self.components {
71 component
72 .dependencies
73 .validate()
74 .with_context(|| format!("component {component_id:?} has invalid dependencies"))?;
75 }
76 Ok(())
77 }
78
79 pub fn ensure_profile(&self, profile: Option<&str>) -> anyhow::Result<()> {
85 let Some(p) = profile else {
86 return Ok(());
87 };
88
89 let is_defined = self.components.values().any(|c| c.profile.contains_key(p));
90
91 if is_defined {
92 Ok(())
93 } else {
94 Err(anyhow!("Profile {p} is not defined in this application"))
95 }
96 }
97}
98
99#[cfg(test)]
100mod tests {
101 use std::path::PathBuf;
102 use toml::toml;
103
104 use super::*;
105
106 #[derive(Deserialize)]
107 #[allow(dead_code)]
108 struct FakeGlobalTriggerConfig {
109 global_option: bool,
110 }
111
112 #[derive(Deserialize)]
113 #[allow(dead_code)]
114 struct FakeTriggerConfig {
115 option: Option<bool>,
116 }
117
118 fn as_reference(spec: &ComponentSpec) -> Option<&str> {
119 match spec {
120 ComponentSpec::Reference(id) => Some(id.as_ref()),
121 ComponentSpec::Inline(_) => None,
122 }
123 }
124
125 fn as_inline(spec: &ComponentSpec) -> Option<&Component> {
126 match spec {
127 ComponentSpec::Reference(_) => None,
128 ComponentSpec::Inline(c) => Some(c),
129 }
130 }
131
132 fn as_local(source: &ComponentSource) -> Option<&str> {
133 match source {
134 ComponentSource::Local(path) => Some(path),
135 _ => None,
136 }
137 }
138
139 #[test]
140 fn deserializing_trigger_configs() {
141 let manifest = AppManifest::deserialize(toml! {
142 spin_manifest_version = 2
143 [application]
144 name = "trigger-configs"
145 [application.trigger.fake]
146 global_option = true
147 [[trigger.fake]]
148 component = { source = "inline.wasm" }
149 option = true
150 })
151 .unwrap();
152
153 FakeGlobalTriggerConfig::deserialize(
154 manifest.application.trigger_global_configs["fake"].clone(),
155 )
156 .unwrap();
157
158 FakeTriggerConfig::deserialize(manifest.triggers["fake"][0].config.clone()).unwrap();
159 }
160
161 #[derive(Deserialize)]
162 #[allow(dead_code)]
163 struct FakeGlobalToolConfig {
164 lint_level: String,
165 }
166
167 #[derive(Deserialize)]
168 #[allow(dead_code)]
169 struct FakeComponentToolConfig {
170 command: String,
171 }
172
173 #[test]
174 fn deserialising_custom_tool_settings() {
175 let manifest = AppManifest::deserialize(toml! {
176 spin_manifest_version = 2
177 [application]
178 name = "trigger-configs"
179 [application.tool.lint]
180 lint_level = "savage"
181 [[trigger.fake]]
182 something = "something else"
183 [component.fake]
184 source = "dummy"
185 [component.fake.tool.clean]
186 command = "cargo clean"
187 })
188 .unwrap();
189
190 FakeGlobalToolConfig::deserialize(manifest.application.tool["lint"].clone()).unwrap();
191 let fake_id: KebabId = "fake".to_owned().try_into().unwrap();
192 FakeComponentToolConfig::deserialize(manifest.components[&fake_id].tool["clean"].clone())
193 .unwrap();
194 }
195
196 #[test]
197 fn deserializing_labels() {
198 AppManifest::deserialize(toml! {
199 spin_manifest_version = 2
200 [application]
201 name = "trigger-configs"
202 [[trigger.fake]]
203 something = "something else"
204 [component.fake]
205 source = "dummy"
206 key_value_stores = ["default", "snake_case", "kebab-case"]
207 sqlite_databases = ["default", "snake_case", "kebab-case"]
208 })
209 .unwrap();
210 }
211
212 #[test]
213 fn deserializing_labels_fails_for_non_kebab_or_snake() {
214 assert!(
215 AppManifest::deserialize(toml! {
216 spin_manifest_version = 2
217 [application]
218 name = "trigger-configs"
219 [[trigger.fake]]
220 something = "something else"
221 [component.fake]
222 source = "dummy"
223 key_value_stores = ["b@dlabel"]
224 })
225 .is_err()
226 );
227 }
228
229 fn get_test_component_with_labels(labels: Vec<String>) -> Component {
230 #[allow(deprecated)]
231 Component {
232 source: ComponentSource::Local("dummy".to_string()),
233 description: "".to_string(),
234 variables: Map::new(),
235 environment: Map::new(),
236 files: vec![],
237 exclude_files: vec![],
238 allowed_http_hosts: vec![],
239 allowed_outbound_hosts: vec![],
240 key_value_stores: labels.clone(),
241 sqlite_databases: labels,
242 ai_models: vec![],
243 targets: None,
244 build: None,
245 tool: Map::new(),
246 dependencies_inherit_configuration: None,
247 dependencies: Default::default(),
248 profile: Default::default(),
249 }
250 }
251
252 #[test]
253 fn serialize_labels() {
254 let stores = vec![
255 "default".to_string(),
256 "snake_case".to_string(),
257 "kebab-case".to_string(),
258 ];
259 let component = get_test_component_with_labels(stores.clone());
260 let serialized = toml::to_string(&component).unwrap();
261 let deserialized = toml::from_str::<Component>(&serialized).unwrap();
262 assert_eq!(deserialized.key_value_stores, stores);
263 }
264
265 #[test]
266 fn serialize_labels_fails_for_non_kebab_or_snake() {
267 let component = get_test_component_with_labels(vec!["camelCase".to_string()]);
268 assert!(toml::to_string(&component).is_err());
269 }
270
271 #[test]
272 fn test_valid_snake_ids() {
273 for valid in ["default", "mixed_CASE_words", "letters1_then2_numbers345"] {
274 if let Err(err) = SnakeId::try_from(valid.to_string()) {
275 panic!("{valid:?} should be value: {err:?}");
276 }
277 }
278 }
279
280 #[test]
281 fn test_invalid_snake_ids() {
282 for invalid in [
283 "",
284 "kebab-case",
285 "_leading_underscore",
286 "trailing_underscore_",
287 "double__underscore",
288 "1initial_number",
289 "unicode_snowpeople☃☃☃",
290 "mIxEd_case",
291 "MiXeD_case",
292 ] {
293 if SnakeId::try_from(invalid.to_string()).is_ok() {
294 panic!("{invalid:?} should not be a valid SnakeId");
295 }
296 }
297 }
298
299 #[test]
300 fn test_check_disjoint() {
301 for (a, b) in [
302 ("foo:bar@0.1.0", "foo:bar@0.2.0"),
303 ("foo:bar/baz@0.1.0", "foo:bar/baz@0.2.0"),
304 ("foo:bar/baz@0.1.0", "foo:bar/bub@0.1.0"),
305 ("foo:bar@0.1.0", "foo:bar/bub@0.2.0"),
306 ("foo:bar@1.0.0", "foo:bar@2.0.0"),
307 ("foo:bar@0.1.0", "foo:bar@1.0.0"),
308 ("foo:bar/baz", "foo:bar/bub"),
309 ("foo:bar/baz@0.1.0-alpha", "foo:bar/baz@0.1.0-beta"),
310 ] {
311 let a: DependencyPackageName = a.parse().expect(a);
312 let b: DependencyPackageName = b.parse().expect(b);
313 ComponentDependencies::check_disjoint(&a, &b).unwrap();
314 }
315
316 for (a, b) in [
317 ("foo:bar@0.1.0", "foo:bar@0.1.1"),
318 ("foo:bar/baz@0.1.0", "foo:bar@0.1.0"),
319 ("foo:bar/baz@0.1.0", "foo:bar@0.1.0"),
320 ("foo:bar", "foo:bar@0.1.0"),
321 ("foo:bar@0.1.0-pre", "foo:bar@0.1.0-pre"),
322 ] {
323 let a: DependencyPackageName = a.parse().expect(a);
324 let b: DependencyPackageName = b.parse().expect(b);
325 assert!(
326 ComponentDependencies::check_disjoint(&a, &b).is_err(),
327 "{a} should conflict with {b}",
328 );
329 }
330 }
331
332 #[test]
333 fn test_validate_dependencies() {
334 assert!(
336 ComponentDependencies::deserialize(toml! {
337 "plain-name" = "0.1.0"
338 })
339 .unwrap()
340 .validate()
341 .is_err()
342 );
343
344 assert!(
346 ComponentDependencies::deserialize(toml! {
347 "plain-name" = { version = "0.1.0" }
348 })
349 .unwrap()
350 .validate()
351 .is_err()
352 );
353
354 assert!(
356 ComponentDependencies::deserialize(toml! {
357 "foo:baz@0.1.0" = { path = "foo.wasm", export = "foo"}
358 })
359 .unwrap()
360 .validate()
361 .is_err()
362 );
363
364 assert!(
366 ComponentDependencies::deserialize(toml! {
367 "foo:baz@0.1.0" = "0.1.0"
368 "foo:bar@0.2.1" = "0.2.1"
369 "foo:bar@0.2.2" = "0.2.2"
370 })
371 .unwrap()
372 .validate()
373 .is_err()
374 );
375
376 assert!(
378 ComponentDependencies::deserialize(toml! {
379 "foo:bar@0.1.0" = "0.1.0"
380 "foo:bar@0.2.0" = "0.2.0"
381 "foo:baz@0.2.0" = "0.1.0"
382 })
383 .unwrap()
384 .validate()
385 .is_ok()
386 );
387
388 assert!(
390 ComponentDependencies::deserialize(toml! {
391 "foo:bar@0.1.0" = "0.1.0"
392 "foo:bar" = ">= 0.2.0"
393 })
394 .unwrap()
395 .validate()
396 .is_err()
397 );
398
399 assert!(
401 ComponentDependencies::deserialize(toml! {
402 "foo:bar/baz@0.1.0" = "0.1.0"
403 "foo:bar/baz@0.2.0" = "0.2.0"
404 })
405 .unwrap()
406 .validate()
407 .is_ok()
408 );
409
410 assert!(
412 ComponentDependencies::deserialize(toml! {
413 "foo:bar/baz@0.1.0" = "0.1.0"
414 "foo:bar@0.2.0" = "0.2.0"
415 })
416 .unwrap()
417 .validate()
418 .is_ok()
419 );
420
421 assert!(
423 ComponentDependencies::deserialize(toml! {
424 "foo:bar/baz@0.1.0" = "0.1.0"
425 "foo:bar@0.1.0" = "0.1.0"
426 })
427 .unwrap()
428 .validate()
429 .is_err()
430 );
431
432 assert!(
434 ComponentDependencies::deserialize(toml! {
435 "foo:bar/baz@0.1.0" = "0.1.0"
436 "foo:bar" = "0.1.0"
437 })
438 .unwrap()
439 .validate()
440 .is_err()
441 );
442
443 assert!(
445 ComponentDependencies::deserialize(toml! {
446 "foo:bar/baz" = "0.1.0"
447 "foo:bar@0.1.0" = "0.1.0"
448 })
449 .unwrap()
450 .validate()
451 .is_err()
452 );
453
454 assert!(
456 ComponentDependencies::deserialize(toml! {
457 "foo:bar/baz" = "0.1.0"
458 "foo:bar" = "0.1.0"
459 })
460 .unwrap()
461 .validate()
462 .is_err()
463 );
464 }
465
466 fn normalized_component(
467 manifest: &AppManifest,
468 component: &str,
469 profile: Option<&str>,
470 ) -> Component {
471 use crate::normalize::normalize_manifest;
472
473 let id =
474 KebabId::try_from(component.to_owned()).expect("component ID should have been kebab");
475
476 let mut manifest = manifest.clone();
477 normalize_manifest(&mut manifest, profile).expect("should have normalised");
478 manifest
479 .components
480 .get(&id)
481 .expect("should have compopnent with id profile-test")
482 .clone()
483 }
484
485 #[test]
486 fn profiles_override_source() {
487 let manifest = AppManifest::deserialize(toml! {
488 spin_manifest_version = 2
489 [application]
490 name = "trigger-configs"
491 [[trigger.fake]]
492 component = "profile-test"
493 [component.profile-test]
494 source = "original"
495 [component.profile-test.profile.fancy]
496 source = "fancy-schmancy"
497 })
498 .expect("manifest should be valid");
499
500 let id = "profile-test";
501
502 let component = normalized_component(&manifest, id, None);
503 assert!(matches!(&component.source, ComponentSource::Local(p) if p == "original"));
504
505 let component = normalized_component(&manifest, id, Some("fancy"));
506 assert!(matches!(&component.source, ComponentSource::Local(p) if p == "fancy-schmancy"));
507
508 let component = normalized_component(&manifest, id, Some("non-existent"));
509 assert!(matches!(&component.source, ComponentSource::Local(p) if p == "original"));
510 }
511
512 #[test]
513 fn profiles_override_build_command() {
514 let manifest = AppManifest::deserialize(toml! {
515 spin_manifest_version = 2
516 [application]
517 name = "trigger-configs"
518 [[trigger.fake]]
519 component = "profile-test"
520 [component.profile-test]
521 source = "original"
522 build.command = "buildme --release"
523 [component.profile-test.profile.fancy]
524 source = "fancy-schmancy"
525 build.command = ["buildme --fancy", "lintme"]
526 })
527 .expect("manifest should be valid");
528
529 let id = "profile-test";
530
531 let build = normalized_component(&manifest, id, None)
532 .build
533 .expect("should have default build");
534 assert_eq!(1, build.commands().len());
535 assert_eq!("buildme --release", build.commands().next().unwrap());
536
537 let build = normalized_component(&manifest, id, Some("fancy"))
538 .build
539 .expect("should have fancy build");
540 assert_eq!(2, build.commands().len());
541 assert_eq!("buildme --fancy", build.commands().next().unwrap());
542 assert_eq!("lintme", build.commands().nth(1).unwrap());
543
544 let build = normalized_component(&manifest, id, Some("non-existent"))
545 .build
546 .expect("should fall back to default build");
547 assert_eq!(1, build.commands().len());
548 assert_eq!("buildme --release", build.commands().next().unwrap());
549 }
550
551 #[test]
552 fn profiles_can_have_build_command_when_default_doesnt() {
553 let manifest = AppManifest::deserialize(toml! {
554 spin_manifest_version = 2
555 [application]
556 name = "trigger-configs"
557 [[trigger.fake]]
558 component = "profile-test"
559 [component.profile-test]
560 source = "original"
561 [component.profile-test.profile.fancy]
562 source = "fancy-schmancy"
563 build.command = ["buildme --fancy", "lintme"]
564 })
565 .expect("manifest should be valid");
566
567 let component = normalized_component(&manifest, "profile-test", None);
568 assert!(component.build.is_none(), "shouldn't have default build");
569
570 let component = normalized_component(&manifest, "profile-test", Some("fancy"));
571 assert!(component.build.is_some(), "should have fancy build");
572
573 let build = component.build.expect("should have fancy build");
574
575 assert_eq!(2, build.commands().len());
576 assert_eq!("buildme --fancy", build.commands().next().unwrap());
577 assert_eq!("lintme", build.commands().nth(1).unwrap());
578 }
579
580 #[test]
581 fn profiles_override_env_vars() {
582 let manifest = AppManifest::deserialize(toml! {
583 spin_manifest_version = 2
584 [application]
585 name = "trigger-configs"
586 [[trigger.fake]]
587 component = "profile-test"
588 [component.profile-test]
589 source = "original"
590 environment = { DB_URL = "pg://production" }
591 [component.profile-test.profile.fancy]
592 environment = { DB_URL = "pg://fancy", FANCINESS = "1" }
593 })
594 .expect("manifest should be valid");
595
596 let id = "profile-test";
597
598 let component = normalized_component(&manifest, id, None);
599
600 assert_eq!(1, component.environment.len());
601 assert_eq!(
602 "pg://production",
603 component
604 .environment
605 .get("DB_URL")
606 .expect("DB_URL should have been set")
607 );
608
609 let component = normalized_component(&manifest, id, Some("fancy"));
610
611 assert_eq!(2, component.environment.len());
612 assert_eq!(
613 "pg://fancy",
614 component
615 .environment
616 .get("DB_URL")
617 .expect("DB_URL should have been set")
618 );
619 assert_eq!(
620 "1",
621 component
622 .environment
623 .get("FANCINESS")
624 .expect("FANCINESS should have been set")
625 );
626 }
627
628 #[test]
629 fn profiles_dependencies() {
630 let manifest = AppManifest::deserialize(toml! {
631 spin_manifest_version = 2
632 [application]
633 name = "trigger-configs"
634 [[trigger.fake]]
635 component = "profile-test"
636 [component.profile-test]
637 source = "original"
638 [component.profile-test.dependencies]
639 "foo-bar" = "1.0.0"
640 [component.profile-test.profile.fancy]
641 dependencies = { "foo-bar" = { path = "local.wasm" }, "fancy-thing" = "1.2.3" }
642 })
643 .expect("manifest should be valid");
644
645 let id = "profile-test";
646
647 let component = normalized_component(&manifest, id, None);
648
649 assert_eq!(1, component.dependencies.inner.len());
650 assert!(matches!(
651 component
652 .dependencies
653 .inner
654 .get(&DependencyName::Plain(KebabId::try_from("foo-bar".to_owned()).unwrap()))
655 .expect("foo-bar dep should have been set"),
656 ComponentDependency::Version(v) if v == "1.0.0",
657 ));
658
659 let component = normalized_component(&manifest, id, Some("fancy"));
660
661 assert_eq!(2, component.dependencies.inner.len());
662 assert!(matches!(
663 component
664 .dependencies
665 .inner
666 .get(&DependencyName::Plain(KebabId::try_from("foo-bar".to_owned()).unwrap()))
667 .expect("foo-bar dep should have been set"),
668 ComponentDependency::Local { path, .. } if path == &PathBuf::from("local.wasm"),
669 ));
670 assert!(matches!(
671 component
672 .dependencies
673 .inner
674 .get(&DependencyName::Plain(KebabId::try_from("fancy-thing".to_owned()).unwrap()))
675 .expect("fancy-thing dep should have been set"),
676 ComponentDependency::Version(v) if v == "1.2.3",
677 ));
678 }
679
680 #[test]
681 fn can_deserialise_one_or_many_one_ref() {
682 let manifest = AppManifest::deserialize(toml! {
683 spin_manifest_version = 2
684 [application]
685 name = "test"
686 [[trigger.fake]]
687 component = "test1"
688 components = { babble = "test2" }
689 })
690 .expect("manifest should be valid");
691
692 let trigger = manifest.triggers.get("fake").unwrap()[0].clone();
693
694 assert_eq!(
695 Some("test1"),
696 as_reference(trigger.component.as_ref().unwrap())
697 );
698 assert_eq!(1, trigger.components.len());
699 let babble_comps = &trigger.components.get("babble").as_ref().unwrap().0;
700 assert_eq!(1, babble_comps.len());
701 assert_eq!(Some("test2"), as_reference(&babble_comps[0]));
702 }
703
704 #[test]
705 fn can_deserialise_one_or_many_one_inline() {
706 let manifest = AppManifest::deserialize(toml! {
707 spin_manifest_version = 2
708 [application]
709 name = "test"
710 [[trigger.fake]]
711 component = "test1"
712 components = { babble = { source = "fie.wasm", allowed_outbound_hosts = ["http://example.com"] } }
713 })
714 .expect("manifest should be valid");
715
716 let trigger = manifest.triggers.get("fake").unwrap()[0].clone();
717
718 assert_eq!(1, trigger.components.len());
719 let babble_comps = &trigger.components.get("babble").as_ref().unwrap().0;
720 assert_eq!(1, babble_comps.len());
721 let single = as_inline(&babble_comps[0]).expect("should have deserialised to inline");
722 assert_eq!(Some("fie.wasm"), as_local(&single.source));
723 assert_eq!(1, single.allowed_outbound_hosts.len());
724 }
725
726 #[test]
727 fn can_deserialise_one_or_many_many() {
728 let manifest = AppManifest::deserialize(toml! {
729 spin_manifest_version = 2
730 [application]
731 name = "test"
732 [[trigger.fake]]
733 component = "test1"
734 components = { babble = ["test2", { source = "fie.wasm", allowed_outbound_hosts = ["http://example.com"] }, "test3"] }
735 })
736 .expect("manifest should be valid");
737
738 let trigger = manifest.triggers.get("fake").unwrap()[0].clone();
739
740 assert_eq!(1, trigger.components.len());
741 let babble_comps = &trigger.components.get("babble").as_ref().unwrap().0;
742 assert_eq!(3, babble_comps.len());
743
744 assert_eq!(Some("test2"), as_reference(&babble_comps[0]));
745
746 let inline = as_inline(&babble_comps[1]).expect("should have deserialised to inline");
747 assert_eq!(Some("fie.wasm"), as_local(&inline.source));
748 assert_eq!(1, inline.allowed_outbound_hosts.len());
749
750 assert_eq!(Some("test3"), as_reference(&babble_comps[2]));
751 }
752}