1use 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")]
24 pub variables: Map<LowerSnakeId, Variable>,
25 #[serde(rename = "trigger")]
27 #[schemars(with = "json_schema::TriggerSchema")]
28 pub triggers: Map<String, Vec<Trigger>>,
29 #[serde(rename = "component")]
31 #[serde(default, skip_serializing_if = "Map::is_empty")]
32 pub components: Map<KebabId, Component>,
33}
34
35impl AppManifest {
36 pub fn validate_dependencies(&self) -> anyhow::Result<()> {
38 for (component_id, component) in &self.components {
39 component
40 .dependencies
41 .validate()
42 .with_context(|| format!("component {component_id:?} has invalid dependencies"))?;
43 }
44 Ok(())
45 }
46}
47
48#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
50#[serde(deny_unknown_fields)]
51pub struct AppDetails {
52 pub name: String,
54 #[serde(default, skip_serializing_if = "String::is_empty")]
56 pub version: String,
57 #[serde(default, skip_serializing_if = "String::is_empty")]
59 pub description: String,
60 #[serde(default, skip_serializing_if = "Vec::is_empty")]
62 pub authors: Vec<String>,
63 #[serde(rename = "trigger", default, skip_serializing_if = "Map::is_empty")]
65 #[schemars(schema_with = "json_schema::map_of_toml_tables")]
66 pub trigger_global_configs: Map<String, toml::Table>,
67 #[serde(default, skip_serializing_if = "Map::is_empty")]
69 #[schemars(schema_with = "json_schema::map_of_toml_tables")]
70 pub tool: Map<String, toml::Table>,
71}
72
73#[derive(Clone, Debug, Serialize, Deserialize)]
75pub struct Trigger {
76 #[serde(default, skip_serializing_if = "String::is_empty")]
78 pub id: String,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub component: Option<ComponentSpec>,
82 #[serde(default, skip_serializing_if = "Map::is_empty")]
84 pub components: Map<String, OneOrManyComponentSpecs>,
85 #[serde(flatten)]
87 pub config: toml::Table,
88}
89
90#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
92#[serde(transparent)]
93pub struct OneOrManyComponentSpecs(
94 #[serde(with = "one_or_many")]
95 #[schemars(schema_with = "json_schema::one_or_many::<ComponentSpec>")]
96 pub Vec<ComponentSpec>,
97);
98
99#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
101#[serde(deny_unknown_fields, untagged, try_from = "toml::Value")]
102pub enum ComponentSpec {
103 Reference(KebabId),
105 Inline(Box<Component>),
107}
108
109impl TryFrom<toml::Value> for ComponentSpec {
110 type Error = toml::de::Error;
111
112 fn try_from(value: toml::Value) -> Result<Self, Self::Error> {
113 if value.is_str() {
114 Ok(ComponentSpec::Reference(KebabId::deserialize(value)?))
115 } else {
116 Ok(ComponentSpec::Inline(Box::new(Component::deserialize(
117 value,
118 )?)))
119 }
120 }
121}
122
123#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
125#[serde(untagged, deny_unknown_fields)]
126pub enum ComponentDependency {
127 Version(String),
129 Package {
131 version: String,
133 registry: Option<String>,
135 package: Option<String>,
138 export: Option<String>,
140 },
141 Local {
143 path: PathBuf,
145 export: Option<String>,
147 },
148 HTTP {
150 url: String,
152 digest: String,
154 export: Option<String>,
156 },
157}
158
159#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
161#[serde(deny_unknown_fields)]
162pub struct Component {
163 pub source: ComponentSource,
165 #[serde(default, skip_serializing_if = "String::is_empty")]
167 pub description: String,
168 #[serde(default, skip_serializing_if = "Map::is_empty")]
170 pub variables: Map<LowerSnakeId, String>,
171 #[serde(default, skip_serializing_if = "Map::is_empty")]
173 pub environment: Map<String, String>,
174 #[serde(default, skip_serializing_if = "Vec::is_empty")]
176 pub files: Vec<WasiFilesMount>,
177 #[serde(default, skip_serializing_if = "Vec::is_empty")]
179 pub exclude_files: Vec<String>,
180 #[serde(default, skip_serializing_if = "Vec::is_empty")]
182 #[deprecated]
183 pub allowed_http_hosts: Vec<String>,
184 #[serde(default, skip_serializing_if = "Vec::is_empty")]
186 pub allowed_outbound_hosts: Vec<String>,
187 #[serde(
189 default,
190 with = "kebab_or_snake_case",
191 skip_serializing_if = "Vec::is_empty"
192 )]
193 #[schemars(with = "Vec<String>")]
194 pub key_value_stores: Vec<String>,
195 #[serde(
197 default,
198 with = "kebab_or_snake_case",
199 skip_serializing_if = "Vec::is_empty"
200 )]
201 #[schemars(with = "Vec<String>")]
202 pub sqlite_databases: Vec<String>,
203 #[serde(default, skip_serializing_if = "Vec::is_empty")]
205 pub ai_models: Vec<KebabId>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub build: Option<ComponentBuildConfig>,
209 #[serde(default, skip_serializing_if = "Map::is_empty")]
211 #[schemars(schema_with = "json_schema::map_of_toml_tables")]
212 pub tool: Map<String, toml::Table>,
213 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
215 pub dependencies_inherit_configuration: bool,
216 #[serde(default, skip_serializing_if = "ComponentDependencies::is_empty")]
218 pub dependencies: ComponentDependencies,
219}
220
221#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
223#[serde(transparent)]
224pub struct ComponentDependencies {
225 pub inner: Map<DependencyName, ComponentDependency>,
227}
228
229impl ComponentDependencies {
230 fn validate(&self) -> anyhow::Result<()> {
234 self.ensure_plain_names_have_package()?;
235 self.ensure_package_names_no_export()?;
236 self.ensure_disjoint()?;
237 Ok(())
238 }
239
240 fn ensure_plain_names_have_package(&self) -> anyhow::Result<()> {
244 for (dependency_name, dependency) in self.inner.iter() {
245 let DependencyName::Plain(plain) = dependency_name else {
246 continue;
247 };
248 match dependency {
249 ComponentDependency::Package { package, .. } if package.is_none() => {}
250 ComponentDependency::Version(_) => {}
251 _ => continue,
252 }
253 anyhow::bail!("dependency {plain:?} must specify a package name");
254 }
255 Ok(())
256 }
257
258 fn ensure_package_names_no_export(&self) -> anyhow::Result<()> {
262 for (dependency_name, dependency) in self.inner.iter() {
263 if let DependencyName::Package(name) = dependency_name {
264 if name.interface.is_none() {
265 let export = match dependency {
266 ComponentDependency::Package { export, .. } => export,
267 ComponentDependency::Local { export, .. } => export,
268 _ => continue,
269 };
270
271 anyhow::ensure!(
272 export.is_none(),
273 "using an export to satisfy the package dependency {dependency_name:?} is not currently permitted",
274 );
275 }
276 }
277 }
278 Ok(())
279 }
280
281 fn ensure_disjoint(&self) -> anyhow::Result<()> {
284 for (idx, this) in self.inner.keys().enumerate() {
285 for other in self.inner.keys().skip(idx + 1) {
286 let DependencyName::Package(other) = other else {
287 continue;
288 };
289 let DependencyName::Package(this) = this else {
290 continue;
291 };
292
293 if this.package == other.package {
294 Self::check_disjoint(this, other)?;
295 }
296 }
297 }
298 Ok(())
299 }
300
301 fn check_disjoint(
302 this: &DependencyPackageName,
303 other: &DependencyPackageName,
304 ) -> anyhow::Result<()> {
305 assert_eq!(this.package, other.package);
306
307 if let (Some(this_ver), Some(other_ver)) = (this.version.clone(), other.version.clone()) {
308 if Self::normalize_compatible_version(this_ver)
309 != Self::normalize_compatible_version(other_ver)
310 {
311 return Ok(());
312 }
313 }
314
315 if let (Some(this_itf), Some(other_itf)) =
316 (this.interface.as_ref(), other.interface.as_ref())
317 {
318 if this_itf != other_itf {
319 return Ok(());
320 }
321 }
322
323 anyhow::bail!("{this:?} dependency conflicts with {other:?}")
324 }
325
326 fn normalize_compatible_version(mut version: semver::Version) -> semver::Version {
330 version.build = semver::BuildMetadata::EMPTY;
331
332 if version.pre != semver::Prerelease::EMPTY {
333 return version;
334 }
335 if version.major > 0 {
336 version.minor = 0;
337 version.patch = 0;
338 return version;
339 }
340
341 if version.minor > 0 {
342 version.patch = 0;
343 return version;
344 }
345
346 version
347 }
348
349 fn is_empty(&self) -> bool {
350 self.inner.is_empty()
351 }
352}
353
354mod kebab_or_snake_case {
355 use serde::{Deserialize, Serialize};
356 pub use spin_serde::{KebabId, SnakeId};
357 pub fn serialize<S>(value: &[String], serializer: S) -> Result<S::Ok, S::Error>
358 where
359 S: serde::ser::Serializer,
360 {
361 if value.iter().all(|s| {
362 KebabId::try_from(s.clone()).is_ok() || SnakeId::try_from(s.to_owned()).is_ok()
363 }) {
364 value.serialize(serializer)
365 } else {
366 Err(serde::ser::Error::custom(
367 "expected kebab-case or snake_case",
368 ))
369 }
370 }
371
372 pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
373 where
374 D: serde::Deserializer<'de>,
375 {
376 let value = toml::Value::deserialize(deserializer)?;
377 let list: Vec<String> = Vec::deserialize(value).map_err(serde::de::Error::custom)?;
378 if list.iter().all(|s| {
379 KebabId::try_from(s.clone()).is_ok() || SnakeId::try_from(s.to_owned()).is_ok()
380 }) {
381 Ok(list)
382 } else {
383 Err(serde::de::Error::custom(
384 "expected kebab-case or snake_case",
385 ))
386 }
387 }
388}
389
390impl Component {
391 pub fn normalized_allowed_outbound_hosts(&self) -> anyhow::Result<Vec<String>> {
394 #[allow(deprecated)]
395 let normalized =
396 crate::compat::convert_allowed_http_to_allowed_hosts(&self.allowed_http_hosts, false)?;
397 if !normalized.is_empty() {
398 terminal::warn!(
399 "Use of the deprecated field `allowed_http_hosts` - to fix, \
400 replace `allowed_http_hosts` with `allowed_outbound_hosts = {normalized:?}`",
401 )
402 }
403
404 Ok(self
405 .allowed_outbound_hosts
406 .iter()
407 .cloned()
408 .chain(normalized)
409 .collect())
410 }
411}
412
413mod one_or_many {
414 use serde::{Deserialize, Deserializer, Serialize, Serializer};
415
416 pub fn serialize<T, S>(vec: &Vec<T>, serializer: S) -> Result<S::Ok, S::Error>
417 where
418 T: Serialize,
419 S: Serializer,
420 {
421 if vec.len() == 1 {
422 vec[0].serialize(serializer)
423 } else {
424 vec.serialize(serializer)
425 }
426 }
427
428 pub fn deserialize<'de, T, D>(deserializer: D) -> Result<Vec<T>, D::Error>
429 where
430 T: Deserialize<'de>,
431 D: Deserializer<'de>,
432 {
433 let value = toml::Value::deserialize(deserializer)?;
434 if let Ok(val) = T::deserialize(value.clone()) {
435 Ok(vec![val])
436 } else {
437 Vec::deserialize(value).map_err(serde::de::Error::custom)
438 }
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use toml::toml;
445
446 use super::*;
447
448 #[derive(Deserialize)]
449 #[allow(dead_code)]
450 struct FakeGlobalTriggerConfig {
451 global_option: bool,
452 }
453
454 #[derive(Deserialize)]
455 #[allow(dead_code)]
456 struct FakeTriggerConfig {
457 option: Option<bool>,
458 }
459
460 #[test]
461 fn deserializing_trigger_configs() {
462 let manifest = AppManifest::deserialize(toml! {
463 spin_manifest_version = 2
464 [application]
465 name = "trigger-configs"
466 [application.trigger.fake]
467 global_option = true
468 [[trigger.fake]]
469 component = { source = "inline.wasm" }
470 option = true
471 })
472 .unwrap();
473
474 FakeGlobalTriggerConfig::deserialize(
475 manifest.application.trigger_global_configs["fake"].clone(),
476 )
477 .unwrap();
478
479 FakeTriggerConfig::deserialize(manifest.triggers["fake"][0].config.clone()).unwrap();
480 }
481
482 #[derive(Deserialize)]
483 #[allow(dead_code)]
484 struct FakeGlobalToolConfig {
485 lint_level: String,
486 }
487
488 #[derive(Deserialize)]
489 #[allow(dead_code)]
490 struct FakeComponentToolConfig {
491 command: String,
492 }
493
494 #[test]
495 fn deserialising_custom_tool_settings() {
496 let manifest = AppManifest::deserialize(toml! {
497 spin_manifest_version = 2
498 [application]
499 name = "trigger-configs"
500 [application.tool.lint]
501 lint_level = "savage"
502 [[trigger.fake]]
503 something = "something else"
504 [component.fake]
505 source = "dummy"
506 [component.fake.tool.clean]
507 command = "cargo clean"
508 })
509 .unwrap();
510
511 FakeGlobalToolConfig::deserialize(manifest.application.tool["lint"].clone()).unwrap();
512 let fake_id: KebabId = "fake".to_owned().try_into().unwrap();
513 FakeComponentToolConfig::deserialize(manifest.components[&fake_id].tool["clean"].clone())
514 .unwrap();
515 }
516
517 #[test]
518 fn deserializing_labels() {
519 AppManifest::deserialize(toml! {
520 spin_manifest_version = 2
521 [application]
522 name = "trigger-configs"
523 [[trigger.fake]]
524 something = "something else"
525 [component.fake]
526 source = "dummy"
527 key_value_stores = ["default", "snake_case", "kebab-case"]
528 sqlite_databases = ["default", "snake_case", "kebab-case"]
529 })
530 .unwrap();
531 }
532
533 #[test]
534 fn deserializing_labels_fails_for_non_kebab_or_snake() {
535 assert!(AppManifest::deserialize(toml! {
536 spin_manifest_version = 2
537 [application]
538 name = "trigger-configs"
539 [[trigger.fake]]
540 something = "something else"
541 [component.fake]
542 source = "dummy"
543 key_value_stores = ["b@dlabel"]
544 })
545 .is_err());
546 }
547
548 fn get_test_component_with_labels(labels: Vec<String>) -> Component {
549 #[allow(deprecated)]
550 Component {
551 source: ComponentSource::Local("dummy".to_string()),
552 description: "".to_string(),
553 variables: Map::new(),
554 environment: Map::new(),
555 files: vec![],
556 exclude_files: vec![],
557 allowed_http_hosts: vec![],
558 allowed_outbound_hosts: vec![],
559 key_value_stores: labels.clone(),
560 sqlite_databases: labels,
561 ai_models: vec![],
562 build: None,
563 tool: Map::new(),
564 dependencies_inherit_configuration: false,
565 dependencies: Default::default(),
566 }
567 }
568
569 #[test]
570 fn serialize_labels() {
571 let stores = vec![
572 "default".to_string(),
573 "snake_case".to_string(),
574 "kebab-case".to_string(),
575 ];
576 let component = get_test_component_with_labels(stores.clone());
577 let serialized = toml::to_string(&component).unwrap();
578 let deserialized = toml::from_str::<Component>(&serialized).unwrap();
579 assert_eq!(deserialized.key_value_stores, stores);
580 }
581
582 #[test]
583 fn serialize_labels_fails_for_non_kebab_or_snake() {
584 let component = get_test_component_with_labels(vec!["camelCase".to_string()]);
585 assert!(toml::to_string(&component).is_err());
586 }
587
588 #[test]
589 fn test_valid_snake_ids() {
590 for valid in ["default", "mixed_CASE_words", "letters1_then2_numbers345"] {
591 if let Err(err) = SnakeId::try_from(valid.to_string()) {
592 panic!("{valid:?} should be value: {err:?}");
593 }
594 }
595 }
596
597 #[test]
598 fn test_invalid_snake_ids() {
599 for invalid in [
600 "",
601 "kebab-case",
602 "_leading_underscore",
603 "trailing_underscore_",
604 "double__underscore",
605 "1initial_number",
606 "unicode_snowpeople☃☃☃",
607 "mIxEd_case",
608 "MiXeD_case",
609 ] {
610 if SnakeId::try_from(invalid.to_string()).is_ok() {
611 panic!("{invalid:?} should not be a valid SnakeId");
612 }
613 }
614 }
615
616 #[test]
617 fn test_check_disjoint() {
618 for (a, b) in [
619 ("foo:bar@0.1.0", "foo:bar@0.2.0"),
620 ("foo:bar/baz@0.1.0", "foo:bar/baz@0.2.0"),
621 ("foo:bar/baz@0.1.0", "foo:bar/bub@0.1.0"),
622 ("foo:bar@0.1.0", "foo:bar/bub@0.2.0"),
623 ("foo:bar@1.0.0", "foo:bar@2.0.0"),
624 ("foo:bar@0.1.0", "foo:bar@1.0.0"),
625 ("foo:bar/baz", "foo:bar/bub"),
626 ("foo:bar/baz@0.1.0-alpha", "foo:bar/baz@0.1.0-beta"),
627 ] {
628 let a: DependencyPackageName = a.parse().expect(a);
629 let b: DependencyPackageName = b.parse().expect(b);
630 ComponentDependencies::check_disjoint(&a, &b).unwrap();
631 }
632
633 for (a, b) in [
634 ("foo:bar@0.1.0", "foo:bar@0.1.1"),
635 ("foo:bar/baz@0.1.0", "foo:bar@0.1.0"),
636 ("foo:bar/baz@0.1.0", "foo:bar@0.1.0"),
637 ("foo:bar", "foo:bar@0.1.0"),
638 ("foo:bar@0.1.0-pre", "foo:bar@0.1.0-pre"),
639 ] {
640 let a: DependencyPackageName = a.parse().expect(a);
641 let b: DependencyPackageName = b.parse().expect(b);
642 assert!(
643 ComponentDependencies::check_disjoint(&a, &b).is_err(),
644 "{a} should conflict with {b}",
645 );
646 }
647 }
648
649 #[test]
650 fn test_validate_dependencies() {
651 assert!(ComponentDependencies::deserialize(toml! {
653 "plain-name" = "0.1.0"
654 })
655 .unwrap()
656 .validate()
657 .is_err());
658
659 assert!(ComponentDependencies::deserialize(toml! {
661 "plain-name" = { version = "0.1.0" }
662 })
663 .unwrap()
664 .validate()
665 .is_err());
666
667 assert!(ComponentDependencies::deserialize(toml! {
669 "foo:baz@0.1.0" = { path = "foo.wasm", export = "foo"}
670 })
671 .unwrap()
672 .validate()
673 .is_err());
674
675 assert!(ComponentDependencies::deserialize(toml! {
677 "foo:baz@0.1.0" = "0.1.0"
678 "foo:bar@0.2.1" = "0.2.1"
679 "foo:bar@0.2.2" = "0.2.2"
680 })
681 .unwrap()
682 .validate()
683 .is_err());
684
685 assert!(ComponentDependencies::deserialize(toml! {
687 "foo:bar@0.1.0" = "0.1.0"
688 "foo:bar@0.2.0" = "0.2.0"
689 "foo:baz@0.2.0" = "0.1.0"
690 })
691 .unwrap()
692 .validate()
693 .is_ok());
694
695 assert!(ComponentDependencies::deserialize(toml! {
697 "foo:bar@0.1.0" = "0.1.0"
698 "foo:bar" = ">= 0.2.0"
699 })
700 .unwrap()
701 .validate()
702 .is_err());
703
704 assert!(ComponentDependencies::deserialize(toml! {
706 "foo:bar/baz@0.1.0" = "0.1.0"
707 "foo:bar/baz@0.2.0" = "0.2.0"
708 })
709 .unwrap()
710 .validate()
711 .is_ok());
712
713 assert!(ComponentDependencies::deserialize(toml! {
715 "foo:bar/baz@0.1.0" = "0.1.0"
716 "foo:bar@0.2.0" = "0.2.0"
717 })
718 .unwrap()
719 .validate()
720 .is_ok());
721
722 assert!(ComponentDependencies::deserialize(toml! {
724 "foo:bar/baz@0.1.0" = "0.1.0"
725 "foo:bar@0.1.0" = "0.1.0"
726 })
727 .unwrap()
728 .validate()
729 .is_err());
730
731 assert!(ComponentDependencies::deserialize(toml! {
733 "foo:bar/baz@0.1.0" = "0.1.0"
734 "foo:bar" = "0.1.0"
735 })
736 .unwrap()
737 .validate()
738 .is_err());
739
740 assert!(ComponentDependencies::deserialize(toml! {
742 "foo:bar/baz" = "0.1.0"
743 "foo:bar@0.1.0" = "0.1.0"
744 })
745 .unwrap()
746 .validate()
747 .is_err());
748
749 assert!(ComponentDependencies::deserialize(toml! {
751 "foo:bar/baz" = "0.1.0"
752 "foo:bar" = "0.1.0"
753 })
754 .unwrap()
755 .validate()
756 .is_err());
757 }
758}