spin_manifest/schema/
v2.rs

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/// App manifest
14#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
15#[serde(deny_unknown_fields)]
16pub struct AppManifest {
17    /// `spin_manifest_version = 2`
18    #[schemars(with = "usize", range = (min = 2, max = 2))]
19    pub spin_manifest_version: FixedVersion<2>,
20    /// `[application]`
21    pub application: AppDetails,
22    /// Application configuration variables. These can be set via environment variables, or
23    /// from sources such as Hashicorp Vault or Azure KeyVault by using a runtime config file.
24    /// They are not available directly to components: use a component variable to ingest them.
25    ///
26    /// Learn more: https://spinframework.dev/variables, https://spinframework.dev/dynamic-configuration#application-variables-runtime-configuration
27    #[serde(default, skip_serializing_if = "Map::is_empty")]
28    pub variables: Map<LowerSnakeId, Variable>,
29    /// The triggers to which the application responds. Most triggers can appear
30    /// multiple times with different parameters: for example, the `http` trigger may
31    /// appear multiple times with different routes, or the `redis` trigger with
32    /// different channels.
33    ///
34    /// Example: `[[trigger.http]]`
35    #[serde(rename = "trigger")]
36    #[schemars(with = "json_schema::TriggerSchema")]
37    pub triggers: Map<String, Vec<Trigger>>,
38    /// `[component.<id>]`
39    #[serde(rename = "component")]
40    #[serde(default, skip_serializing_if = "Map::is_empty")]
41    pub components: Map<KebabId, Component>,
42}
43
44impl AppManifest {
45    /// This method ensures that the dependencies of each component are valid.
46    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
57/// App details
58#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
59#[serde(deny_unknown_fields)]
60pub struct AppDetails {
61    /// The name of the application.
62    ///
63    /// Example: `name = "my-app"`
64    pub name: String,
65    /// The application version. This should be a valid semver version.
66    ///
67    /// Example: `version = "1.0.0"`
68    #[serde(default, skip_serializing_if = "String::is_empty")]
69    pub version: String,
70    /// A human-readable description of the application.
71    ///
72    /// Example: `description = "App description"`
73    #[serde(default, skip_serializing_if = "String::is_empty")]
74    pub description: String,
75    /// The author(s) of the application.
76    ///
77    /// `authors = ["author@example.com"]`
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    pub authors: Vec<String>,
80    /// The Spin environments with which the application must be compatible.
81    ///
82    /// Example: `targets = ["spin-up:3.3", "spinkube:0.4"]`
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub targets: Vec<TargetEnvironmentRef>,
85    /// Application-level settings for the trigger types used in the application.
86    /// The possible values are trigger type-specific.
87    ///
88    /// Example:
89    ///
90    /// ```ignore
91    /// [application.triggers.redis]
92    /// address = "redis://notifications.example.com:6379"
93    /// ```
94    ///
95    /// Learn more (Redis example): https://spinframework.dev/redis-trigger#setting-a-default-server
96    #[serde(rename = "trigger", default, skip_serializing_if = "Map::is_empty")]
97    #[schemars(schema_with = "json_schema::map_of_toml_tables")]
98    pub trigger_global_configs: Map<String, toml::Table>,
99    /// Settings for custom tools or plugins. Spin ignores this field.
100    #[serde(default, skip_serializing_if = "Map::is_empty")]
101    #[schemars(schema_with = "json_schema::map_of_toml_tables")]
102    pub tool: Map<String, toml::Table>,
103}
104
105/// Trigger configuration. A trigger maps an event of the trigger's type (e.g.
106/// an HTTP request on route `/shop`, a Redis message on channel `orders`) to
107/// a Spin component.
108///
109/// The trigger manifest contains additional fields which depend on the trigger
110/// type. For the `http` type, these additional fields are `route` (required) and
111/// `executor` (optional). For the `redis` type, the additional fields are
112/// `channel` (required) and `address` (optional). For other types, see the trigger
113/// documentation.
114///
115/// Learn more: https://spinframework.dev/http-trigger, https://spinframework.dev/redis-trigger
116#[derive(Clone, Debug, Serialize, Deserialize)]
117pub struct Trigger {
118    /// Optional identifier for the trigger.
119    ///
120    /// Example: `id = "trigger-id"`
121    #[serde(default, skip_serializing_if = "String::is_empty")]
122    pub id: String,
123    /// The component that Spin should run when the trigger occurs. For HTTP triggers,
124    /// this is the HTTP request handler for the trigger route. This is typically
125    /// the ID of an entry in the `[component]` table, although you can also write
126    /// the component out as the value of this field.
127    ///
128    /// Example: `component = "shop-handler"`
129    ///
130    /// Learn more: https://spinframework.dev/triggers#triggers-and-components
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub component: Option<ComponentSpec>,
133    /// Reserved for future use.
134    ///
135    /// `components = { ... }`
136    #[serde(default, skip_serializing_if = "Map::is_empty")]
137    pub components: Map<String, OneOrManyComponentSpecs>,
138    /// Opaque trigger-type-specific config
139    #[serde(flatten)]
140    pub config: toml::Table,
141}
142
143/// One or many `ComponentSpec`(s)
144#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
145#[serde(transparent)]
146pub struct OneOrManyComponentSpecs(
147    #[serde(with = "one_or_many")]
148    #[schemars(schema_with = "json_schema::one_or_many::<ComponentSpec>")]
149    pub Vec<ComponentSpec>,
150);
151
152/// Component reference or inline definition
153#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
154#[serde(deny_unknown_fields, untagged, try_from = "toml::Value")]
155pub enum ComponentSpec {
156    /// `"component-id"`
157    Reference(KebabId),
158    /// `{ ... }`
159    Inline(Box<Component>),
160}
161
162impl TryFrom<toml::Value> for ComponentSpec {
163    type Error = toml::de::Error;
164
165    fn try_from(value: toml::Value) -> Result<Self, Self::Error> {
166        if value.is_str() {
167            Ok(ComponentSpec::Reference(KebabId::deserialize(value)?))
168        } else {
169            Ok(ComponentSpec::Inline(Box::new(Component::deserialize(
170                value,
171            )?)))
172        }
173    }
174}
175
176/// Specifies how to satisfy an import dependency of the component. This may be one of:
177///
178/// - A semantic versioning constraint for the package version to use. Spin fetches the latest matching version of the package whose name matches the dependency name from the default registry.
179///
180/// Example: `"my:dep/import" = ">= 0.1.0"`
181///
182/// - A package from a registry.
183///
184/// Example: `"my:dep/import" = { version = "0.1.0", registry = "registry.io", ...}`
185///
186/// - A package from a filesystem path.
187///
188/// Example: `"my:dependency" = { path = "path/to/component.wasm", export = "my-export" }`
189///
190/// - A package from an HTTP URL.
191///
192/// Example: `"my:import" = { url = "https://example.com/component.wasm", sha256 = "sha256:..." }`
193///
194/// Learn more: https://spinframework.dev/v3/writing-apps#using-component-dependencies
195#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
196#[serde(untagged, deny_unknown_fields)]
197pub enum ComponentDependency {
198    /// `... = ">= 0.1.0"`
199    #[schemars(description = "")] // schema docs are on the parent
200    Version(String),
201    /// `... = { version = "0.1.0", registry = "registry.io", ...}`
202    #[schemars(description = "")] // schema docs are on the parent
203    Package {
204        /// A semantic versioning constraint for the package version to use. Required. Spin
205        /// fetches the latest matching version from the specified registry, or from
206        /// the default registry if no registry is specified.
207        ///
208        /// Example: `"my:dep/import" = { version = ">= 0.1.0" }`
209        ///
210        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-registry
211        version: String,
212        /// The registry that hosts the package. If omitted, this defaults to your
213        /// system default registry.
214        ///
215        /// Example: `"my:dep/import" = { registry = "registry.io", version = " 0.1.0" }`
216        ///
217        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-registry
218        registry: Option<String>,
219        /// The name of the package to use. If omitted, this defaults to the package name of the
220        /// imported interface.
221        ///
222        /// Example: `"my:dep/import" = { package = "your:implementation", version = " 0.1.0" }`
223        ///
224        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-registry
225        package: Option<String>,
226        /// The name of the export in the package. If omitted, this defaults to the name of the import.
227        ///
228        /// Example: `"my:dep/import" = { export = "your:impl/export", version = " 0.1.0" }`
229        ///
230        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-registry
231        export: Option<String>,
232    },
233    /// `... = { path = "path/to/component.wasm", export = "my-export" }`
234    #[schemars(description = "")] // schema docs are on the parent
235    Local {
236        /// The path to the Wasm file that implements the dependency.
237        ///
238        /// Example: `"my:dep/import" = { path = "path/to/component.wasm" }`
239        ///
240        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-local-component
241        path: PathBuf,
242        /// The name of the export in the package. If omitted, this defaults to the name of the import.
243        ///
244        /// Example: `"my:dep/import" = { export = "your:impl/export", path = "path/to/component.wasm" }`
245        ///
246        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-local-component
247        export: Option<String>,
248    },
249    /// `... = { url = "https://example.com/component.wasm", sha256 = "..." }`
250    #[schemars(description = "")] // schema docs are on the parent
251    HTTP {
252        /// The URL to the Wasm component that implements the dependency.
253        ///
254        /// Example: `"my:dep/import" = { url = "https://example.com/component.wasm", sha256 = "sha256:..." }`
255        ///
256        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-url
257        url: String,
258        /// The SHA256 digest of the Wasm file. This is required for integrity checking. Must begin with `sha256:`.
259        ///
260        /// Example: `"my:dep/import" = { sha256 = "sha256:...", ... }`
261        ///
262        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-url
263        digest: String,
264        /// The name of the export in the package. If omitted, this defaults to the name of the import.
265        ///
266        /// Example: `"my:dep/import" = { export = "your:impl/export", ... }`
267        ///
268        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-url
269        export: Option<String>,
270    },
271}
272
273/// A Spin component.
274#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
275#[serde(deny_unknown_fields)]
276pub struct Component {
277    /// The file, package, or URL containing the component Wasm binary.
278    ///
279    /// Example: `source = "bin/cart.wasm"`
280    ///
281    /// Learn more: https://spinframework.dev/writing-apps#the-component-source
282    pub source: ComponentSource,
283    /// A human-readable description of the component.
284    ///
285    /// Example: `description = "Shopping cart"`
286    #[serde(default, skip_serializing_if = "String::is_empty")]
287    pub description: String,
288    /// Configuration variables available to the component. Names must be
289    /// in `lower_snake_case`. Values are strings, and may refer
290    /// to application variables using `{{ ... }}` syntax.
291    ///
292    /// `variables = { users_endpoint = "https://{{ api_host }}/users"}`
293    ///
294    /// Learn more: https://spinframework.dev/variables#adding-variables-to-your-applications
295    #[serde(default, skip_serializing_if = "Map::is_empty")]
296    pub variables: Map<LowerSnakeId, String>,
297    /// Environment variables to be set for the Wasm module.
298    ///
299    /// `environment = { DB_URL = "mysql://spin:spin@localhost/dev" }`
300    #[serde(default, skip_serializing_if = "Map::is_empty")]
301    pub environment: Map<String, String>,
302    /// The files the component is allowed to read. Each list entry is either:
303    ///
304    /// - a glob pattern (e.g. "assets/**/*.jpg"); or
305    ///
306    /// - a source-destination pair indicating where a host directory should be mapped in the guest (e.g. { source = "assets", destination = "/" })
307    ///
308    /// Learn more: https://spinframework.dev/writing-apps#including-files-with-components
309    #[serde(default, skip_serializing_if = "Vec::is_empty")]
310    pub files: Vec<WasiFilesMount>,
311    /// Any files or glob patterns that should not be available to the
312    /// Wasm module at runtime, even though they match a `files`` entry.
313    ///
314    /// Example: `exclude_files = ["secrets/*"]`
315    ///
316    /// Learn more: https://spinframework.dev/writing-apps#including-files-with-components
317    #[serde(default, skip_serializing_if = "Vec::is_empty")]
318    pub exclude_files: Vec<String>,
319    /// Deprecated. Use `allowed_outbound_hosts` instead.
320    ///
321    /// Example: `allowed_http_hosts = ["example.com"]`
322    #[serde(default, skip_serializing_if = "Vec::is_empty")]
323    #[deprecated]
324    pub allowed_http_hosts: Vec<String>,
325    /// The network destinations which the component is allowed to access.
326    /// Each entry is in the form "(scheme)://(host)[:port]". Each element
327    /// allows * as a wildcard e.g. "https://\*" (HTTPS on the default port
328    /// to any destination) or "\*://localhost:\*" (any protocol to any port on
329    /// localhost). The host part allows segment wildcards for subdomains
330    /// e.g. "https://\*.example.com". Application variables are allowed using
331    /// `{{ my_var }}`` syntax.
332    ///
333    /// Example: `allowed_outbound_hosts = ["redis://myredishost.com:6379"]`
334    ///
335    /// Learn more: https://spinframework.dev/http-outbound#granting-http-permissions-to-components
336    #[serde(default, skip_serializing_if = "Vec::is_empty")]
337    #[schemars(with = "Vec<json_schema::AllowedOutboundHost>")]
338    pub allowed_outbound_hosts: Vec<String>,
339    /// The key-value stores which the component is allowed to access. Stores are identified
340    /// by label e.g. "default" or "customer". Stores other than "default" must be mapped
341    /// to a backing store in the runtime config.
342    ///
343    /// Example: `key_value_stores = ["default", "my-store"]`
344    ///
345    /// Learn more: https://spinframework.dev/kv-store-api-guide#custom-key-value-stores
346    #[serde(
347        default,
348        with = "kebab_or_snake_case",
349        skip_serializing_if = "Vec::is_empty"
350    )]
351    #[schemars(with = "Vec<json_schema::KeyValueStore>")]
352    pub key_value_stores: Vec<String>,
353    /// The SQLite databases which the component is allowed to access. Databases are identified
354    /// by label e.g. "default" or "analytics". Databases other than "default" must be mapped
355    /// to a backing store in the runtime config. Use "spin up --sqlite" to run database setup scripts.
356    ///
357    /// Example: `sqlite_databases = ["default", "my-database"]`
358    ///
359    /// Learn more: https://spinframework.dev/sqlite-api-guide#preparing-an-sqlite-database
360    #[serde(
361        default,
362        with = "kebab_or_snake_case",
363        skip_serializing_if = "Vec::is_empty"
364    )]
365    #[schemars(with = "Vec<json_schema::SqliteDatabase>")]
366    pub sqlite_databases: Vec<String>,
367    /// The AI models which the component is allowed to access. For local execution, you must
368    /// download all models; for hosted execution, you should check which models are available
369    /// in your target environment.
370    ///
371    /// Example: `ai_models = ["llama2-chat"]`
372    ///
373    /// Learn more: https://spinframework.dev/serverless-ai-api-guide#using-serverless-ai-from-applications
374    #[serde(default, skip_serializing_if = "Vec::is_empty")]
375    #[schemars(with = "Vec<json_schema::AIModel>")]
376    pub ai_models: Vec<String>,
377    /// The component build configuration.
378    ///
379    /// Learn more: https://spinframework.dev/build
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub build: Option<ComponentBuildConfig>,
382    /// Settings for custom tools or plugins. Spin ignores this field.
383    #[serde(default, skip_serializing_if = "Map::is_empty")]
384    #[schemars(schema_with = "json_schema::map_of_toml_tables")]
385    pub tool: Map<String, toml::Table>,
386    /// If true, dependencies can invoke Spin APIs with the same permissions as the main
387    /// component. If false, dependencies have no permissions (e.g. network,
388    /// key-value stores, SQLite databases).
389    ///
390    /// Learn more: https://spinframework.dev/writing-apps#dependency-permissions
391    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
392    pub dependencies_inherit_configuration: bool,
393    /// Specifies how to satisfy Wasm Component Model imports of this component.
394    ///
395    /// Learn more: https://spinframework.dev/writing-apps#using-component-dependencies
396    #[serde(default, skip_serializing_if = "ComponentDependencies::is_empty")]
397    pub dependencies: ComponentDependencies,
398}
399
400/// Component dependencies
401#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
402#[serde(transparent)]
403pub struct ComponentDependencies {
404    /// `dependencies = { "foo:bar" = ">= 0.1.0" }`
405    pub inner: Map<DependencyName, ComponentDependency>,
406}
407
408impl ComponentDependencies {
409    /// This method validates the correct specification of dependencies in a
410    /// component section of the manifest. See the documentation on the methods
411    /// called for more information on the specific checks.
412    fn validate(&self) -> anyhow::Result<()> {
413        self.ensure_plain_names_have_package()?;
414        self.ensure_package_names_no_export()?;
415        self.ensure_disjoint()?;
416        Ok(())
417    }
418
419    /// This method ensures that all dependency names in plain form (e.g.
420    /// "foo-bar") do not map to a `ComponentDependency::Version`, or a
421    /// `ComponentDependency::Package` where the `package` is `None`.
422    fn ensure_plain_names_have_package(&self) -> anyhow::Result<()> {
423        for (dependency_name, dependency) in self.inner.iter() {
424            let DependencyName::Plain(plain) = dependency_name else {
425                continue;
426            };
427            match dependency {
428                ComponentDependency::Package { package, .. } if package.is_none() => {}
429                ComponentDependency::Version(_) => {}
430                _ => continue,
431            }
432            anyhow::bail!("dependency {plain:?} must specify a package name");
433        }
434        Ok(())
435    }
436
437    /// This method ensures that dependency names in the package form (e.g.
438    /// "foo:bar" or "foo:bar@0.1.0") do not map to specific exported
439    /// interfaces, e.g. `"foo:bar = { ..., export = "my-export" }"` is invalid.
440    fn ensure_package_names_no_export(&self) -> anyhow::Result<()> {
441        for (dependency_name, dependency) in self.inner.iter() {
442            if let DependencyName::Package(name) = dependency_name {
443                if name.interface.is_none() {
444                    let export = match dependency {
445                        ComponentDependency::Package { export, .. } => export,
446                        ComponentDependency::Local { export, .. } => export,
447                        _ => continue,
448                    };
449
450                    anyhow::ensure!(
451                        export.is_none(),
452                        "using an export to satisfy the package dependency {dependency_name:?} is not currently permitted",
453                    );
454                }
455            }
456        }
457        Ok(())
458    }
459
460    /// This method ensures that dependencies names do not conflict with each other. That is to say
461    /// that two dependencies of the same package must have disjoint versions or interfaces.
462    fn ensure_disjoint(&self) -> anyhow::Result<()> {
463        for (idx, this) in self.inner.keys().enumerate() {
464            for other in self.inner.keys().skip(idx + 1) {
465                let DependencyName::Package(other) = other else {
466                    continue;
467                };
468                let DependencyName::Package(this) = this else {
469                    continue;
470                };
471
472                if this.package == other.package {
473                    Self::check_disjoint(this, other)?;
474                }
475            }
476        }
477        Ok(())
478    }
479
480    fn check_disjoint(
481        this: &DependencyPackageName,
482        other: &DependencyPackageName,
483    ) -> anyhow::Result<()> {
484        assert_eq!(this.package, other.package);
485
486        if let (Some(this_ver), Some(other_ver)) = (this.version.clone(), other.version.clone()) {
487            if Self::normalize_compatible_version(this_ver)
488                != Self::normalize_compatible_version(other_ver)
489            {
490                return Ok(());
491            }
492        }
493
494        if let (Some(this_itf), Some(other_itf)) =
495            (this.interface.as_ref(), other.interface.as_ref())
496        {
497            if this_itf != other_itf {
498                return Ok(());
499            }
500        }
501
502        Err(anyhow!("{this:?} dependency conflicts with {other:?}"))
503    }
504
505    /// Normalize version to perform a compatibility check against another version.
506    ///
507    /// See backwards comptabilitiy rules at https://semver.org/
508    fn normalize_compatible_version(mut version: semver::Version) -> semver::Version {
509        version.build = semver::BuildMetadata::EMPTY;
510
511        if version.pre != semver::Prerelease::EMPTY {
512            return version;
513        }
514        if version.major > 0 {
515            version.minor = 0;
516            version.patch = 0;
517            return version;
518        }
519
520        if version.minor > 0 {
521            version.patch = 0;
522            return version;
523        }
524
525        version
526    }
527
528    fn is_empty(&self) -> bool {
529        self.inner.is_empty()
530    }
531}
532
533/// Identifies a deployment target.
534#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
535#[serde(untagged, deny_unknown_fields)]
536pub enum TargetEnvironmentRef {
537    /// Environment definition doc reference e.g. `spin-up:3.2`, `my-host`. This is looked up
538    /// in the default environment catalogue (registry).
539    DefaultRegistry(String),
540    /// An environment definition doc in an OCI registry other than the default
541    Registry {
542        /// Registry or prefix hosting the environment document e.g. `ghcr.io/my/environments`.
543        registry: String,
544        /// Environment definition document name e.g. `my-spin-env:1.2`. For hosted environments
545        /// where you always want `latest`, omit the version tag e.g. `my-host`.
546        id: String,
547    },
548    /// A local environment document file. This is expected to contain a serialised
549    /// EnvironmentDefinition in TOML format.
550    File {
551        /// The file path of the document.
552        path: PathBuf,
553    },
554}
555
556mod kebab_or_snake_case {
557    use serde::{Deserialize, Serialize};
558    pub use spin_serde::{KebabId, SnakeId};
559    pub fn serialize<S>(value: &[String], serializer: S) -> Result<S::Ok, S::Error>
560    where
561        S: serde::ser::Serializer,
562    {
563        if value.iter().all(|s| {
564            KebabId::try_from(s.clone()).is_ok() || SnakeId::try_from(s.to_owned()).is_ok()
565        }) {
566            value.serialize(serializer)
567        } else {
568            Err(serde::ser::Error::custom(
569                "expected kebab-case or snake_case",
570            ))
571        }
572    }
573
574    pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
575    where
576        D: serde::Deserializer<'de>,
577    {
578        let value = toml::Value::deserialize(deserializer)?;
579        let list: Vec<String> = Vec::deserialize(value).map_err(serde::de::Error::custom)?;
580        if list.iter().all(|s| {
581            KebabId::try_from(s.clone()).is_ok() || SnakeId::try_from(s.to_owned()).is_ok()
582        }) {
583            Ok(list)
584        } else {
585            Err(serde::de::Error::custom(
586                "expected kebab-case or snake_case",
587            ))
588        }
589    }
590}
591
592impl Component {
593    /// Combine `allowed_outbound_hosts` with the deprecated `allowed_http_hosts` into
594    /// one array all normalized to the syntax of `allowed_outbound_hosts`.
595    pub fn normalized_allowed_outbound_hosts(&self) -> anyhow::Result<Vec<String>> {
596        #[allow(deprecated)]
597        let normalized =
598            crate::compat::convert_allowed_http_to_allowed_hosts(&self.allowed_http_hosts, false)?;
599        if !normalized.is_empty() {
600            terminal::warn!(
601                "Use of the deprecated field `allowed_http_hosts` - to fix, \
602            replace `allowed_http_hosts` with `allowed_outbound_hosts = {normalized:?}`",
603            )
604        }
605
606        Ok(self
607            .allowed_outbound_hosts
608            .iter()
609            .cloned()
610            .chain(normalized)
611            .collect())
612    }
613}
614
615mod one_or_many {
616    use serde::{Deserialize, Deserializer, Serialize, Serializer};
617
618    pub fn serialize<T, S>(vec: &Vec<T>, serializer: S) -> Result<S::Ok, S::Error>
619    where
620        T: Serialize,
621        S: Serializer,
622    {
623        if vec.len() == 1 {
624            vec[0].serialize(serializer)
625        } else {
626            vec.serialize(serializer)
627        }
628    }
629
630    pub fn deserialize<'de, T, D>(deserializer: D) -> Result<Vec<T>, D::Error>
631    where
632        T: Deserialize<'de>,
633        D: Deserializer<'de>,
634    {
635        let value = toml::Value::deserialize(deserializer)?;
636        if let Ok(val) = T::deserialize(value.clone()) {
637            Ok(vec![val])
638        } else {
639            Vec::deserialize(value).map_err(serde::de::Error::custom)
640        }
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use toml::toml;
647
648    use super::*;
649
650    #[derive(Deserialize)]
651    #[allow(dead_code)]
652    struct FakeGlobalTriggerConfig {
653        global_option: bool,
654    }
655
656    #[derive(Deserialize)]
657    #[allow(dead_code)]
658    struct FakeTriggerConfig {
659        option: Option<bool>,
660    }
661
662    #[test]
663    fn deserializing_trigger_configs() {
664        let manifest = AppManifest::deserialize(toml! {
665            spin_manifest_version = 2
666            [application]
667            name = "trigger-configs"
668            [application.trigger.fake]
669            global_option = true
670            [[trigger.fake]]
671            component = { source = "inline.wasm" }
672            option = true
673        })
674        .unwrap();
675
676        FakeGlobalTriggerConfig::deserialize(
677            manifest.application.trigger_global_configs["fake"].clone(),
678        )
679        .unwrap();
680
681        FakeTriggerConfig::deserialize(manifest.triggers["fake"][0].config.clone()).unwrap();
682    }
683
684    #[derive(Deserialize)]
685    #[allow(dead_code)]
686    struct FakeGlobalToolConfig {
687        lint_level: String,
688    }
689
690    #[derive(Deserialize)]
691    #[allow(dead_code)]
692    struct FakeComponentToolConfig {
693        command: String,
694    }
695
696    #[test]
697    fn deserialising_custom_tool_settings() {
698        let manifest = AppManifest::deserialize(toml! {
699            spin_manifest_version = 2
700            [application]
701            name = "trigger-configs"
702            [application.tool.lint]
703            lint_level = "savage"
704            [[trigger.fake]]
705            something = "something else"
706            [component.fake]
707            source = "dummy"
708            [component.fake.tool.clean]
709            command = "cargo clean"
710        })
711        .unwrap();
712
713        FakeGlobalToolConfig::deserialize(manifest.application.tool["lint"].clone()).unwrap();
714        let fake_id: KebabId = "fake".to_owned().try_into().unwrap();
715        FakeComponentToolConfig::deserialize(manifest.components[&fake_id].tool["clean"].clone())
716            .unwrap();
717    }
718
719    #[test]
720    fn deserializing_labels() {
721        AppManifest::deserialize(toml! {
722            spin_manifest_version = 2
723            [application]
724            name = "trigger-configs"
725            [[trigger.fake]]
726            something = "something else"
727            [component.fake]
728            source = "dummy"
729            key_value_stores = ["default", "snake_case", "kebab-case"]
730            sqlite_databases = ["default", "snake_case", "kebab-case"]
731        })
732        .unwrap();
733    }
734
735    #[test]
736    fn deserializing_labels_fails_for_non_kebab_or_snake() {
737        assert!(AppManifest::deserialize(toml! {
738            spin_manifest_version = 2
739            [application]
740            name = "trigger-configs"
741            [[trigger.fake]]
742            something = "something else"
743            [component.fake]
744            source = "dummy"
745            key_value_stores = ["b@dlabel"]
746        })
747        .is_err());
748    }
749
750    fn get_test_component_with_labels(labels: Vec<String>) -> Component {
751        #[allow(deprecated)]
752        Component {
753            source: ComponentSource::Local("dummy".to_string()),
754            description: "".to_string(),
755            variables: Map::new(),
756            environment: Map::new(),
757            files: vec![],
758            exclude_files: vec![],
759            allowed_http_hosts: vec![],
760            allowed_outbound_hosts: vec![],
761            key_value_stores: labels.clone(),
762            sqlite_databases: labels,
763            ai_models: vec![],
764            build: None,
765            tool: Map::new(),
766            dependencies_inherit_configuration: false,
767            dependencies: Default::default(),
768        }
769    }
770
771    #[test]
772    fn serialize_labels() {
773        let stores = vec![
774            "default".to_string(),
775            "snake_case".to_string(),
776            "kebab-case".to_string(),
777        ];
778        let component = get_test_component_with_labels(stores.clone());
779        let serialized = toml::to_string(&component).unwrap();
780        let deserialized = toml::from_str::<Component>(&serialized).unwrap();
781        assert_eq!(deserialized.key_value_stores, stores);
782    }
783
784    #[test]
785    fn serialize_labels_fails_for_non_kebab_or_snake() {
786        let component = get_test_component_with_labels(vec!["camelCase".to_string()]);
787        assert!(toml::to_string(&component).is_err());
788    }
789
790    #[test]
791    fn test_valid_snake_ids() {
792        for valid in ["default", "mixed_CASE_words", "letters1_then2_numbers345"] {
793            if let Err(err) = SnakeId::try_from(valid.to_string()) {
794                panic!("{valid:?} should be value: {err:?}");
795            }
796        }
797    }
798
799    #[test]
800    fn test_invalid_snake_ids() {
801        for invalid in [
802            "",
803            "kebab-case",
804            "_leading_underscore",
805            "trailing_underscore_",
806            "double__underscore",
807            "1initial_number",
808            "unicode_snowpeople☃☃☃",
809            "mIxEd_case",
810            "MiXeD_case",
811        ] {
812            if SnakeId::try_from(invalid.to_string()).is_ok() {
813                panic!("{invalid:?} should not be a valid SnakeId");
814            }
815        }
816    }
817
818    #[test]
819    fn test_check_disjoint() {
820        for (a, b) in [
821            ("foo:bar@0.1.0", "foo:bar@0.2.0"),
822            ("foo:bar/baz@0.1.0", "foo:bar/baz@0.2.0"),
823            ("foo:bar/baz@0.1.0", "foo:bar/bub@0.1.0"),
824            ("foo:bar@0.1.0", "foo:bar/bub@0.2.0"),
825            ("foo:bar@1.0.0", "foo:bar@2.0.0"),
826            ("foo:bar@0.1.0", "foo:bar@1.0.0"),
827            ("foo:bar/baz", "foo:bar/bub"),
828            ("foo:bar/baz@0.1.0-alpha", "foo:bar/baz@0.1.0-beta"),
829        ] {
830            let a: DependencyPackageName = a.parse().expect(a);
831            let b: DependencyPackageName = b.parse().expect(b);
832            ComponentDependencies::check_disjoint(&a, &b).unwrap();
833        }
834
835        for (a, b) in [
836            ("foo:bar@0.1.0", "foo:bar@0.1.1"),
837            ("foo:bar/baz@0.1.0", "foo:bar@0.1.0"),
838            ("foo:bar/baz@0.1.0", "foo:bar@0.1.0"),
839            ("foo:bar", "foo:bar@0.1.0"),
840            ("foo:bar@0.1.0-pre", "foo:bar@0.1.0-pre"),
841        ] {
842            let a: DependencyPackageName = a.parse().expect(a);
843            let b: DependencyPackageName = b.parse().expect(b);
844            assert!(
845                ComponentDependencies::check_disjoint(&a, &b).is_err(),
846                "{a} should conflict with {b}",
847            );
848        }
849    }
850
851    #[test]
852    fn test_validate_dependencies() {
853        // Specifying a dependency name as a plain-name without a package is an error
854        assert!(ComponentDependencies::deserialize(toml! {
855            "plain-name" = "0.1.0"
856        })
857        .unwrap()
858        .validate()
859        .is_err());
860
861        // Specifying a dependency name as a plain-name without a package is an error
862        assert!(ComponentDependencies::deserialize(toml! {
863            "plain-name" = { version = "0.1.0" }
864        })
865        .unwrap()
866        .validate()
867        .is_err());
868
869        // Specifying an export to satisfy a package dependency name is an error
870        assert!(ComponentDependencies::deserialize(toml! {
871            "foo:baz@0.1.0" = { path = "foo.wasm", export = "foo"}
872        })
873        .unwrap()
874        .validate()
875        .is_err());
876
877        // Two compatible versions of the same package is an error
878        assert!(ComponentDependencies::deserialize(toml! {
879            "foo:baz@0.1.0" = "0.1.0"
880            "foo:bar@0.2.1" = "0.2.1"
881            "foo:bar@0.2.2" = "0.2.2"
882        })
883        .unwrap()
884        .validate()
885        .is_err());
886
887        // Two disjoint versions of the same package is ok
888        assert!(ComponentDependencies::deserialize(toml! {
889            "foo:bar@0.1.0" = "0.1.0"
890            "foo:bar@0.2.0" = "0.2.0"
891            "foo:baz@0.2.0" = "0.1.0"
892        })
893        .unwrap()
894        .validate()
895        .is_ok());
896
897        // Unversioned and versioned dependencies of the same package is an error
898        assert!(ComponentDependencies::deserialize(toml! {
899            "foo:bar@0.1.0" = "0.1.0"
900            "foo:bar" = ">= 0.2.0"
901        })
902        .unwrap()
903        .validate()
904        .is_err());
905
906        // Two interfaces of two disjoint versions of a package is ok
907        assert!(ComponentDependencies::deserialize(toml! {
908            "foo:bar/baz@0.1.0" = "0.1.0"
909            "foo:bar/baz@0.2.0" = "0.2.0"
910        })
911        .unwrap()
912        .validate()
913        .is_ok());
914
915        // A versioned interface and a different versioned package is ok
916        assert!(ComponentDependencies::deserialize(toml! {
917            "foo:bar/baz@0.1.0" = "0.1.0"
918            "foo:bar@0.2.0" = "0.2.0"
919        })
920        .unwrap()
921        .validate()
922        .is_ok());
923
924        // A versioned interface and package of the same version is an error
925        assert!(ComponentDependencies::deserialize(toml! {
926            "foo:bar/baz@0.1.0" = "0.1.0"
927            "foo:bar@0.1.0" = "0.1.0"
928        })
929        .unwrap()
930        .validate()
931        .is_err());
932
933        // A versioned interface and unversioned package is an error
934        assert!(ComponentDependencies::deserialize(toml! {
935            "foo:bar/baz@0.1.0" = "0.1.0"
936            "foo:bar" = "0.1.0"
937        })
938        .unwrap()
939        .validate()
940        .is_err());
941
942        // An unversioned interface and versioned package is an error
943        assert!(ComponentDependencies::deserialize(toml! {
944            "foo:bar/baz" = "0.1.0"
945            "foo:bar@0.1.0" = "0.1.0"
946        })
947        .unwrap()
948        .validate()
949        .is_err());
950
951        // An unversioned interface and unversioned package is an error
952        assert!(ComponentDependencies::deserialize(toml! {
953            "foo:bar/baz" = "0.1.0"
954            "foo:bar" = "0.1.0"
955        })
956        .unwrap()
957        .validate()
958        .is_err());
959    }
960}