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 Catalogue(String),
744 Http {
746 url: String,
748 },
749 File {
752 path: PathBuf,
754 },
755}
756
757impl std::fmt::Display for TargetEnvironmentRef {
758 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
759 match self {
760 Self::Catalogue(e) => e.fmt(f),
761 Self::Http { url } => url.fmt(f),
762 Self::File { path } => path.display().fmt(f),
763 }
764 }
765}
766
767mod kebab_or_snake_case {
768 use serde::{Deserialize, Serialize};
769 pub use spin_serde::{KebabId, SnakeId};
770 pub fn serialize<S>(value: &[String], serializer: S) -> Result<S::Ok, S::Error>
771 where
772 S: serde::ser::Serializer,
773 {
774 if value.iter().all(|s| {
775 KebabId::try_from(s.clone()).is_ok() || SnakeId::try_from(s.to_owned()).is_ok()
776 }) {
777 value.serialize(serializer)
778 } else {
779 Err(serde::ser::Error::custom(
780 "expected kebab-case or snake_case",
781 ))
782 }
783 }
784
785 pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
786 where
787 D: serde::Deserializer<'de>,
788 {
789 let value = toml::Value::deserialize(deserializer)?;
790 let list: Vec<String> = Vec::deserialize(value).map_err(serde::de::Error::custom)?;
791 if list.iter().all(|s| {
792 KebabId::try_from(s.clone()).is_ok() || SnakeId::try_from(s.to_owned()).is_ok()
793 }) {
794 Ok(list)
795 } else {
796 Err(serde::de::Error::custom(
797 "expected kebab-case or snake_case",
798 ))
799 }
800 }
801}
802
803impl Component {
804 pub fn normalized_allowed_outbound_hosts(&self) -> anyhow::Result<Vec<String>> {
807 #[allow(deprecated)]
808 let normalized =
809 crate::compat::convert_allowed_http_to_allowed_hosts(&self.allowed_http_hosts, false)?;
810 if !normalized.is_empty() {
811 terminal::warn!(
812 "Use of the deprecated field `allowed_http_hosts` - to fix, \
813 replace `allowed_http_hosts` with `allowed_outbound_hosts = {normalized:?}`",
814 )
815 }
816
817 Ok(self
818 .allowed_outbound_hosts
819 .iter()
820 .cloned()
821 .chain(normalized)
822 .collect())
823 }
824}
825
826mod one_or_many {
827 use serde::{Deserialize, Deserializer, Serialize, Serializer};
828
829 pub fn serialize<T, S>(vec: &Vec<T>, serializer: S) -> Result<S::Ok, S::Error>
830 where
831 T: Serialize,
832 S: Serializer,
833 {
834 if vec.len() == 1 {
835 vec[0].serialize(serializer)
836 } else {
837 vec.serialize(serializer)
838 }
839 }
840
841 pub fn deserialize<'de, T, D>(deserializer: D) -> Result<Vec<T>, D::Error>
842 where
843 T: Deserialize<'de>,
844 D: Deserializer<'de>,
845 {
846 let value = toml::Value::deserialize(deserializer)?;
847 if let Ok(val) = T::deserialize(value.clone()) {
848 Ok(vec![val])
849 } else {
850 Vec::deserialize(value).map_err(serde::de::Error::custom)
851 }
852 }
853}
854
855#[cfg(test)]
856mod tests {
857 use toml::toml;
858
859 use super::*;
860
861 #[derive(Deserialize)]
862 #[allow(dead_code)]
863 struct FakeGlobalTriggerConfig {
864 global_option: bool,
865 }
866
867 #[derive(Deserialize)]
868 #[allow(dead_code)]
869 struct FakeTriggerConfig {
870 option: Option<bool>,
871 }
872
873 #[test]
874 fn deserializing_trigger_configs() {
875 let manifest = AppManifest::deserialize(toml! {
876 spin_manifest_version = 2
877 [application]
878 name = "trigger-configs"
879 [application.trigger.fake]
880 global_option = true
881 [[trigger.fake]]
882 component = { source = "inline.wasm" }
883 option = true
884 })
885 .unwrap();
886
887 FakeGlobalTriggerConfig::deserialize(
888 manifest.application.trigger_global_configs["fake"].clone(),
889 )
890 .unwrap();
891
892 FakeTriggerConfig::deserialize(manifest.triggers["fake"][0].config.clone()).unwrap();
893 }
894
895 #[derive(Deserialize)]
896 #[allow(dead_code)]
897 struct FakeGlobalToolConfig {
898 lint_level: String,
899 }
900
901 #[derive(Deserialize)]
902 #[allow(dead_code)]
903 struct FakeComponentToolConfig {
904 command: String,
905 }
906
907 #[test]
908 fn deserialising_custom_tool_settings() {
909 let manifest = AppManifest::deserialize(toml! {
910 spin_manifest_version = 2
911 [application]
912 name = "trigger-configs"
913 [application.tool.lint]
914 lint_level = "savage"
915 [[trigger.fake]]
916 something = "something else"
917 [component.fake]
918 source = "dummy"
919 [component.fake.tool.clean]
920 command = "cargo clean"
921 })
922 .unwrap();
923
924 FakeGlobalToolConfig::deserialize(manifest.application.tool["lint"].clone()).unwrap();
925 let fake_id: KebabId = "fake".to_owned().try_into().unwrap();
926 FakeComponentToolConfig::deserialize(manifest.components[&fake_id].tool["clean"].clone())
927 .unwrap();
928 }
929
930 #[test]
931 fn deserializing_labels() {
932 AppManifest::deserialize(toml! {
933 spin_manifest_version = 2
934 [application]
935 name = "trigger-configs"
936 [[trigger.fake]]
937 something = "something else"
938 [component.fake]
939 source = "dummy"
940 key_value_stores = ["default", "snake_case", "kebab-case"]
941 sqlite_databases = ["default", "snake_case", "kebab-case"]
942 })
943 .unwrap();
944 }
945
946 #[test]
947 fn deserializing_labels_fails_for_non_kebab_or_snake() {
948 assert!(
949 AppManifest::deserialize(toml! {
950 spin_manifest_version = 2
951 [application]
952 name = "trigger-configs"
953 [[trigger.fake]]
954 something = "something else"
955 [component.fake]
956 source = "dummy"
957 key_value_stores = ["b@dlabel"]
958 })
959 .is_err()
960 );
961 }
962
963 fn get_test_component_with_labels(labels: Vec<String>) -> Component {
964 #[allow(deprecated)]
965 Component {
966 source: ComponentSource::Local("dummy".to_string()),
967 description: "".to_string(),
968 variables: Map::new(),
969 environment: Map::new(),
970 files: vec![],
971 exclude_files: vec![],
972 allowed_http_hosts: vec![],
973 allowed_outbound_hosts: vec![],
974 key_value_stores: labels.clone(),
975 sqlite_databases: labels,
976 ai_models: vec![],
977 targets: None,
978 build: None,
979 tool: Map::new(),
980 dependencies_inherit_configuration: None,
981 dependencies: Default::default(),
982 profile: Default::default(),
983 }
984 }
985
986 #[test]
987 fn serialize_labels() {
988 let stores = vec![
989 "default".to_string(),
990 "snake_case".to_string(),
991 "kebab-case".to_string(),
992 ];
993 let component = get_test_component_with_labels(stores.clone());
994 let serialized = toml::to_string(&component).unwrap();
995 let deserialized = toml::from_str::<Component>(&serialized).unwrap();
996 assert_eq!(deserialized.key_value_stores, stores);
997 }
998
999 #[test]
1000 fn serialize_labels_fails_for_non_kebab_or_snake() {
1001 let component = get_test_component_with_labels(vec!["camelCase".to_string()]);
1002 assert!(toml::to_string(&component).is_err());
1003 }
1004
1005 #[test]
1006 fn test_valid_snake_ids() {
1007 for valid in ["default", "mixed_CASE_words", "letters1_then2_numbers345"] {
1008 if let Err(err) = SnakeId::try_from(valid.to_string()) {
1009 panic!("{valid:?} should be value: {err:?}");
1010 }
1011 }
1012 }
1013
1014 #[test]
1015 fn test_invalid_snake_ids() {
1016 for invalid in [
1017 "",
1018 "kebab-case",
1019 "_leading_underscore",
1020 "trailing_underscore_",
1021 "double__underscore",
1022 "1initial_number",
1023 "unicode_snowpeople☃☃☃",
1024 "mIxEd_case",
1025 "MiXeD_case",
1026 ] {
1027 if SnakeId::try_from(invalid.to_string()).is_ok() {
1028 panic!("{invalid:?} should not be a valid SnakeId");
1029 }
1030 }
1031 }
1032
1033 #[test]
1034 fn test_check_disjoint() {
1035 for (a, b) in [
1036 ("foo:bar@0.1.0", "foo:bar@0.2.0"),
1037 ("foo:bar/baz@0.1.0", "foo:bar/baz@0.2.0"),
1038 ("foo:bar/baz@0.1.0", "foo:bar/bub@0.1.0"),
1039 ("foo:bar@0.1.0", "foo:bar/bub@0.2.0"),
1040 ("foo:bar@1.0.0", "foo:bar@2.0.0"),
1041 ("foo:bar@0.1.0", "foo:bar@1.0.0"),
1042 ("foo:bar/baz", "foo:bar/bub"),
1043 ("foo:bar/baz@0.1.0-alpha", "foo:bar/baz@0.1.0-beta"),
1044 ] {
1045 let a: DependencyPackageName = a.parse().expect(a);
1046 let b: DependencyPackageName = b.parse().expect(b);
1047 ComponentDependencies::check_disjoint(&a, &b).unwrap();
1048 }
1049
1050 for (a, b) in [
1051 ("foo:bar@0.1.0", "foo:bar@0.1.1"),
1052 ("foo:bar/baz@0.1.0", "foo:bar@0.1.0"),
1053 ("foo:bar/baz@0.1.0", "foo:bar@0.1.0"),
1054 ("foo:bar", "foo:bar@0.1.0"),
1055 ("foo:bar@0.1.0-pre", "foo:bar@0.1.0-pre"),
1056 ] {
1057 let a: DependencyPackageName = a.parse().expect(a);
1058 let b: DependencyPackageName = b.parse().expect(b);
1059 assert!(
1060 ComponentDependencies::check_disjoint(&a, &b).is_err(),
1061 "{a} should conflict with {b}",
1062 );
1063 }
1064 }
1065
1066 #[test]
1067 fn test_validate_dependencies() {
1068 assert!(
1070 ComponentDependencies::deserialize(toml! {
1071 "plain-name" = "0.1.0"
1072 })
1073 .unwrap()
1074 .validate()
1075 .is_err()
1076 );
1077
1078 assert!(
1080 ComponentDependencies::deserialize(toml! {
1081 "plain-name" = { version = "0.1.0" }
1082 })
1083 .unwrap()
1084 .validate()
1085 .is_err()
1086 );
1087
1088 assert!(
1090 ComponentDependencies::deserialize(toml! {
1091 "foo:baz@0.1.0" = { path = "foo.wasm", export = "foo"}
1092 })
1093 .unwrap()
1094 .validate()
1095 .is_err()
1096 );
1097
1098 assert!(
1100 ComponentDependencies::deserialize(toml! {
1101 "foo:baz@0.1.0" = "0.1.0"
1102 "foo:bar@0.2.1" = "0.2.1"
1103 "foo:bar@0.2.2" = "0.2.2"
1104 })
1105 .unwrap()
1106 .validate()
1107 .is_err()
1108 );
1109
1110 assert!(
1112 ComponentDependencies::deserialize(toml! {
1113 "foo:bar@0.1.0" = "0.1.0"
1114 "foo:bar@0.2.0" = "0.2.0"
1115 "foo:baz@0.2.0" = "0.1.0"
1116 })
1117 .unwrap()
1118 .validate()
1119 .is_ok()
1120 );
1121
1122 assert!(
1124 ComponentDependencies::deserialize(toml! {
1125 "foo:bar@0.1.0" = "0.1.0"
1126 "foo:bar" = ">= 0.2.0"
1127 })
1128 .unwrap()
1129 .validate()
1130 .is_err()
1131 );
1132
1133 assert!(
1135 ComponentDependencies::deserialize(toml! {
1136 "foo:bar/baz@0.1.0" = "0.1.0"
1137 "foo:bar/baz@0.2.0" = "0.2.0"
1138 })
1139 .unwrap()
1140 .validate()
1141 .is_ok()
1142 );
1143
1144 assert!(
1146 ComponentDependencies::deserialize(toml! {
1147 "foo:bar/baz@0.1.0" = "0.1.0"
1148 "foo:bar@0.2.0" = "0.2.0"
1149 })
1150 .unwrap()
1151 .validate()
1152 .is_ok()
1153 );
1154
1155 assert!(
1157 ComponentDependencies::deserialize(toml! {
1158 "foo:bar/baz@0.1.0" = "0.1.0"
1159 "foo:bar@0.1.0" = "0.1.0"
1160 })
1161 .unwrap()
1162 .validate()
1163 .is_err()
1164 );
1165
1166 assert!(
1168 ComponentDependencies::deserialize(toml! {
1169 "foo:bar/baz@0.1.0" = "0.1.0"
1170 "foo:bar" = "0.1.0"
1171 })
1172 .unwrap()
1173 .validate()
1174 .is_err()
1175 );
1176
1177 assert!(
1179 ComponentDependencies::deserialize(toml! {
1180 "foo:bar/baz" = "0.1.0"
1181 "foo:bar@0.1.0" = "0.1.0"
1182 })
1183 .unwrap()
1184 .validate()
1185 .is_err()
1186 );
1187
1188 assert!(
1190 ComponentDependencies::deserialize(toml! {
1191 "foo:bar/baz" = "0.1.0"
1192 "foo:bar" = "0.1.0"
1193 })
1194 .unwrap()
1195 .validate()
1196 .is_err()
1197 );
1198 }
1199
1200 fn normalized_component(
1201 manifest: &AppManifest,
1202 component: &str,
1203 profile: Option<&str>,
1204 ) -> Component {
1205 use crate::normalize::normalize_manifest;
1206
1207 let id =
1208 KebabId::try_from(component.to_owned()).expect("component ID should have been kebab");
1209
1210 let mut manifest = manifest.clone();
1211 normalize_manifest(&mut manifest, profile).expect("should have normalised");
1212 manifest
1213 .components
1214 .get(&id)
1215 .expect("should have compopnent with id profile-test")
1216 .clone()
1217 }
1218
1219 #[test]
1220 fn profiles_override_source() {
1221 let manifest = AppManifest::deserialize(toml! {
1222 spin_manifest_version = 2
1223 [application]
1224 name = "trigger-configs"
1225 [[trigger.fake]]
1226 component = "profile-test"
1227 [component.profile-test]
1228 source = "original"
1229 [component.profile-test.profile.fancy]
1230 source = "fancy-schmancy"
1231 })
1232 .expect("manifest should be valid");
1233
1234 let id = "profile-test";
1235
1236 let component = normalized_component(&manifest, id, None);
1237 assert!(matches!(&component.source, ComponentSource::Local(p) if p == "original"));
1238
1239 let component = normalized_component(&manifest, id, Some("fancy"));
1240 assert!(matches!(&component.source, ComponentSource::Local(p) if p == "fancy-schmancy"));
1241
1242 let component = normalized_component(&manifest, id, Some("non-existent"));
1243 assert!(matches!(&component.source, ComponentSource::Local(p) if p == "original"));
1244 }
1245
1246 #[test]
1247 fn profiles_override_build_command() {
1248 let manifest = AppManifest::deserialize(toml! {
1249 spin_manifest_version = 2
1250 [application]
1251 name = "trigger-configs"
1252 [[trigger.fake]]
1253 component = "profile-test"
1254 [component.profile-test]
1255 source = "original"
1256 build.command = "buildme --release"
1257 [component.profile-test.profile.fancy]
1258 source = "fancy-schmancy"
1259 build.command = ["buildme --fancy", "lintme"]
1260 })
1261 .expect("manifest should be valid");
1262
1263 let id = "profile-test";
1264
1265 let build = normalized_component(&manifest, id, None)
1266 .build
1267 .expect("should have default build");
1268 assert_eq!(1, build.commands().len());
1269 assert_eq!("buildme --release", build.commands().next().unwrap());
1270
1271 let build = normalized_component(&manifest, id, Some("fancy"))
1272 .build
1273 .expect("should have fancy build");
1274 assert_eq!(2, build.commands().len());
1275 assert_eq!("buildme --fancy", build.commands().next().unwrap());
1276 assert_eq!("lintme", build.commands().nth(1).unwrap());
1277
1278 let build = normalized_component(&manifest, id, Some("non-existent"))
1279 .build
1280 .expect("should fall back to default build");
1281 assert_eq!(1, build.commands().len());
1282 assert_eq!("buildme --release", build.commands().next().unwrap());
1283 }
1284
1285 #[test]
1286 fn profiles_can_have_build_command_when_default_doesnt() {
1287 let manifest = AppManifest::deserialize(toml! {
1288 spin_manifest_version = 2
1289 [application]
1290 name = "trigger-configs"
1291 [[trigger.fake]]
1292 component = "profile-test"
1293 [component.profile-test]
1294 source = "original"
1295 [component.profile-test.profile.fancy]
1296 source = "fancy-schmancy"
1297 build.command = ["buildme --fancy", "lintme"]
1298 })
1299 .expect("manifest should be valid");
1300
1301 let component = normalized_component(&manifest, "profile-test", None);
1302 assert!(component.build.is_none(), "shouldn't have default build");
1303
1304 let component = normalized_component(&manifest, "profile-test", Some("fancy"));
1305 assert!(component.build.is_some(), "should have fancy build");
1306
1307 let build = component.build.expect("should have fancy build");
1308
1309 assert_eq!(2, build.commands().len());
1310 assert_eq!("buildme --fancy", build.commands().next().unwrap());
1311 assert_eq!("lintme", build.commands().nth(1).unwrap());
1312 }
1313
1314 #[test]
1315 fn profiles_override_env_vars() {
1316 let manifest = AppManifest::deserialize(toml! {
1317 spin_manifest_version = 2
1318 [application]
1319 name = "trigger-configs"
1320 [[trigger.fake]]
1321 component = "profile-test"
1322 [component.profile-test]
1323 source = "original"
1324 environment = { DB_URL = "pg://production" }
1325 [component.profile-test.profile.fancy]
1326 environment = { DB_URL = "pg://fancy", FANCINESS = "1" }
1327 })
1328 .expect("manifest should be valid");
1329
1330 let id = "profile-test";
1331
1332 let component = normalized_component(&manifest, id, None);
1333
1334 assert_eq!(1, component.environment.len());
1335 assert_eq!(
1336 "pg://production",
1337 component
1338 .environment
1339 .get("DB_URL")
1340 .expect("DB_URL should have been set")
1341 );
1342
1343 let component = normalized_component(&manifest, id, Some("fancy"));
1344
1345 assert_eq!(2, component.environment.len());
1346 assert_eq!(
1347 "pg://fancy",
1348 component
1349 .environment
1350 .get("DB_URL")
1351 .expect("DB_URL should have been set")
1352 );
1353 assert_eq!(
1354 "1",
1355 component
1356 .environment
1357 .get("FANCINESS")
1358 .expect("FANCINESS should have been set")
1359 );
1360 }
1361
1362 #[test]
1363 fn profiles_dependencies() {
1364 let manifest = AppManifest::deserialize(toml! {
1365 spin_manifest_version = 2
1366 [application]
1367 name = "trigger-configs"
1368 [[trigger.fake]]
1369 component = "profile-test"
1370 [component.profile-test]
1371 source = "original"
1372 [component.profile-test.dependencies]
1373 "foo-bar" = "1.0.0"
1374 [component.profile-test.profile.fancy]
1375 dependencies = { "foo-bar" = { path = "local.wasm" }, "fancy-thing" = "1.2.3" }
1376 })
1377 .expect("manifest should be valid");
1378
1379 let id = "profile-test";
1380
1381 let component = normalized_component(&manifest, id, None);
1382
1383 assert_eq!(1, component.dependencies.inner.len());
1384 assert!(matches!(
1385 component
1386 .dependencies
1387 .inner
1388 .get(&DependencyName::Plain(KebabId::try_from("foo-bar".to_owned()).unwrap()))
1389 .expect("foo-bar dep should have been set"),
1390 ComponentDependency::Version(v) if v == "1.0.0",
1391 ));
1392
1393 let component = normalized_component(&manifest, id, Some("fancy"));
1394
1395 assert_eq!(2, component.dependencies.inner.len());
1396 assert!(matches!(
1397 component
1398 .dependencies
1399 .inner
1400 .get(&DependencyName::Plain(KebabId::try_from("foo-bar".to_owned()).unwrap()))
1401 .expect("foo-bar dep should have been set"),
1402 ComponentDependency::Local { path, .. } if path == &PathBuf::from("local.wasm"),
1403 ));
1404 assert!(matches!(
1405 component
1406 .dependencies
1407 .inner
1408 .get(&DependencyName::Plain(KebabId::try_from("fancy-thing".to_owned()).unwrap()))
1409 .expect("fancy-thing dep should have been set"),
1410 ComponentDependency::Version(v) if v == "1.2.3",
1411 ));
1412 }
1413}