1use anyhow::{Context, anyhow};
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")]
176#[schemars(schema_with = "json_schema::id_or_component")]
177pub enum ComponentSpec {
178 Reference(KebabId),
180 Inline(Box<Component>),
182}
183
184impl TryFrom<toml::Value> for ComponentSpec {
185 type Error = toml::de::Error;
186
187 fn try_from(value: toml::Value) -> Result<Self, Self::Error> {
188 if value.is_str() {
189 Ok(ComponentSpec::Reference(KebabId::deserialize(value)?))
190 } else {
191 Ok(ComponentSpec::Inline(Box::new(Component::deserialize(
192 value,
193 )?)))
194 }
195 }
196}
197
198#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
225#[serde(untagged, deny_unknown_fields)]
226pub enum ComponentDependency {
227 #[schemars(description = "")] Version(String),
230 #[schemars(description = "")] Package {
233 version: String,
241 registry: Option<String>,
248 package: Option<String>,
255 export: Option<String>,
261 inherit_configuration: Option<InheritConfiguration>,
270 },
271 #[schemars(description = "")] Local {
274 path: PathBuf,
280 export: Option<String>,
286 inherit_configuration: Option<InheritConfiguration>,
295 },
296 #[schemars(description = "")] HTTP {
299 url: String,
305 digest: String,
311 export: Option<String>,
317 inherit_configuration: Option<InheritConfiguration>,
326 },
327 #[schemars(description = "")] AppComponent {
330 component: KebabId,
336 export: Option<String>,
342 inherit_configuration: Option<InheritConfiguration>,
351 },
352}
353
354#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
361#[serde(untagged)]
362pub enum InheritConfiguration {
363 All(bool),
365 Some(Vec<String>),
367}
368
369impl ComponentDependency {
370 pub fn inherit_configuration(&self) -> Option<&InheritConfiguration> {
372 match self {
373 ComponentDependency::Version(_) => None,
374 ComponentDependency::Package {
375 inherit_configuration,
376 ..
377 }
378 | ComponentDependency::Local {
379 inherit_configuration,
380 ..
381 }
382 | ComponentDependency::HTTP {
383 inherit_configuration,
384 ..
385 }
386 | ComponentDependency::AppComponent {
387 inherit_configuration,
388 ..
389 } => inherit_configuration.as_ref(),
390 }
391 }
392
393 pub fn set_inherit_configuration(&mut self, value: InheritConfiguration) {
396 match self {
397 ComponentDependency::Version(_) => {}
398 ComponentDependency::Package {
399 inherit_configuration,
400 ..
401 }
402 | ComponentDependency::Local {
403 inherit_configuration,
404 ..
405 }
406 | ComponentDependency::HTTP {
407 inherit_configuration,
408 ..
409 }
410 | ComponentDependency::AppComponent {
411 inherit_configuration,
412 ..
413 } => {
414 *inherit_configuration = Some(value);
415 }
416 }
417 }
418}
419
420#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
422#[serde(deny_unknown_fields)]
423pub struct Component {
424 pub source: ComponentSource,
430 #[serde(default, skip_serializing_if = "String::is_empty")]
434 pub description: String,
435 #[serde(default, skip_serializing_if = "Map::is_empty")]
443 pub variables: Map<LowerSnakeId, String>,
444 #[serde(default, skip_serializing_if = "Map::is_empty")]
448 pub environment: Map<String, String>,
449 #[serde(default, skip_serializing_if = "Vec::is_empty")]
457 pub files: Vec<WasiFilesMount>,
458 #[serde(default, skip_serializing_if = "Vec::is_empty")]
465 pub exclude_files: Vec<String>,
466 #[serde(default, skip_serializing_if = "Vec::is_empty")]
470 #[deprecated]
471 pub allowed_http_hosts: Vec<String>,
472 #[serde(default, skip_serializing_if = "Vec::is_empty")]
484 #[schemars(with = "Vec<json_schema::AllowedOutboundHost>")]
485 pub allowed_outbound_hosts: Vec<String>,
486 #[serde(
494 default,
495 with = "kebab_or_snake_case",
496 skip_serializing_if = "Vec::is_empty"
497 )]
498 #[schemars(with = "Vec<json_schema::KeyValueStore>")]
499 pub key_value_stores: Vec<String>,
500 #[serde(
508 default,
509 with = "kebab_or_snake_case",
510 skip_serializing_if = "Vec::is_empty"
511 )]
512 #[schemars(with = "Vec<json_schema::SqliteDatabase>")]
513 pub sqlite_databases: Vec<String>,
514 #[serde(default, skip_serializing_if = "Vec::is_empty")]
522 #[schemars(with = "Vec<json_schema::AIModel>")]
523 pub ai_models: Vec<String>,
524 #[serde(default, skip_serializing_if = "Option::is_none")]
529 pub targets: Option<Vec<TargetEnvironmentRef>>,
530 #[serde(default, skip_serializing_if = "Option::is_none")]
534 pub build: Option<ComponentBuildConfig>,
535 #[serde(default, skip_serializing_if = "Map::is_empty")]
537 #[schemars(schema_with = "json_schema::map_of_toml_tables")]
538 pub tool: Map<String, toml::Table>,
539 #[serde(default, skip_serializing_if = "Option::is_none")]
545 pub dependencies_inherit_configuration: Option<bool>,
546 #[serde(default, skip_serializing_if = "ComponentDependencies::is_empty")]
550 pub dependencies: ComponentDependencies,
551 #[serde(default, skip_serializing_if = "Map::is_empty")]
555 pub(crate) profile: Map<String, ComponentProfileOverride>,
556}
557
558#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
560#[serde(deny_unknown_fields)]
561pub struct ComponentProfileOverride {
562 #[serde(default, skip_serializing_if = "Option::is_none")]
568 pub(crate) source: Option<ComponentSource>,
569
570 #[serde(default, skip_serializing_if = "Map::is_empty")]
576 pub(crate) environment: Map<String, String>,
577
578 #[serde(default, skip_serializing_if = "ComponentDependencies::is_empty")]
584 pub(crate) dependencies: ComponentDependencies,
585
586 #[serde(default, skip_serializing_if = "Option::is_none")]
590 pub(crate) build: Option<ComponentProfileBuildOverride>,
591}
592
593#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
595#[serde(deny_unknown_fields)]
596pub struct ComponentProfileBuildOverride {
597 pub(crate) command: super::common::Commands,
604}
605
606#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
608#[serde(transparent)]
609pub struct ComponentDependencies {
610 pub inner: Map<DependencyName, ComponentDependency>,
612}
613
614impl ComponentDependencies {
615 fn validate(&self) -> anyhow::Result<()> {
619 self.ensure_plain_names_have_package()?;
620 self.ensure_package_names_no_export()?;
621 self.ensure_disjoint()?;
622 Ok(())
623 }
624
625 fn ensure_plain_names_have_package(&self) -> anyhow::Result<()> {
629 for (dependency_name, dependency) in self.inner.iter() {
630 let DependencyName::Plain(plain) = dependency_name else {
631 continue;
632 };
633 match dependency {
634 ComponentDependency::Package { package, .. } if package.is_none() => {}
635 ComponentDependency::Version(_) => {}
636 _ => continue,
637 }
638 anyhow::bail!("dependency {plain:?} must specify a package name");
639 }
640 Ok(())
641 }
642
643 fn ensure_package_names_no_export(&self) -> anyhow::Result<()> {
647 for (dependency_name, dependency) in self.inner.iter() {
648 if let DependencyName::Package(name) = dependency_name
649 && name.interface.is_none()
650 {
651 let export = match dependency {
652 ComponentDependency::Package { export, .. } => export,
653 ComponentDependency::Local { export, .. } => export,
654 _ => continue,
655 };
656
657 anyhow::ensure!(
658 export.is_none(),
659 "using an export to satisfy the package dependency {dependency_name:?} is not currently permitted",
660 );
661 }
662 }
663 Ok(())
664 }
665
666 fn ensure_disjoint(&self) -> anyhow::Result<()> {
669 for (idx, this) in self.inner.keys().enumerate() {
670 for other in self.inner.keys().skip(idx + 1) {
671 let DependencyName::Package(other) = other else {
672 continue;
673 };
674 let DependencyName::Package(this) = this else {
675 continue;
676 };
677
678 if this.package == other.package {
679 Self::check_disjoint(this, other)?;
680 }
681 }
682 }
683 Ok(())
684 }
685
686 fn check_disjoint(
687 this: &DependencyPackageName,
688 other: &DependencyPackageName,
689 ) -> anyhow::Result<()> {
690 assert_eq!(this.package, other.package);
691
692 if let (Some(this_ver), Some(other_ver)) = (this.version.clone(), other.version.clone())
693 && Self::normalize_compatible_version(this_ver)
694 != Self::normalize_compatible_version(other_ver)
695 {
696 return Ok(());
697 }
698
699 if let (Some(this_itf), Some(other_itf)) =
700 (this.interface.as_ref(), other.interface.as_ref())
701 && this_itf != other_itf
702 {
703 return Ok(());
704 }
705
706 Err(anyhow!("{this:?} dependency conflicts with {other:?}"))
707 }
708
709 fn normalize_compatible_version(mut version: semver::Version) -> semver::Version {
713 version.build = semver::BuildMetadata::EMPTY;
714
715 if version.pre != semver::Prerelease::EMPTY {
716 return version;
717 }
718 if version.major > 0 {
719 version.minor = 0;
720 version.patch = 0;
721 return version;
722 }
723
724 if version.minor > 0 {
725 version.patch = 0;
726 return version;
727 }
728
729 version
730 }
731
732 fn is_empty(&self) -> bool {
733 self.inner.is_empty()
734 }
735}
736
737#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
739#[serde(untagged, deny_unknown_fields)]
740pub enum TargetEnvironmentRef {
741 DefaultRegistry(String),
744 Registry {
746 registry: String,
748 id: String,
751 },
752 File {
755 path: PathBuf,
757 },
758}
759
760mod kebab_or_snake_case {
761 use serde::{Deserialize, Serialize};
762 pub use spin_serde::{KebabId, SnakeId};
763 pub fn serialize<S>(value: &[String], serializer: S) -> Result<S::Ok, S::Error>
764 where
765 S: serde::ser::Serializer,
766 {
767 if value.iter().all(|s| {
768 KebabId::try_from(s.clone()).is_ok() || SnakeId::try_from(s.to_owned()).is_ok()
769 }) {
770 value.serialize(serializer)
771 } else {
772 Err(serde::ser::Error::custom(
773 "expected kebab-case or snake_case",
774 ))
775 }
776 }
777
778 pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
779 where
780 D: serde::Deserializer<'de>,
781 {
782 let value = toml::Value::deserialize(deserializer)?;
783 let list: Vec<String> = Vec::deserialize(value).map_err(serde::de::Error::custom)?;
784 if list.iter().all(|s| {
785 KebabId::try_from(s.clone()).is_ok() || SnakeId::try_from(s.to_owned()).is_ok()
786 }) {
787 Ok(list)
788 } else {
789 Err(serde::de::Error::custom(
790 "expected kebab-case or snake_case",
791 ))
792 }
793 }
794}
795
796impl Component {
797 pub fn normalized_allowed_outbound_hosts(&self) -> anyhow::Result<Vec<String>> {
800 #[allow(deprecated)]
801 let normalized =
802 crate::compat::convert_allowed_http_to_allowed_hosts(&self.allowed_http_hosts, false)?;
803 if !normalized.is_empty() {
804 terminal::warn!(
805 "Use of the deprecated field `allowed_http_hosts` - to fix, \
806 replace `allowed_http_hosts` with `allowed_outbound_hosts = {normalized:?}`",
807 )
808 }
809
810 Ok(self
811 .allowed_outbound_hosts
812 .iter()
813 .cloned()
814 .chain(normalized)
815 .collect())
816 }
817}
818
819mod one_or_many {
820 use serde::{Deserialize, Deserializer, Serialize, Serializer};
821
822 pub fn serialize<T, S>(vec: &Vec<T>, serializer: S) -> Result<S::Ok, S::Error>
823 where
824 T: Serialize,
825 S: Serializer,
826 {
827 if vec.len() == 1 {
828 vec[0].serialize(serializer)
829 } else {
830 vec.serialize(serializer)
831 }
832 }
833
834 pub fn deserialize<'de, T, D>(deserializer: D) -> Result<Vec<T>, D::Error>
835 where
836 T: Deserialize<'de>,
837 D: Deserializer<'de>,
838 {
839 let value = toml::Value::deserialize(deserializer)?;
840 if let Ok(val) = T::deserialize(value.clone()) {
841 Ok(vec![val])
842 } else {
843 Vec::deserialize(value).map_err(serde::de::Error::custom)
844 }
845 }
846}
847
848#[cfg(test)]
849mod tests {
850 use toml::toml;
851
852 use super::*;
853
854 #[derive(Deserialize)]
855 #[allow(dead_code)]
856 struct FakeGlobalTriggerConfig {
857 global_option: bool,
858 }
859
860 #[derive(Deserialize)]
861 #[allow(dead_code)]
862 struct FakeTriggerConfig {
863 option: Option<bool>,
864 }
865
866 #[test]
867 fn deserializing_trigger_configs() {
868 let manifest = AppManifest::deserialize(toml! {
869 spin_manifest_version = 2
870 [application]
871 name = "trigger-configs"
872 [application.trigger.fake]
873 global_option = true
874 [[trigger.fake]]
875 component = { source = "inline.wasm" }
876 option = true
877 })
878 .unwrap();
879
880 FakeGlobalTriggerConfig::deserialize(
881 manifest.application.trigger_global_configs["fake"].clone(),
882 )
883 .unwrap();
884
885 FakeTriggerConfig::deserialize(manifest.triggers["fake"][0].config.clone()).unwrap();
886 }
887
888 #[derive(Deserialize)]
889 #[allow(dead_code)]
890 struct FakeGlobalToolConfig {
891 lint_level: String,
892 }
893
894 #[derive(Deserialize)]
895 #[allow(dead_code)]
896 struct FakeComponentToolConfig {
897 command: String,
898 }
899
900 #[test]
901 fn deserialising_custom_tool_settings() {
902 let manifest = AppManifest::deserialize(toml! {
903 spin_manifest_version = 2
904 [application]
905 name = "trigger-configs"
906 [application.tool.lint]
907 lint_level = "savage"
908 [[trigger.fake]]
909 something = "something else"
910 [component.fake]
911 source = "dummy"
912 [component.fake.tool.clean]
913 command = "cargo clean"
914 })
915 .unwrap();
916
917 FakeGlobalToolConfig::deserialize(manifest.application.tool["lint"].clone()).unwrap();
918 let fake_id: KebabId = "fake".to_owned().try_into().unwrap();
919 FakeComponentToolConfig::deserialize(manifest.components[&fake_id].tool["clean"].clone())
920 .unwrap();
921 }
922
923 #[test]
924 fn deserializing_labels() {
925 AppManifest::deserialize(toml! {
926 spin_manifest_version = 2
927 [application]
928 name = "trigger-configs"
929 [[trigger.fake]]
930 something = "something else"
931 [component.fake]
932 source = "dummy"
933 key_value_stores = ["default", "snake_case", "kebab-case"]
934 sqlite_databases = ["default", "snake_case", "kebab-case"]
935 })
936 .unwrap();
937 }
938
939 #[test]
940 fn deserializing_labels_fails_for_non_kebab_or_snake() {
941 assert!(
942 AppManifest::deserialize(toml! {
943 spin_manifest_version = 2
944 [application]
945 name = "trigger-configs"
946 [[trigger.fake]]
947 something = "something else"
948 [component.fake]
949 source = "dummy"
950 key_value_stores = ["b@dlabel"]
951 })
952 .is_err()
953 );
954 }
955
956 fn get_test_component_with_labels(labels: Vec<String>) -> Component {
957 #[allow(deprecated)]
958 Component {
959 source: ComponentSource::Local("dummy".to_string()),
960 description: "".to_string(),
961 variables: Map::new(),
962 environment: Map::new(),
963 files: vec![],
964 exclude_files: vec![],
965 allowed_http_hosts: vec![],
966 allowed_outbound_hosts: vec![],
967 key_value_stores: labels.clone(),
968 sqlite_databases: labels,
969 ai_models: vec![],
970 targets: None,
971 build: None,
972 tool: Map::new(),
973 dependencies_inherit_configuration: None,
974 dependencies: Default::default(),
975 profile: Default::default(),
976 }
977 }
978
979 #[test]
980 fn serialize_labels() {
981 let stores = vec![
982 "default".to_string(),
983 "snake_case".to_string(),
984 "kebab-case".to_string(),
985 ];
986 let component = get_test_component_with_labels(stores.clone());
987 let serialized = toml::to_string(&component).unwrap();
988 let deserialized = toml::from_str::<Component>(&serialized).unwrap();
989 assert_eq!(deserialized.key_value_stores, stores);
990 }
991
992 #[test]
993 fn serialize_labels_fails_for_non_kebab_or_snake() {
994 let component = get_test_component_with_labels(vec!["camelCase".to_string()]);
995 assert!(toml::to_string(&component).is_err());
996 }
997
998 #[test]
999 fn test_valid_snake_ids() {
1000 for valid in ["default", "mixed_CASE_words", "letters1_then2_numbers345"] {
1001 if let Err(err) = SnakeId::try_from(valid.to_string()) {
1002 panic!("{valid:?} should be value: {err:?}");
1003 }
1004 }
1005 }
1006
1007 #[test]
1008 fn test_invalid_snake_ids() {
1009 for invalid in [
1010 "",
1011 "kebab-case",
1012 "_leading_underscore",
1013 "trailing_underscore_",
1014 "double__underscore",
1015 "1initial_number",
1016 "unicode_snowpeople☃☃☃",
1017 "mIxEd_case",
1018 "MiXeD_case",
1019 ] {
1020 if SnakeId::try_from(invalid.to_string()).is_ok() {
1021 panic!("{invalid:?} should not be a valid SnakeId");
1022 }
1023 }
1024 }
1025
1026 #[test]
1027 fn test_check_disjoint() {
1028 for (a, b) in [
1029 ("foo:bar@0.1.0", "foo:bar@0.2.0"),
1030 ("foo:bar/baz@0.1.0", "foo:bar/baz@0.2.0"),
1031 ("foo:bar/baz@0.1.0", "foo:bar/bub@0.1.0"),
1032 ("foo:bar@0.1.0", "foo:bar/bub@0.2.0"),
1033 ("foo:bar@1.0.0", "foo:bar@2.0.0"),
1034 ("foo:bar@0.1.0", "foo:bar@1.0.0"),
1035 ("foo:bar/baz", "foo:bar/bub"),
1036 ("foo:bar/baz@0.1.0-alpha", "foo:bar/baz@0.1.0-beta"),
1037 ] {
1038 let a: DependencyPackageName = a.parse().expect(a);
1039 let b: DependencyPackageName = b.parse().expect(b);
1040 ComponentDependencies::check_disjoint(&a, &b).unwrap();
1041 }
1042
1043 for (a, b) in [
1044 ("foo:bar@0.1.0", "foo:bar@0.1.1"),
1045 ("foo:bar/baz@0.1.0", "foo:bar@0.1.0"),
1046 ("foo:bar/baz@0.1.0", "foo:bar@0.1.0"),
1047 ("foo:bar", "foo:bar@0.1.0"),
1048 ("foo:bar@0.1.0-pre", "foo:bar@0.1.0-pre"),
1049 ] {
1050 let a: DependencyPackageName = a.parse().expect(a);
1051 let b: DependencyPackageName = b.parse().expect(b);
1052 assert!(
1053 ComponentDependencies::check_disjoint(&a, &b).is_err(),
1054 "{a} should conflict with {b}",
1055 );
1056 }
1057 }
1058
1059 #[test]
1060 fn test_validate_dependencies() {
1061 assert!(
1063 ComponentDependencies::deserialize(toml! {
1064 "plain-name" = "0.1.0"
1065 })
1066 .unwrap()
1067 .validate()
1068 .is_err()
1069 );
1070
1071 assert!(
1073 ComponentDependencies::deserialize(toml! {
1074 "plain-name" = { version = "0.1.0" }
1075 })
1076 .unwrap()
1077 .validate()
1078 .is_err()
1079 );
1080
1081 assert!(
1083 ComponentDependencies::deserialize(toml! {
1084 "foo:baz@0.1.0" = { path = "foo.wasm", export = "foo"}
1085 })
1086 .unwrap()
1087 .validate()
1088 .is_err()
1089 );
1090
1091 assert!(
1093 ComponentDependencies::deserialize(toml! {
1094 "foo:baz@0.1.0" = "0.1.0"
1095 "foo:bar@0.2.1" = "0.2.1"
1096 "foo:bar@0.2.2" = "0.2.2"
1097 })
1098 .unwrap()
1099 .validate()
1100 .is_err()
1101 );
1102
1103 assert!(
1105 ComponentDependencies::deserialize(toml! {
1106 "foo:bar@0.1.0" = "0.1.0"
1107 "foo:bar@0.2.0" = "0.2.0"
1108 "foo:baz@0.2.0" = "0.1.0"
1109 })
1110 .unwrap()
1111 .validate()
1112 .is_ok()
1113 );
1114
1115 assert!(
1117 ComponentDependencies::deserialize(toml! {
1118 "foo:bar@0.1.0" = "0.1.0"
1119 "foo:bar" = ">= 0.2.0"
1120 })
1121 .unwrap()
1122 .validate()
1123 .is_err()
1124 );
1125
1126 assert!(
1128 ComponentDependencies::deserialize(toml! {
1129 "foo:bar/baz@0.1.0" = "0.1.0"
1130 "foo:bar/baz@0.2.0" = "0.2.0"
1131 })
1132 .unwrap()
1133 .validate()
1134 .is_ok()
1135 );
1136
1137 assert!(
1139 ComponentDependencies::deserialize(toml! {
1140 "foo:bar/baz@0.1.0" = "0.1.0"
1141 "foo:bar@0.2.0" = "0.2.0"
1142 })
1143 .unwrap()
1144 .validate()
1145 .is_ok()
1146 );
1147
1148 assert!(
1150 ComponentDependencies::deserialize(toml! {
1151 "foo:bar/baz@0.1.0" = "0.1.0"
1152 "foo:bar@0.1.0" = "0.1.0"
1153 })
1154 .unwrap()
1155 .validate()
1156 .is_err()
1157 );
1158
1159 assert!(
1161 ComponentDependencies::deserialize(toml! {
1162 "foo:bar/baz@0.1.0" = "0.1.0"
1163 "foo:bar" = "0.1.0"
1164 })
1165 .unwrap()
1166 .validate()
1167 .is_err()
1168 );
1169
1170 assert!(
1172 ComponentDependencies::deserialize(toml! {
1173 "foo:bar/baz" = "0.1.0"
1174 "foo:bar@0.1.0" = "0.1.0"
1175 })
1176 .unwrap()
1177 .validate()
1178 .is_err()
1179 );
1180
1181 assert!(
1183 ComponentDependencies::deserialize(toml! {
1184 "foo:bar/baz" = "0.1.0"
1185 "foo:bar" = "0.1.0"
1186 })
1187 .unwrap()
1188 .validate()
1189 .is_err()
1190 );
1191 }
1192
1193 fn normalized_component(
1194 manifest: &AppManifest,
1195 component: &str,
1196 profile: Option<&str>,
1197 ) -> Component {
1198 use crate::normalize::normalize_manifest;
1199
1200 let id =
1201 KebabId::try_from(component.to_owned()).expect("component ID should have been kebab");
1202
1203 let mut manifest = manifest.clone();
1204 normalize_manifest(&mut manifest, profile).expect("should have normalised");
1205 manifest
1206 .components
1207 .get(&id)
1208 .expect("should have compopnent with id profile-test")
1209 .clone()
1210 }
1211
1212 #[test]
1213 fn profiles_override_source() {
1214 let manifest = AppManifest::deserialize(toml! {
1215 spin_manifest_version = 2
1216 [application]
1217 name = "trigger-configs"
1218 [[trigger.fake]]
1219 component = "profile-test"
1220 [component.profile-test]
1221 source = "original"
1222 [component.profile-test.profile.fancy]
1223 source = "fancy-schmancy"
1224 })
1225 .expect("manifest should be valid");
1226
1227 let id = "profile-test";
1228
1229 let component = normalized_component(&manifest, id, None);
1230 assert!(matches!(&component.source, ComponentSource::Local(p) if p == "original"));
1231
1232 let component = normalized_component(&manifest, id, Some("fancy"));
1233 assert!(matches!(&component.source, ComponentSource::Local(p) if p == "fancy-schmancy"));
1234
1235 let component = normalized_component(&manifest, id, Some("non-existent"));
1236 assert!(matches!(&component.source, ComponentSource::Local(p) if p == "original"));
1237 }
1238
1239 #[test]
1240 fn profiles_override_build_command() {
1241 let manifest = AppManifest::deserialize(toml! {
1242 spin_manifest_version = 2
1243 [application]
1244 name = "trigger-configs"
1245 [[trigger.fake]]
1246 component = "profile-test"
1247 [component.profile-test]
1248 source = "original"
1249 build.command = "buildme --release"
1250 [component.profile-test.profile.fancy]
1251 source = "fancy-schmancy"
1252 build.command = ["buildme --fancy", "lintme"]
1253 })
1254 .expect("manifest should be valid");
1255
1256 let id = "profile-test";
1257
1258 let build = normalized_component(&manifest, id, None)
1259 .build
1260 .expect("should have default build");
1261 assert_eq!(1, build.commands().len());
1262 assert_eq!("buildme --release", build.commands().next().unwrap());
1263
1264 let build = normalized_component(&manifest, id, Some("fancy"))
1265 .build
1266 .expect("should have fancy build");
1267 assert_eq!(2, build.commands().len());
1268 assert_eq!("buildme --fancy", build.commands().next().unwrap());
1269 assert_eq!("lintme", build.commands().nth(1).unwrap());
1270
1271 let build = normalized_component(&manifest, id, Some("non-existent"))
1272 .build
1273 .expect("should fall back to default build");
1274 assert_eq!(1, build.commands().len());
1275 assert_eq!("buildme --release", build.commands().next().unwrap());
1276 }
1277
1278 #[test]
1279 fn profiles_can_have_build_command_when_default_doesnt() {
1280 let manifest = AppManifest::deserialize(toml! {
1281 spin_manifest_version = 2
1282 [application]
1283 name = "trigger-configs"
1284 [[trigger.fake]]
1285 component = "profile-test"
1286 [component.profile-test]
1287 source = "original"
1288 [component.profile-test.profile.fancy]
1289 source = "fancy-schmancy"
1290 build.command = ["buildme --fancy", "lintme"]
1291 })
1292 .expect("manifest should be valid");
1293
1294 let component = normalized_component(&manifest, "profile-test", None);
1295 assert!(component.build.is_none(), "shouldn't have default build");
1296
1297 let component = normalized_component(&manifest, "profile-test", Some("fancy"));
1298 assert!(component.build.is_some(), "should have fancy build");
1299
1300 let build = component.build.expect("should have fancy build");
1301
1302 assert_eq!(2, build.commands().len());
1303 assert_eq!("buildme --fancy", build.commands().next().unwrap());
1304 assert_eq!("lintme", build.commands().nth(1).unwrap());
1305 }
1306
1307 #[test]
1308 fn profiles_override_env_vars() {
1309 let manifest = AppManifest::deserialize(toml! {
1310 spin_manifest_version = 2
1311 [application]
1312 name = "trigger-configs"
1313 [[trigger.fake]]
1314 component = "profile-test"
1315 [component.profile-test]
1316 source = "original"
1317 environment = { DB_URL = "pg://production" }
1318 [component.profile-test.profile.fancy]
1319 environment = { DB_URL = "pg://fancy", FANCINESS = "1" }
1320 })
1321 .expect("manifest should be valid");
1322
1323 let id = "profile-test";
1324
1325 let component = normalized_component(&manifest, id, None);
1326
1327 assert_eq!(1, component.environment.len());
1328 assert_eq!(
1329 "pg://production",
1330 component
1331 .environment
1332 .get("DB_URL")
1333 .expect("DB_URL should have been set")
1334 );
1335
1336 let component = normalized_component(&manifest, id, Some("fancy"));
1337
1338 assert_eq!(2, component.environment.len());
1339 assert_eq!(
1340 "pg://fancy",
1341 component
1342 .environment
1343 .get("DB_URL")
1344 .expect("DB_URL should have been set")
1345 );
1346 assert_eq!(
1347 "1",
1348 component
1349 .environment
1350 .get("FANCINESS")
1351 .expect("FANCINESS should have been set")
1352 );
1353 }
1354
1355 #[test]
1356 fn profiles_dependencies() {
1357 let manifest = AppManifest::deserialize(toml! {
1358 spin_manifest_version = 2
1359 [application]
1360 name = "trigger-configs"
1361 [[trigger.fake]]
1362 component = "profile-test"
1363 [component.profile-test]
1364 source = "original"
1365 [component.profile-test.dependencies]
1366 "foo-bar" = "1.0.0"
1367 [component.profile-test.profile.fancy]
1368 dependencies = { "foo-bar" = { path = "local.wasm" }, "fancy-thing" = "1.2.3" }
1369 })
1370 .expect("manifest should be valid");
1371
1372 let id = "profile-test";
1373
1374 let component = normalized_component(&manifest, id, None);
1375
1376 assert_eq!(1, component.dependencies.inner.len());
1377 assert!(matches!(
1378 component
1379 .dependencies
1380 .inner
1381 .get(&DependencyName::Plain(KebabId::try_from("foo-bar".to_owned()).unwrap()))
1382 .expect("foo-bar dep should have been set"),
1383 ComponentDependency::Version(v) if v == "1.0.0",
1384 ));
1385
1386 let component = normalized_component(&manifest, id, Some("fancy"));
1387
1388 assert_eq!(2, component.dependencies.inner.len());
1389 assert!(matches!(
1390 component
1391 .dependencies
1392 .inner
1393 .get(&DependencyName::Plain(KebabId::try_from("foo-bar".to_owned()).unwrap()))
1394 .expect("foo-bar dep should have been set"),
1395 ComponentDependency::Local { path, .. } if path == &PathBuf::from("local.wasm"),
1396 ));
1397 assert!(matches!(
1398 component
1399 .dependencies
1400 .inner
1401 .get(&DependencyName::Plain(KebabId::try_from("fancy-thing".to_owned()).unwrap()))
1402 .expect("fancy-thing dep should have been set"),
1403 ComponentDependency::Version(v) if v == "1.2.3",
1404 ));
1405 }
1406}