1use anyhow::{anyhow, Context};
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use spin_serde::{DependencyName, DependencyPackageName, FixedVersion, LowerSnakeId};
5pub use spin_serde::{KebabId, SnakeId};
6use std::path::PathBuf;
7
8pub use super::common::{ComponentBuildConfig, ComponentSource, Variable, WasiFilesMount};
9use super::json_schema;
10
11pub(crate) type Map<K, V> = indexmap::IndexMap<K, V>;
12
13#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
15#[serde(deny_unknown_fields)]
16pub struct AppManifest {
17 #[schemars(with = "usize", range = (min = 2, max = 2))]
19 pub spin_manifest_version: FixedVersion<2>,
20 pub application: AppDetails,
22 #[serde(default, skip_serializing_if = "Map::is_empty")]
28 pub variables: Map<LowerSnakeId, Variable>,
29 #[serde(rename = "trigger")]
36 #[schemars(with = "json_schema::TriggerSchema")]
37 pub triggers: Map<String, Vec<Trigger>>,
38 #[serde(rename = "component")]
40 #[serde(default, skip_serializing_if = "Map::is_empty")]
41 pub components: Map<KebabId, Component>,
42}
43
44impl AppManifest {
45 pub fn validate_dependencies(&self) -> anyhow::Result<()> {
47 for (component_id, component) in &self.components {
48 component
49 .dependencies
50 .validate()
51 .with_context(|| format!("component {component_id:?} has invalid dependencies"))?;
52 }
53 Ok(())
54 }
55
56 pub fn ensure_profile(&self, profile: Option<&str>) -> anyhow::Result<()> {
62 let Some(p) = profile else {
63 return Ok(());
64 };
65
66 let is_defined = self.components.values().any(|c| c.profile.contains_key(p));
67
68 if is_defined {
69 Ok(())
70 } else {
71 Err(anyhow!("Profile {p} is not defined in this application"))
72 }
73 }
74}
75
76#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
78#[serde(deny_unknown_fields)]
79pub struct AppDetails {
80 pub name: String,
84 #[serde(default, skip_serializing_if = "String::is_empty")]
88 pub version: String,
89 #[serde(default, skip_serializing_if = "String::is_empty")]
93 pub description: String,
94 #[serde(default, skip_serializing_if = "Vec::is_empty")]
98 pub authors: Vec<String>,
99 #[serde(default, skip_serializing_if = "Vec::is_empty")]
105 pub targets: Vec<TargetEnvironmentRef>,
106 #[serde(rename = "trigger", default, skip_serializing_if = "Map::is_empty")]
118 #[schemars(schema_with = "json_schema::map_of_toml_tables")]
119 pub trigger_global_configs: Map<String, toml::Table>,
120 #[serde(default, skip_serializing_if = "Map::is_empty")]
122 #[schemars(schema_with = "json_schema::map_of_toml_tables")]
123 pub tool: Map<String, toml::Table>,
124}
125
126#[derive(Clone, Debug, Serialize, Deserialize)]
138pub struct Trigger {
139 #[serde(default, skip_serializing_if = "String::is_empty")]
143 pub id: String,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub component: Option<ComponentSpec>,
154 #[serde(default, skip_serializing_if = "Map::is_empty")]
158 pub components: Map<String, OneOrManyComponentSpecs>,
159 #[serde(flatten)]
161 pub config: toml::Table,
162}
163
164#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
166#[serde(transparent)]
167pub struct OneOrManyComponentSpecs(
168 #[serde(with = "one_or_many")]
169 #[schemars(schema_with = "json_schema::one_or_many::<ComponentSpec>")]
170 pub Vec<ComponentSpec>,
171);
172
173#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
175#[serde(deny_unknown_fields, untagged, try_from = "toml::Value")]
176pub enum ComponentSpec {
177 Reference(KebabId),
179 Inline(Box<Component>),
181}
182
183impl TryFrom<toml::Value> for ComponentSpec {
184 type Error = toml::de::Error;
185
186 fn try_from(value: toml::Value) -> Result<Self, Self::Error> {
187 if value.is_str() {
188 Ok(ComponentSpec::Reference(KebabId::deserialize(value)?))
189 } else {
190 Ok(ComponentSpec::Inline(Box::new(Component::deserialize(
191 value,
192 )?)))
193 }
194 }
195}
196
197#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
224#[serde(untagged, deny_unknown_fields)]
225pub enum ComponentDependency {
226 #[schemars(description = "")] Version(String),
229 #[schemars(description = "")] Package {
232 version: String,
240 registry: Option<String>,
247 package: Option<String>,
254 export: Option<String>,
260 },
261 #[schemars(description = "")] Local {
264 path: PathBuf,
270 export: Option<String>,
276 },
277 #[schemars(description = "")] HTTP {
280 url: String,
286 digest: String,
292 export: Option<String>,
298 },
299 #[schemars(description = "")] AppComponent {
302 component: KebabId,
308 export: Option<String>,
314 },
315}
316
317#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
319#[serde(deny_unknown_fields)]
320pub struct Component {
321 pub source: ComponentSource,
327 #[serde(default, skip_serializing_if = "String::is_empty")]
331 pub description: String,
332 #[serde(default, skip_serializing_if = "Map::is_empty")]
340 pub variables: Map<LowerSnakeId, String>,
341 #[serde(default, skip_serializing_if = "Map::is_empty")]
345 pub environment: Map<String, String>,
346 #[serde(default, skip_serializing_if = "Vec::is_empty")]
354 pub files: Vec<WasiFilesMount>,
355 #[serde(default, skip_serializing_if = "Vec::is_empty")]
362 pub exclude_files: Vec<String>,
363 #[serde(default, skip_serializing_if = "Vec::is_empty")]
367 #[deprecated]
368 pub allowed_http_hosts: Vec<String>,
369 #[serde(default, skip_serializing_if = "Vec::is_empty")]
381 #[schemars(with = "Vec<json_schema::AllowedOutboundHost>")]
382 pub allowed_outbound_hosts: Vec<String>,
383 #[serde(
391 default,
392 with = "kebab_or_snake_case",
393 skip_serializing_if = "Vec::is_empty"
394 )]
395 #[schemars(with = "Vec<json_schema::KeyValueStore>")]
396 pub key_value_stores: Vec<String>,
397 #[serde(
405 default,
406 with = "kebab_or_snake_case",
407 skip_serializing_if = "Vec::is_empty"
408 )]
409 #[schemars(with = "Vec<json_schema::SqliteDatabase>")]
410 pub sqlite_databases: Vec<String>,
411 #[serde(default, skip_serializing_if = "Vec::is_empty")]
419 #[schemars(with = "Vec<json_schema::AIModel>")]
420 pub ai_models: Vec<String>,
421 #[serde(default, skip_serializing_if = "Option::is_none")]
426 pub targets: Option<Vec<TargetEnvironmentRef>>,
427 #[serde(default, skip_serializing_if = "Option::is_none")]
431 pub build: Option<ComponentBuildConfig>,
432 #[serde(default, skip_serializing_if = "Map::is_empty")]
434 #[schemars(schema_with = "json_schema::map_of_toml_tables")]
435 pub tool: Map<String, toml::Table>,
436 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
442 pub dependencies_inherit_configuration: bool,
443 #[serde(default, skip_serializing_if = "ComponentDependencies::is_empty")]
447 pub dependencies: ComponentDependencies,
448 #[serde(default, skip_serializing_if = "Map::is_empty")]
452 pub(crate) profile: Map<String, ComponentProfileOverride>,
453}
454
455#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
457#[serde(deny_unknown_fields)]
458pub struct ComponentProfileOverride {
459 #[serde(default, skip_serializing_if = "Option::is_none")]
465 pub(crate) source: Option<ComponentSource>,
466
467 #[serde(default, skip_serializing_if = "Map::is_empty")]
473 pub(crate) environment: Map<String, String>,
474
475 #[serde(default, skip_serializing_if = "ComponentDependencies::is_empty")]
481 pub(crate) dependencies: ComponentDependencies,
482
483 #[serde(default, skip_serializing_if = "Option::is_none")]
487 pub(crate) build: Option<ComponentProfileBuildOverride>,
488}
489
490#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
492#[serde(deny_unknown_fields)]
493pub struct ComponentProfileBuildOverride {
494 pub(crate) command: super::common::Commands,
501}
502
503#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
505#[serde(transparent)]
506pub struct ComponentDependencies {
507 pub inner: Map<DependencyName, ComponentDependency>,
509}
510
511impl ComponentDependencies {
512 fn validate(&self) -> anyhow::Result<()> {
516 self.ensure_plain_names_have_package()?;
517 self.ensure_package_names_no_export()?;
518 self.ensure_disjoint()?;
519 Ok(())
520 }
521
522 fn ensure_plain_names_have_package(&self) -> anyhow::Result<()> {
526 for (dependency_name, dependency) in self.inner.iter() {
527 let DependencyName::Plain(plain) = dependency_name else {
528 continue;
529 };
530 match dependency {
531 ComponentDependency::Package { package, .. } if package.is_none() => {}
532 ComponentDependency::Version(_) => {}
533 _ => continue,
534 }
535 anyhow::bail!("dependency {plain:?} must specify a package name");
536 }
537 Ok(())
538 }
539
540 fn ensure_package_names_no_export(&self) -> anyhow::Result<()> {
544 for (dependency_name, dependency) in self.inner.iter() {
545 if let DependencyName::Package(name) = dependency_name {
546 if name.interface.is_none() {
547 let export = match dependency {
548 ComponentDependency::Package { export, .. } => export,
549 ComponentDependency::Local { export, .. } => export,
550 _ => continue,
551 };
552
553 anyhow::ensure!(
554 export.is_none(),
555 "using an export to satisfy the package dependency {dependency_name:?} is not currently permitted",
556 );
557 }
558 }
559 }
560 Ok(())
561 }
562
563 fn ensure_disjoint(&self) -> anyhow::Result<()> {
566 for (idx, this) in self.inner.keys().enumerate() {
567 for other in self.inner.keys().skip(idx + 1) {
568 let DependencyName::Package(other) = other else {
569 continue;
570 };
571 let DependencyName::Package(this) = this else {
572 continue;
573 };
574
575 if this.package == other.package {
576 Self::check_disjoint(this, other)?;
577 }
578 }
579 }
580 Ok(())
581 }
582
583 fn check_disjoint(
584 this: &DependencyPackageName,
585 other: &DependencyPackageName,
586 ) -> anyhow::Result<()> {
587 assert_eq!(this.package, other.package);
588
589 if let (Some(this_ver), Some(other_ver)) = (this.version.clone(), other.version.clone()) {
590 if Self::normalize_compatible_version(this_ver)
591 != Self::normalize_compatible_version(other_ver)
592 {
593 return Ok(());
594 }
595 }
596
597 if let (Some(this_itf), Some(other_itf)) =
598 (this.interface.as_ref(), other.interface.as_ref())
599 {
600 if this_itf != other_itf {
601 return Ok(());
602 }
603 }
604
605 Err(anyhow!("{this:?} dependency conflicts with {other:?}"))
606 }
607
608 fn normalize_compatible_version(mut version: semver::Version) -> semver::Version {
612 version.build = semver::BuildMetadata::EMPTY;
613
614 if version.pre != semver::Prerelease::EMPTY {
615 return version;
616 }
617 if version.major > 0 {
618 version.minor = 0;
619 version.patch = 0;
620 return version;
621 }
622
623 if version.minor > 0 {
624 version.patch = 0;
625 return version;
626 }
627
628 version
629 }
630
631 fn is_empty(&self) -> bool {
632 self.inner.is_empty()
633 }
634}
635
636#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
638#[serde(untagged, deny_unknown_fields)]
639pub enum TargetEnvironmentRef {
640 DefaultRegistry(String),
643 Registry {
645 registry: String,
647 id: String,
650 },
651 File {
654 path: PathBuf,
656 },
657}
658
659mod kebab_or_snake_case {
660 use serde::{Deserialize, Serialize};
661 pub use spin_serde::{KebabId, SnakeId};
662 pub fn serialize<S>(value: &[String], serializer: S) -> Result<S::Ok, S::Error>
663 where
664 S: serde::ser::Serializer,
665 {
666 if value.iter().all(|s| {
667 KebabId::try_from(s.clone()).is_ok() || SnakeId::try_from(s.to_owned()).is_ok()
668 }) {
669 value.serialize(serializer)
670 } else {
671 Err(serde::ser::Error::custom(
672 "expected kebab-case or snake_case",
673 ))
674 }
675 }
676
677 pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
678 where
679 D: serde::Deserializer<'de>,
680 {
681 let value = toml::Value::deserialize(deserializer)?;
682 let list: Vec<String> = Vec::deserialize(value).map_err(serde::de::Error::custom)?;
683 if list.iter().all(|s| {
684 KebabId::try_from(s.clone()).is_ok() || SnakeId::try_from(s.to_owned()).is_ok()
685 }) {
686 Ok(list)
687 } else {
688 Err(serde::de::Error::custom(
689 "expected kebab-case or snake_case",
690 ))
691 }
692 }
693}
694
695impl Component {
696 pub fn normalized_allowed_outbound_hosts(&self) -> anyhow::Result<Vec<String>> {
699 #[allow(deprecated)]
700 let normalized =
701 crate::compat::convert_allowed_http_to_allowed_hosts(&self.allowed_http_hosts, false)?;
702 if !normalized.is_empty() {
703 terminal::warn!(
704 "Use of the deprecated field `allowed_http_hosts` - to fix, \
705 replace `allowed_http_hosts` with `allowed_outbound_hosts = {normalized:?}`",
706 )
707 }
708
709 Ok(self
710 .allowed_outbound_hosts
711 .iter()
712 .cloned()
713 .chain(normalized)
714 .collect())
715 }
716}
717
718mod one_or_many {
719 use serde::{Deserialize, Deserializer, Serialize, Serializer};
720
721 pub fn serialize<T, S>(vec: &Vec<T>, serializer: S) -> Result<S::Ok, S::Error>
722 where
723 T: Serialize,
724 S: Serializer,
725 {
726 if vec.len() == 1 {
727 vec[0].serialize(serializer)
728 } else {
729 vec.serialize(serializer)
730 }
731 }
732
733 pub fn deserialize<'de, T, D>(deserializer: D) -> Result<Vec<T>, D::Error>
734 where
735 T: Deserialize<'de>,
736 D: Deserializer<'de>,
737 {
738 let value = toml::Value::deserialize(deserializer)?;
739 if let Ok(val) = T::deserialize(value.clone()) {
740 Ok(vec![val])
741 } else {
742 Vec::deserialize(value).map_err(serde::de::Error::custom)
743 }
744 }
745}
746
747#[cfg(test)]
748mod tests {
749 use toml::toml;
750
751 use super::*;
752
753 #[derive(Deserialize)]
754 #[allow(dead_code)]
755 struct FakeGlobalTriggerConfig {
756 global_option: bool,
757 }
758
759 #[derive(Deserialize)]
760 #[allow(dead_code)]
761 struct FakeTriggerConfig {
762 option: Option<bool>,
763 }
764
765 #[test]
766 fn deserializing_trigger_configs() {
767 let manifest = AppManifest::deserialize(toml! {
768 spin_manifest_version = 2
769 [application]
770 name = "trigger-configs"
771 [application.trigger.fake]
772 global_option = true
773 [[trigger.fake]]
774 component = { source = "inline.wasm" }
775 option = true
776 })
777 .unwrap();
778
779 FakeGlobalTriggerConfig::deserialize(
780 manifest.application.trigger_global_configs["fake"].clone(),
781 )
782 .unwrap();
783
784 FakeTriggerConfig::deserialize(manifest.triggers["fake"][0].config.clone()).unwrap();
785 }
786
787 #[derive(Deserialize)]
788 #[allow(dead_code)]
789 struct FakeGlobalToolConfig {
790 lint_level: String,
791 }
792
793 #[derive(Deserialize)]
794 #[allow(dead_code)]
795 struct FakeComponentToolConfig {
796 command: String,
797 }
798
799 #[test]
800 fn deserialising_custom_tool_settings() {
801 let manifest = AppManifest::deserialize(toml! {
802 spin_manifest_version = 2
803 [application]
804 name = "trigger-configs"
805 [application.tool.lint]
806 lint_level = "savage"
807 [[trigger.fake]]
808 something = "something else"
809 [component.fake]
810 source = "dummy"
811 [component.fake.tool.clean]
812 command = "cargo clean"
813 })
814 .unwrap();
815
816 FakeGlobalToolConfig::deserialize(manifest.application.tool["lint"].clone()).unwrap();
817 let fake_id: KebabId = "fake".to_owned().try_into().unwrap();
818 FakeComponentToolConfig::deserialize(manifest.components[&fake_id].tool["clean"].clone())
819 .unwrap();
820 }
821
822 #[test]
823 fn deserializing_labels() {
824 AppManifest::deserialize(toml! {
825 spin_manifest_version = 2
826 [application]
827 name = "trigger-configs"
828 [[trigger.fake]]
829 something = "something else"
830 [component.fake]
831 source = "dummy"
832 key_value_stores = ["default", "snake_case", "kebab-case"]
833 sqlite_databases = ["default", "snake_case", "kebab-case"]
834 })
835 .unwrap();
836 }
837
838 #[test]
839 fn deserializing_labels_fails_for_non_kebab_or_snake() {
840 assert!(AppManifest::deserialize(toml! {
841 spin_manifest_version = 2
842 [application]
843 name = "trigger-configs"
844 [[trigger.fake]]
845 something = "something else"
846 [component.fake]
847 source = "dummy"
848 key_value_stores = ["b@dlabel"]
849 })
850 .is_err());
851 }
852
853 fn get_test_component_with_labels(labels: Vec<String>) -> Component {
854 #[allow(deprecated)]
855 Component {
856 source: ComponentSource::Local("dummy".to_string()),
857 description: "".to_string(),
858 variables: Map::new(),
859 environment: Map::new(),
860 files: vec![],
861 exclude_files: vec![],
862 allowed_http_hosts: vec![],
863 allowed_outbound_hosts: vec![],
864 key_value_stores: labels.clone(),
865 sqlite_databases: labels,
866 ai_models: vec![],
867 targets: None,
868 build: None,
869 tool: Map::new(),
870 dependencies_inherit_configuration: false,
871 dependencies: Default::default(),
872 profile: Default::default(),
873 }
874 }
875
876 #[test]
877 fn serialize_labels() {
878 let stores = vec![
879 "default".to_string(),
880 "snake_case".to_string(),
881 "kebab-case".to_string(),
882 ];
883 let component = get_test_component_with_labels(stores.clone());
884 let serialized = toml::to_string(&component).unwrap();
885 let deserialized = toml::from_str::<Component>(&serialized).unwrap();
886 assert_eq!(deserialized.key_value_stores, stores);
887 }
888
889 #[test]
890 fn serialize_labels_fails_for_non_kebab_or_snake() {
891 let component = get_test_component_with_labels(vec!["camelCase".to_string()]);
892 assert!(toml::to_string(&component).is_err());
893 }
894
895 #[test]
896 fn test_valid_snake_ids() {
897 for valid in ["default", "mixed_CASE_words", "letters1_then2_numbers345"] {
898 if let Err(err) = SnakeId::try_from(valid.to_string()) {
899 panic!("{valid:?} should be value: {err:?}");
900 }
901 }
902 }
903
904 #[test]
905 fn test_invalid_snake_ids() {
906 for invalid in [
907 "",
908 "kebab-case",
909 "_leading_underscore",
910 "trailing_underscore_",
911 "double__underscore",
912 "1initial_number",
913 "unicode_snowpeople☃☃☃",
914 "mIxEd_case",
915 "MiXeD_case",
916 ] {
917 if SnakeId::try_from(invalid.to_string()).is_ok() {
918 panic!("{invalid:?} should not be a valid SnakeId");
919 }
920 }
921 }
922
923 #[test]
924 fn test_check_disjoint() {
925 for (a, b) in [
926 ("foo:bar@0.1.0", "foo:bar@0.2.0"),
927 ("foo:bar/baz@0.1.0", "foo:bar/baz@0.2.0"),
928 ("foo:bar/baz@0.1.0", "foo:bar/bub@0.1.0"),
929 ("foo:bar@0.1.0", "foo:bar/bub@0.2.0"),
930 ("foo:bar@1.0.0", "foo:bar@2.0.0"),
931 ("foo:bar@0.1.0", "foo:bar@1.0.0"),
932 ("foo:bar/baz", "foo:bar/bub"),
933 ("foo:bar/baz@0.1.0-alpha", "foo:bar/baz@0.1.0-beta"),
934 ] {
935 let a: DependencyPackageName = a.parse().expect(a);
936 let b: DependencyPackageName = b.parse().expect(b);
937 ComponentDependencies::check_disjoint(&a, &b).unwrap();
938 }
939
940 for (a, b) in [
941 ("foo:bar@0.1.0", "foo:bar@0.1.1"),
942 ("foo:bar/baz@0.1.0", "foo:bar@0.1.0"),
943 ("foo:bar/baz@0.1.0", "foo:bar@0.1.0"),
944 ("foo:bar", "foo:bar@0.1.0"),
945 ("foo:bar@0.1.0-pre", "foo:bar@0.1.0-pre"),
946 ] {
947 let a: DependencyPackageName = a.parse().expect(a);
948 let b: DependencyPackageName = b.parse().expect(b);
949 assert!(
950 ComponentDependencies::check_disjoint(&a, &b).is_err(),
951 "{a} should conflict with {b}",
952 );
953 }
954 }
955
956 #[test]
957 fn test_validate_dependencies() {
958 assert!(ComponentDependencies::deserialize(toml! {
960 "plain-name" = "0.1.0"
961 })
962 .unwrap()
963 .validate()
964 .is_err());
965
966 assert!(ComponentDependencies::deserialize(toml! {
968 "plain-name" = { version = "0.1.0" }
969 })
970 .unwrap()
971 .validate()
972 .is_err());
973
974 assert!(ComponentDependencies::deserialize(toml! {
976 "foo:baz@0.1.0" = { path = "foo.wasm", export = "foo"}
977 })
978 .unwrap()
979 .validate()
980 .is_err());
981
982 assert!(ComponentDependencies::deserialize(toml! {
984 "foo:baz@0.1.0" = "0.1.0"
985 "foo:bar@0.2.1" = "0.2.1"
986 "foo:bar@0.2.2" = "0.2.2"
987 })
988 .unwrap()
989 .validate()
990 .is_err());
991
992 assert!(ComponentDependencies::deserialize(toml! {
994 "foo:bar@0.1.0" = "0.1.0"
995 "foo:bar@0.2.0" = "0.2.0"
996 "foo:baz@0.2.0" = "0.1.0"
997 })
998 .unwrap()
999 .validate()
1000 .is_ok());
1001
1002 assert!(ComponentDependencies::deserialize(toml! {
1004 "foo:bar@0.1.0" = "0.1.0"
1005 "foo:bar" = ">= 0.2.0"
1006 })
1007 .unwrap()
1008 .validate()
1009 .is_err());
1010
1011 assert!(ComponentDependencies::deserialize(toml! {
1013 "foo:bar/baz@0.1.0" = "0.1.0"
1014 "foo:bar/baz@0.2.0" = "0.2.0"
1015 })
1016 .unwrap()
1017 .validate()
1018 .is_ok());
1019
1020 assert!(ComponentDependencies::deserialize(toml! {
1022 "foo:bar/baz@0.1.0" = "0.1.0"
1023 "foo:bar@0.2.0" = "0.2.0"
1024 })
1025 .unwrap()
1026 .validate()
1027 .is_ok());
1028
1029 assert!(ComponentDependencies::deserialize(toml! {
1031 "foo:bar/baz@0.1.0" = "0.1.0"
1032 "foo:bar@0.1.0" = "0.1.0"
1033 })
1034 .unwrap()
1035 .validate()
1036 .is_err());
1037
1038 assert!(ComponentDependencies::deserialize(toml! {
1040 "foo:bar/baz@0.1.0" = "0.1.0"
1041 "foo:bar" = "0.1.0"
1042 })
1043 .unwrap()
1044 .validate()
1045 .is_err());
1046
1047 assert!(ComponentDependencies::deserialize(toml! {
1049 "foo:bar/baz" = "0.1.0"
1050 "foo:bar@0.1.0" = "0.1.0"
1051 })
1052 .unwrap()
1053 .validate()
1054 .is_err());
1055
1056 assert!(ComponentDependencies::deserialize(toml! {
1058 "foo:bar/baz" = "0.1.0"
1059 "foo:bar" = "0.1.0"
1060 })
1061 .unwrap()
1062 .validate()
1063 .is_err());
1064 }
1065
1066 fn normalized_component(
1067 manifest: &AppManifest,
1068 component: &str,
1069 profile: Option<&str>,
1070 ) -> Component {
1071 use crate::normalize::normalize_manifest;
1072
1073 let id =
1074 KebabId::try_from(component.to_owned()).expect("component ID should have been kebab");
1075
1076 let mut manifest = manifest.clone();
1077 normalize_manifest(&mut manifest, profile).expect("should have normalised");
1078 manifest
1079 .components
1080 .get(&id)
1081 .expect("should have compopnent with id profile-test")
1082 .clone()
1083 }
1084
1085 #[test]
1086 fn profiles_override_source() {
1087 let manifest = AppManifest::deserialize(toml! {
1088 spin_manifest_version = 2
1089 [application]
1090 name = "trigger-configs"
1091 [[trigger.fake]]
1092 component = "profile-test"
1093 [component.profile-test]
1094 source = "original"
1095 [component.profile-test.profile.fancy]
1096 source = "fancy-schmancy"
1097 })
1098 .expect("manifest should be valid");
1099
1100 let id = "profile-test";
1101
1102 let component = normalized_component(&manifest, id, None);
1103 assert!(matches!(&component.source, ComponentSource::Local(p) if p == "original"));
1104
1105 let component = normalized_component(&manifest, id, Some("fancy"));
1106 assert!(matches!(&component.source, ComponentSource::Local(p) if p == "fancy-schmancy"));
1107
1108 let component = normalized_component(&manifest, id, Some("non-existent"));
1109 assert!(matches!(&component.source, ComponentSource::Local(p) if p == "original"));
1110 }
1111
1112 #[test]
1113 fn profiles_override_build_command() {
1114 let manifest = AppManifest::deserialize(toml! {
1115 spin_manifest_version = 2
1116 [application]
1117 name = "trigger-configs"
1118 [[trigger.fake]]
1119 component = "profile-test"
1120 [component.profile-test]
1121 source = "original"
1122 build.command = "buildme --release"
1123 [component.profile-test.profile.fancy]
1124 source = "fancy-schmancy"
1125 build.command = ["buildme --fancy", "lintme"]
1126 })
1127 .expect("manifest should be valid");
1128
1129 let id = "profile-test";
1130
1131 let build = normalized_component(&manifest, id, None)
1132 .build
1133 .expect("should have default build");
1134 assert_eq!(1, build.commands().len());
1135 assert_eq!("buildme --release", build.commands().next().unwrap());
1136
1137 let build = normalized_component(&manifest, id, Some("fancy"))
1138 .build
1139 .expect("should have fancy build");
1140 assert_eq!(2, build.commands().len());
1141 assert_eq!("buildme --fancy", build.commands().next().unwrap());
1142 assert_eq!("lintme", build.commands().nth(1).unwrap());
1143
1144 let build = normalized_component(&manifest, id, Some("non-existent"))
1145 .build
1146 .expect("should fall back to default build");
1147 assert_eq!(1, build.commands().len());
1148 assert_eq!("buildme --release", build.commands().next().unwrap());
1149 }
1150
1151 #[test]
1152 fn profiles_can_have_build_command_when_default_doesnt() {
1153 let manifest = AppManifest::deserialize(toml! {
1154 spin_manifest_version = 2
1155 [application]
1156 name = "trigger-configs"
1157 [[trigger.fake]]
1158 component = "profile-test"
1159 [component.profile-test]
1160 source = "original"
1161 [component.profile-test.profile.fancy]
1162 source = "fancy-schmancy"
1163 build.command = ["buildme --fancy", "lintme"]
1164 })
1165 .expect("manifest should be valid");
1166
1167 let component = normalized_component(&manifest, "profile-test", None);
1168 assert!(component.build.is_none(), "shouldn't have default build");
1169
1170 let component = normalized_component(&manifest, "profile-test", Some("fancy"));
1171 assert!(component.build.is_some(), "should have fancy build");
1172
1173 let build = component.build.expect("should have fancy build");
1174
1175 assert_eq!(2, build.commands().len());
1176 assert_eq!("buildme --fancy", build.commands().next().unwrap());
1177 assert_eq!("lintme", build.commands().nth(1).unwrap());
1178 }
1179
1180 #[test]
1181 fn profiles_override_env_vars() {
1182 let manifest = AppManifest::deserialize(toml! {
1183 spin_manifest_version = 2
1184 [application]
1185 name = "trigger-configs"
1186 [[trigger.fake]]
1187 component = "profile-test"
1188 [component.profile-test]
1189 source = "original"
1190 environment = { DB_URL = "pg://production" }
1191 [component.profile-test.profile.fancy]
1192 environment = { DB_URL = "pg://fancy", FANCINESS = "1" }
1193 })
1194 .expect("manifest should be valid");
1195
1196 let id = "profile-test";
1197
1198 let component = normalized_component(&manifest, id, None);
1199
1200 assert_eq!(1, component.environment.len());
1201 assert_eq!(
1202 "pg://production",
1203 component
1204 .environment
1205 .get("DB_URL")
1206 .expect("DB_URL should have been set")
1207 );
1208
1209 let component = normalized_component(&manifest, id, Some("fancy"));
1210
1211 assert_eq!(2, component.environment.len());
1212 assert_eq!(
1213 "pg://fancy",
1214 component
1215 .environment
1216 .get("DB_URL")
1217 .expect("DB_URL should have been set")
1218 );
1219 assert_eq!(
1220 "1",
1221 component
1222 .environment
1223 .get("FANCINESS")
1224 .expect("FANCINESS should have been set")
1225 );
1226 }
1227
1228 #[test]
1229 fn profiles_dependencies() {
1230 let manifest = AppManifest::deserialize(toml! {
1231 spin_manifest_version = 2
1232 [application]
1233 name = "trigger-configs"
1234 [[trigger.fake]]
1235 component = "profile-test"
1236 [component.profile-test]
1237 source = "original"
1238 [component.profile-test.dependencies]
1239 "foo-bar" = "1.0.0"
1240 [component.profile-test.profile.fancy]
1241 dependencies = { "foo-bar" = { path = "local.wasm" }, "fancy-thing" = "1.2.3" }
1242 })
1243 .expect("manifest should be valid");
1244
1245 let id = "profile-test";
1246
1247 let component = normalized_component(&manifest, id, None);
1248
1249 assert_eq!(1, component.dependencies.inner.len());
1250 assert!(matches!(
1251 component
1252 .dependencies
1253 .inner
1254 .get(&DependencyName::Plain(KebabId::try_from("foo-bar".to_owned()).unwrap()))
1255 .expect("foo-bar dep should have been set"),
1256 ComponentDependency::Version(v) if v == "1.0.0",
1257 ));
1258
1259 let component = normalized_component(&manifest, id, Some("fancy"));
1260
1261 assert_eq!(2, component.dependencies.inner.len());
1262 assert!(matches!(
1263 component
1264 .dependencies
1265 .inner
1266 .get(&DependencyName::Plain(KebabId::try_from("foo-bar".to_owned()).unwrap()))
1267 .expect("foo-bar dep should have been set"),
1268 ComponentDependency::Local { path, .. } if path == &PathBuf::from("local.wasm"),
1269 ));
1270 assert!(matches!(
1271 component
1272 .dependencies
1273 .inner
1274 .get(&DependencyName::Plain(KebabId::try_from("fancy-thing".to_owned()).unwrap()))
1275 .expect("fancy-thing dep should have been set"),
1276 ComponentDependency::Version(v) if v == "1.2.3",
1277 ));
1278 }
1279}