Skip to main content

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    /// Whether any component in the application defines the given profile.
57    /// Not every component defines every profile, and components intentionally
58    /// fall back to the anonymouse profile if they are asked for a profile
59    /// they don't define. So this can be used to detect that a user might have
60    /// mistyped a profile (e.g. `spin up --profile deugb`).
61    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/// App details
77#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
78#[serde(deny_unknown_fields)]
79pub struct AppDetails {
80    /// The name of the application.
81    ///
82    /// Example: `name = "my-app"`
83    pub name: String,
84    /// The application version. This should be a valid semver version.
85    ///
86    /// Example: `version = "1.0.0"`
87    #[serde(default, skip_serializing_if = "String::is_empty")]
88    pub version: String,
89    /// A human-readable description of the application.
90    ///
91    /// Example: `description = "App description"`
92    #[serde(default, skip_serializing_if = "String::is_empty")]
93    pub description: String,
94    /// The author(s) of the application.
95    ///
96    /// `authors = ["author@example.com"]`
97    #[serde(default, skip_serializing_if = "Vec::is_empty")]
98    pub authors: Vec<String>,
99    /// The Spin environments with which application components must be compatible
100    /// unless otherwise specified. Individual components may express different
101    /// requirements: these override the application-level default.
102    ///
103    /// Example: `targets = ["spin-up:3.3", "spinkube:0.4"]`
104    #[serde(default, skip_serializing_if = "Vec::is_empty")]
105    pub targets: Vec<TargetEnvironmentRef>,
106    /// Application-level settings for the trigger types used in the application.
107    /// The possible values are trigger type-specific.
108    ///
109    /// Example:
110    ///
111    /// ```ignore
112    /// [application.triggers.redis]
113    /// address = "redis://notifications.example.com:6379"
114    /// ```
115    ///
116    /// Learn more (Redis example): https://spinframework.dev/redis-trigger#setting-a-default-server
117    #[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    /// Settings for custom tools or plugins. Spin ignores this field.
121    #[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/// Trigger configuration. A trigger maps an event of the trigger's type (e.g.
127/// an HTTP request on route `/shop`, a Redis message on channel `orders`) to
128/// a Spin component.
129///
130/// The trigger manifest contains additional fields which depend on the trigger
131/// type. For the `http` type, these additional fields are `route` (required) and
132/// `executor` (optional). For the `redis` type, the additional fields are
133/// `channel` (required) and `address` (optional). For other types, see the trigger
134/// documentation.
135///
136/// Learn more: https://spinframework.dev/http-trigger, https://spinframework.dev/redis-trigger
137#[derive(Clone, Debug, Serialize, Deserialize)]
138pub struct Trigger {
139    /// Optional identifier for the trigger.
140    ///
141    /// Example: `id = "trigger-id"`
142    #[serde(default, skip_serializing_if = "String::is_empty")]
143    pub id: String,
144    /// The component that Spin should run when the trigger occurs. For HTTP triggers,
145    /// this is the HTTP request handler for the trigger route. This is typically
146    /// the ID of an entry in the `[component]` table, although you can also write
147    /// the component out as the value of this field.
148    ///
149    /// Example: `component = "shop-handler"`
150    ///
151    /// Learn more: https://spinframework.dev/triggers#triggers-and-components
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub component: Option<ComponentSpec>,
154    /// Reserved for future use.
155    ///
156    /// `components = { ... }`
157    #[serde(default, skip_serializing_if = "Map::is_empty")]
158    pub components: Map<String, OneOrManyComponentSpecs>,
159    /// Opaque trigger-type-specific config
160    #[serde(flatten)]
161    pub config: toml::Table,
162}
163
164/// One or many `ComponentSpec`(s)
165#[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/// Component reference or inline definition
174#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
175#[serde(deny_unknown_fields, untagged, try_from = "toml::Value")]
176pub enum ComponentSpec {
177    /// `"component-id"`
178    Reference(KebabId),
179    /// `{ ... }`
180    Inline(Box<Component>),
181}
182
183impl TryFrom<toml::Value> for ComponentSpec {
184    type Error = toml::de::Error;
185
186    fn try_from(value: toml::Value) -> Result<Self, Self::Error> {
187        if value.is_str() {
188            Ok(ComponentSpec::Reference(KebabId::deserialize(value)?))
189        } else {
190            Ok(ComponentSpec::Inline(Box::new(Component::deserialize(
191                value,
192            )?)))
193        }
194    }
195}
196
197/// Specifies how to satisfy an import dependency of the component. This may be one of:
198///
199/// - 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.
200///
201/// Example: `"my:dep/import" = ">= 0.1.0"`
202///
203/// - A package from a registry.
204///
205/// Example: `"my:dep/import" = { version = "0.1.0", registry = "registry.io", ...}`
206///
207/// - A package from a filesystem path.
208///
209/// Example: `"my:dependency" = { path = "path/to/component.wasm", export = "my-export" }`
210///
211/// - A component in the application. The referenced component binary is composed: additional
212///   configuration such as files, networking, storage, etc. are ignored. This is intended
213///   primarily as a convenience for including dependencies in the manifest so that they
214///   can be built using `spin build`.
215///
216/// Example: `"my:dependency" = { component = "my-dependency", export = "my-export" }`
217///
218/// - A package from an HTTP URL.
219///
220/// Example: `"my:import" = { url = "https://example.com/component.wasm", sha256 = "sha256:..." }`
221///
222/// Learn more: https://spinframework.dev/v3/writing-apps#using-component-dependencies
223#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
224#[serde(untagged, deny_unknown_fields)]
225pub enum ComponentDependency {
226    /// `... = ">= 0.1.0"`
227    #[schemars(description = "")] // schema docs are on the parent
228    Version(String),
229    /// `... = { version = "0.1.0", registry = "registry.io", ...}`
230    #[schemars(description = "")] // schema docs are on the parent
231    Package {
232        /// A semantic versioning constraint for the package version to use. Required. Spin
233        /// fetches the latest matching version from the specified registry, or from
234        /// the default registry if no registry is specified.
235        ///
236        /// Example: `"my:dep/import" = { version = ">= 0.1.0" }`
237        ///
238        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-registry
239        version: String,
240        /// The registry that hosts the package. If omitted, this defaults to your
241        /// system default registry.
242        ///
243        /// Example: `"my:dep/import" = { registry = "registry.io", version = " 0.1.0" }`
244        ///
245        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-registry
246        registry: Option<String>,
247        /// The name of the package to use. If omitted, this defaults to the package name of the
248        /// imported interface.
249        ///
250        /// Example: `"my:dep/import" = { package = "your:implementation", version = " 0.1.0" }`
251        ///
252        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-registry
253        package: Option<String>,
254        /// The name of the export in the package. If omitted, this defaults to the name of the import.
255        ///
256        /// Example: `"my:dep/import" = { export = "your:impl/export", version = " 0.1.0" }`
257        ///
258        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-registry
259        export: Option<String>,
260    },
261    /// `... = { path = "path/to/component.wasm", export = "my-export" }`
262    #[schemars(description = "")] // schema docs are on the parent
263    Local {
264        /// The path to the Wasm file that implements the dependency.
265        ///
266        /// Example: `"my:dep/import" = { path = "path/to/component.wasm" }`
267        ///
268        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-local-component
269        path: PathBuf,
270        /// The name of the export in the package. If omitted, this defaults to the name of the import.
271        ///
272        /// Example: `"my:dep/import" = { export = "your:impl/export", path = "path/to/component.wasm" }`
273        ///
274        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-local-component
275        export: Option<String>,
276    },
277    /// `... = { url = "https://example.com/component.wasm", sha256 = "..." }`
278    #[schemars(description = "")] // schema docs are on the parent
279    HTTP {
280        /// The URL to the Wasm component that implements the dependency.
281        ///
282        /// Example: `"my:dep/import" = { url = "https://example.com/component.wasm", sha256 = "sha256:..." }`
283        ///
284        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-url
285        url: String,
286        /// The SHA256 digest of the Wasm file. This is required for integrity checking. Must begin with `sha256:`.
287        ///
288        /// Example: `"my:dep/import" = { sha256 = "sha256:...", ... }`
289        ///
290        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-url
291        digest: String,
292        /// The name of the export in the package. If omitted, this defaults to the name of the import.
293        ///
294        /// Example: `"my:dep/import" = { export = "your:impl/export", ... }`
295        ///
296        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-url
297        export: Option<String>,
298    },
299    /// `... = { component = "my-dependency" }`
300    #[schemars(description = "")] // schema docs are on the parent
301    AppComponent {
302        /// The ID of the component which implements the dependency.
303        ///
304        /// Example: `"my:dep/import" = { component = "my-dependency" }`
305        ///
306        /// Learn more: https://spinframework.dev/writing-apps#using-component-dependencies
307        component: KebabId,
308        /// The name of the export in the package. If omitted, this defaults to the name of the import.
309        ///
310        /// Example: `"my:dep/import" = { export = "your:impl/export", component = "my-dependency" }`
311        ///
312        /// Learn more: https://spinframework.dev/writing-apps#using-component-dependencies
313        export: Option<String>,
314    },
315}
316
317/// A Spin component.
318#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
319#[serde(deny_unknown_fields)]
320pub struct Component {
321    /// The file, package, or URL containing the component Wasm binary.
322    ///
323    /// Example: `source = "bin/cart.wasm"`
324    ///
325    /// Learn more: https://spinframework.dev/writing-apps#the-component-source
326    pub source: ComponentSource,
327    /// A human-readable description of the component.
328    ///
329    /// Example: `description = "Shopping cart"`
330    #[serde(default, skip_serializing_if = "String::is_empty")]
331    pub description: String,
332    /// Configuration variables available to the component. Names must be
333    /// in `lower_snake_case`. Values are strings, and may refer
334    /// to application variables using `{{ ... }}` syntax.
335    ///
336    /// `variables = { users_endpoint = "https://{{ api_host }}/users"}`
337    ///
338    /// Learn more: https://spinframework.dev/variables#adding-variables-to-your-applications
339    #[serde(default, skip_serializing_if = "Map::is_empty")]
340    pub variables: Map<LowerSnakeId, String>,
341    /// Environment variables to be set for the Wasm module.
342    ///
343    /// `environment = { DB_URL = "mysql://spin:spin@localhost/dev" }`
344    #[serde(default, skip_serializing_if = "Map::is_empty")]
345    pub environment: Map<String, String>,
346    /// The files the component is allowed to read. Each list entry is either:
347    ///
348    /// - a glob pattern (e.g. "assets/**/*.jpg"); or
349    ///
350    /// - a source-destination pair indicating where a host directory should be mapped in the guest (e.g. { source = "assets", destination = "/" })
351    ///
352    /// Learn more: https://spinframework.dev/writing-apps#including-files-with-components
353    #[serde(default, skip_serializing_if = "Vec::is_empty")]
354    pub files: Vec<WasiFilesMount>,
355    /// Any files or glob patterns that should not be available to the
356    /// Wasm module at runtime, even though they match a `files`` entry.
357    ///
358    /// Example: `exclude_files = ["secrets/*"]`
359    ///
360    /// Learn more: https://spinframework.dev/writing-apps#including-files-with-components
361    #[serde(default, skip_serializing_if = "Vec::is_empty")]
362    pub exclude_files: Vec<String>,
363    /// Deprecated. Use `allowed_outbound_hosts` instead.
364    ///
365    /// Example: `allowed_http_hosts = ["example.com"]`
366    #[serde(default, skip_serializing_if = "Vec::is_empty")]
367    #[deprecated]
368    pub allowed_http_hosts: Vec<String>,
369    /// The network destinations which the component is allowed to access.
370    /// Each entry is in the form "(scheme)://(host)[:port]". Each element
371    /// allows * as a wildcard e.g. "https://\*" (HTTPS on the default port
372    /// to any destination) or "\*://localhost:\*" (any protocol to any port on
373    /// localhost). The host part allows segment wildcards for subdomains
374    /// e.g. "https://\*.example.com". Application variables are allowed using
375    /// `{{ my_var }}`` syntax.
376    ///
377    /// Example: `allowed_outbound_hosts = ["redis://myredishost.com:6379"]`
378    ///
379    /// Learn more: https://spinframework.dev/http-outbound#granting-http-permissions-to-components
380    #[serde(default, skip_serializing_if = "Vec::is_empty")]
381    #[schemars(with = "Vec<json_schema::AllowedOutboundHost>")]
382    pub allowed_outbound_hosts: Vec<String>,
383    /// The key-value stores which the component is allowed to access. Stores are identified
384    /// by label e.g. "default" or "customer". Stores other than "default" must be mapped
385    /// to a backing store in the runtime config.
386    ///
387    /// Example: `key_value_stores = ["default", "my-store"]`
388    ///
389    /// Learn more: https://spinframework.dev/kv-store-api-guide#custom-key-value-stores
390    #[serde(
391        default,
392        with = "kebab_or_snake_case",
393        skip_serializing_if = "Vec::is_empty"
394    )]
395    #[schemars(with = "Vec<json_schema::KeyValueStore>")]
396    pub key_value_stores: Vec<String>,
397    /// The SQLite databases which the component is allowed to access. Databases are identified
398    /// by label e.g. "default" or "analytics". Databases other than "default" must be mapped
399    /// to a backing store in the runtime config. Use "spin up --sqlite" to run database setup scripts.
400    ///
401    /// Example: `sqlite_databases = ["default", "my-database"]`
402    ///
403    /// Learn more: https://spinframework.dev/sqlite-api-guide#preparing-an-sqlite-database
404    #[serde(
405        default,
406        with = "kebab_or_snake_case",
407        skip_serializing_if = "Vec::is_empty"
408    )]
409    #[schemars(with = "Vec<json_schema::SqliteDatabase>")]
410    pub sqlite_databases: Vec<String>,
411    /// The AI models which the component is allowed to access. For local execution, you must
412    /// download all models; for hosted execution, you should check which models are available
413    /// in your target environment.
414    ///
415    /// Example: `ai_models = ["llama2-chat"]`
416    ///
417    /// Learn more: https://spinframework.dev/serverless-ai-api-guide#using-serverless-ai-from-applications
418    #[serde(default, skip_serializing_if = "Vec::is_empty")]
419    #[schemars(with = "Vec<json_schema::AIModel>")]
420    pub ai_models: Vec<String>,
421    /// The Spin environments with which the component must be compatible.
422    /// If present, this overrides the default application targets (they are not combined).
423    ///
424    /// Example: `targets = ["spin-up:3.3", "spinkube:0.4"]`
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub targets: Option<Vec<TargetEnvironmentRef>>,
427    /// The component build configuration.
428    ///
429    /// Learn more: https://spinframework.dev/build
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub build: Option<ComponentBuildConfig>,
432    /// Settings for custom tools or plugins. Spin ignores this field.
433    #[serde(default, skip_serializing_if = "Map::is_empty")]
434    #[schemars(schema_with = "json_schema::map_of_toml_tables")]
435    pub tool: Map<String, toml::Table>,
436    /// If true, dependencies can invoke Spin APIs with the same permissions as the main
437    /// component. If false, dependencies have no permissions (e.g. network,
438    /// key-value stores, SQLite databases).
439    ///
440    /// Learn more: https://spinframework.dev/writing-apps#dependency-permissions
441    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
442    pub dependencies_inherit_configuration: bool,
443    /// Specifies how to satisfy Wasm Component Model imports of this component.
444    ///
445    /// Learn more: https://spinframework.dev/writing-apps#using-component-dependencies
446    #[serde(default, skip_serializing_if = "ComponentDependencies::is_empty")]
447    pub dependencies: ComponentDependencies,
448    /// Override values to use when building or running a named build profile.
449    ///
450    /// Example: `profile.debug.build.command = "npm run build-debug"`
451    #[serde(default, skip_serializing_if = "Map::is_empty")]
452    pub(crate) profile: Map<String, ComponentProfileOverride>,
453}
454
455/// Customisations for a Spin component in a non-default profile.
456#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
457#[serde(deny_unknown_fields)]
458pub struct ComponentProfileOverride {
459    /// The file, package, or URL containing the component Wasm binary.
460    ///
461    /// Example: `source = "bin/debug/cart.wasm"`
462    ///
463    /// Learn more: https://spinframework.dev/writing-apps#the-component-source
464    #[serde(default, skip_serializing_if = "Option::is_none")]
465    pub(crate) source: Option<ComponentSource>,
466
467    /// Environment variables for the Wasm module to be overridden in this profile.
468    /// Environment variables specified in the default profile will still be set
469    /// if not overridden here.
470    ///
471    /// `environment = { DB_URL = "mysql://spin:spin@localhost/dev" }`
472    #[serde(default, skip_serializing_if = "Map::is_empty")]
473    pub(crate) environment: Map<String, String>,
474
475    /// Wasm Component Model imports to be overridden in this profile.
476    /// Dependencies specified in the default profile will still be composed
477    /// if not overridden here.
478    ///
479    /// Learn more: https://spinframework.dev/writing-apps#using-component-dependencies
480    #[serde(default, skip_serializing_if = "ComponentDependencies::is_empty")]
481    pub(crate) dependencies: ComponentDependencies,
482
483    /// The command or commands for building the component in non-default profiles.
484    /// If a component has no special build instructions for a profile, the
485    /// default build command is used.
486    #[serde(default, skip_serializing_if = "Option::is_none")]
487    pub(crate) build: Option<ComponentProfileBuildOverride>,
488}
489
490/// Customisations for a Spin component build in a non-default profile.
491#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
492#[serde(deny_unknown_fields)]
493pub struct ComponentProfileBuildOverride {
494    /// The command or commands to build the component in a named profile. If multiple commands
495    /// are specified, they are run sequentially from left to right.
496    ///
497    /// Example: `build.command = "cargo build"`
498    ///
499    /// Learn more: https://spinframework.dev/build#setting-up-for-spin-build
500    pub(crate) command: super::common::Commands,
501}
502
503/// Component dependencies
504#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
505#[serde(transparent)]
506pub struct ComponentDependencies {
507    /// `dependencies = { "foo:bar" = ">= 0.1.0" }`
508    pub inner: Map<DependencyName, ComponentDependency>,
509}
510
511impl ComponentDependencies {
512    /// This method validates the correct specification of dependencies in a
513    /// component section of the manifest. See the documentation on the methods
514    /// called for more information on the specific checks.
515    fn validate(&self) -> anyhow::Result<()> {
516        self.ensure_plain_names_have_package()?;
517        self.ensure_package_names_no_export()?;
518        self.ensure_disjoint()?;
519        Ok(())
520    }
521
522    /// This method ensures that all dependency names in plain form (e.g.
523    /// "foo-bar") do not map to a `ComponentDependency::Version`, or a
524    /// `ComponentDependency::Package` where the `package` is `None`.
525    fn ensure_plain_names_have_package(&self) -> anyhow::Result<()> {
526        for (dependency_name, dependency) in self.inner.iter() {
527            let DependencyName::Plain(plain) = dependency_name else {
528                continue;
529            };
530            match dependency {
531                ComponentDependency::Package { package, .. } if package.is_none() => {}
532                ComponentDependency::Version(_) => {}
533                _ => continue,
534            }
535            anyhow::bail!("dependency {plain:?} must specify a package name");
536        }
537        Ok(())
538    }
539
540    /// This method ensures that dependency names in the package form (e.g.
541    /// "foo:bar" or "foo:bar@0.1.0") do not map to specific exported
542    /// interfaces, e.g. `"foo:bar = { ..., export = "my-export" }"` is invalid.
543    fn ensure_package_names_no_export(&self) -> anyhow::Result<()> {
544        for (dependency_name, dependency) in self.inner.iter() {
545            if let DependencyName::Package(name) = dependency_name {
546                if name.interface.is_none() {
547                    let export = match dependency {
548                        ComponentDependency::Package { export, .. } => export,
549                        ComponentDependency::Local { export, .. } => export,
550                        _ => continue,
551                    };
552
553                    anyhow::ensure!(
554                        export.is_none(),
555                        "using an export to satisfy the package dependency {dependency_name:?} is not currently permitted",
556                    );
557                }
558            }
559        }
560        Ok(())
561    }
562
563    /// This method ensures that dependencies names do not conflict with each other. That is to say
564    /// that two dependencies of the same package must have disjoint versions or interfaces.
565    fn ensure_disjoint(&self) -> anyhow::Result<()> {
566        for (idx, this) in self.inner.keys().enumerate() {
567            for other in self.inner.keys().skip(idx + 1) {
568                let DependencyName::Package(other) = other else {
569                    continue;
570                };
571                let DependencyName::Package(this) = this else {
572                    continue;
573                };
574
575                if this.package == other.package {
576                    Self::check_disjoint(this, other)?;
577                }
578            }
579        }
580        Ok(())
581    }
582
583    fn check_disjoint(
584        this: &DependencyPackageName,
585        other: &DependencyPackageName,
586    ) -> anyhow::Result<()> {
587        assert_eq!(this.package, other.package);
588
589        if let (Some(this_ver), Some(other_ver)) = (this.version.clone(), other.version.clone()) {
590            if Self::normalize_compatible_version(this_ver)
591                != Self::normalize_compatible_version(other_ver)
592            {
593                return Ok(());
594            }
595        }
596
597        if let (Some(this_itf), Some(other_itf)) =
598            (this.interface.as_ref(), other.interface.as_ref())
599        {
600            if this_itf != other_itf {
601                return Ok(());
602            }
603        }
604
605        Err(anyhow!("{this:?} dependency conflicts with {other:?}"))
606    }
607
608    /// Normalize version to perform a compatibility check against another version.
609    ///
610    /// See backwards comptabilitiy rules at https://semver.org/
611    fn normalize_compatible_version(mut version: semver::Version) -> semver::Version {
612        version.build = semver::BuildMetadata::EMPTY;
613
614        if version.pre != semver::Prerelease::EMPTY {
615            return version;
616        }
617        if version.major > 0 {
618            version.minor = 0;
619            version.patch = 0;
620            return version;
621        }
622
623        if version.minor > 0 {
624            version.patch = 0;
625            return version;
626        }
627
628        version
629    }
630
631    fn is_empty(&self) -> bool {
632        self.inner.is_empty()
633    }
634}
635
636/// Identifies a deployment target.
637#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
638#[serde(untagged, deny_unknown_fields)]
639pub enum TargetEnvironmentRef {
640    /// Environment definition doc reference e.g. `spin-up:3.2`, `my-host`. This is looked up
641    /// in the default environment catalogue (registry).
642    DefaultRegistry(String),
643    /// An environment definition doc in an OCI registry other than the default
644    Registry {
645        /// Registry or prefix hosting the environment document e.g. `ghcr.io/my/environments`.
646        registry: String,
647        /// Environment definition document name e.g. `my-spin-env:1.2`. For hosted environments
648        /// where you always want `latest`, omit the version tag e.g. `my-host`.
649        id: String,
650    },
651    /// A local environment document file. This is expected to contain a serialised
652    /// EnvironmentDefinition in TOML format.
653    File {
654        /// The file path of the document.
655        path: PathBuf,
656    },
657}
658
659mod kebab_or_snake_case {
660    use serde::{Deserialize, Serialize};
661    pub use spin_serde::{KebabId, SnakeId};
662    pub fn serialize<S>(value: &[String], serializer: S) -> Result<S::Ok, S::Error>
663    where
664        S: serde::ser::Serializer,
665    {
666        if value.iter().all(|s| {
667            KebabId::try_from(s.clone()).is_ok() || SnakeId::try_from(s.to_owned()).is_ok()
668        }) {
669            value.serialize(serializer)
670        } else {
671            Err(serde::ser::Error::custom(
672                "expected kebab-case or snake_case",
673            ))
674        }
675    }
676
677    pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
678    where
679        D: serde::Deserializer<'de>,
680    {
681        let value = toml::Value::deserialize(deserializer)?;
682        let list: Vec<String> = Vec::deserialize(value).map_err(serde::de::Error::custom)?;
683        if list.iter().all(|s| {
684            KebabId::try_from(s.clone()).is_ok() || SnakeId::try_from(s.to_owned()).is_ok()
685        }) {
686            Ok(list)
687        } else {
688            Err(serde::de::Error::custom(
689                "expected kebab-case or snake_case",
690            ))
691        }
692    }
693}
694
695impl Component {
696    /// Combine `allowed_outbound_hosts` with the deprecated `allowed_http_hosts` into
697    /// one array all normalized to the syntax of `allowed_outbound_hosts`.
698    pub fn normalized_allowed_outbound_hosts(&self) -> anyhow::Result<Vec<String>> {
699        #[allow(deprecated)]
700        let normalized =
701            crate::compat::convert_allowed_http_to_allowed_hosts(&self.allowed_http_hosts, false)?;
702        if !normalized.is_empty() {
703            terminal::warn!(
704                "Use of the deprecated field `allowed_http_hosts` - to fix, \
705            replace `allowed_http_hosts` with `allowed_outbound_hosts = {normalized:?}`",
706            )
707        }
708
709        Ok(self
710            .allowed_outbound_hosts
711            .iter()
712            .cloned()
713            .chain(normalized)
714            .collect())
715    }
716}
717
718mod one_or_many {
719    use serde::{Deserialize, Deserializer, Serialize, Serializer};
720
721    pub fn serialize<T, S>(vec: &Vec<T>, serializer: S) -> Result<S::Ok, S::Error>
722    where
723        T: Serialize,
724        S: Serializer,
725    {
726        if vec.len() == 1 {
727            vec[0].serialize(serializer)
728        } else {
729            vec.serialize(serializer)
730        }
731    }
732
733    pub fn deserialize<'de, T, D>(deserializer: D) -> Result<Vec<T>, D::Error>
734    where
735        T: Deserialize<'de>,
736        D: Deserializer<'de>,
737    {
738        let value = toml::Value::deserialize(deserializer)?;
739        if let Ok(val) = T::deserialize(value.clone()) {
740            Ok(vec![val])
741        } else {
742            Vec::deserialize(value).map_err(serde::de::Error::custom)
743        }
744    }
745}
746
747#[cfg(test)]
748mod tests {
749    use toml::toml;
750
751    use super::*;
752
753    #[derive(Deserialize)]
754    #[allow(dead_code)]
755    struct FakeGlobalTriggerConfig {
756        global_option: bool,
757    }
758
759    #[derive(Deserialize)]
760    #[allow(dead_code)]
761    struct FakeTriggerConfig {
762        option: Option<bool>,
763    }
764
765    #[test]
766    fn deserializing_trigger_configs() {
767        let manifest = AppManifest::deserialize(toml! {
768            spin_manifest_version = 2
769            [application]
770            name = "trigger-configs"
771            [application.trigger.fake]
772            global_option = true
773            [[trigger.fake]]
774            component = { source = "inline.wasm" }
775            option = true
776        })
777        .unwrap();
778
779        FakeGlobalTriggerConfig::deserialize(
780            manifest.application.trigger_global_configs["fake"].clone(),
781        )
782        .unwrap();
783
784        FakeTriggerConfig::deserialize(manifest.triggers["fake"][0].config.clone()).unwrap();
785    }
786
787    #[derive(Deserialize)]
788    #[allow(dead_code)]
789    struct FakeGlobalToolConfig {
790        lint_level: String,
791    }
792
793    #[derive(Deserialize)]
794    #[allow(dead_code)]
795    struct FakeComponentToolConfig {
796        command: String,
797    }
798
799    #[test]
800    fn deserialising_custom_tool_settings() {
801        let manifest = AppManifest::deserialize(toml! {
802            spin_manifest_version = 2
803            [application]
804            name = "trigger-configs"
805            [application.tool.lint]
806            lint_level = "savage"
807            [[trigger.fake]]
808            something = "something else"
809            [component.fake]
810            source = "dummy"
811            [component.fake.tool.clean]
812            command = "cargo clean"
813        })
814        .unwrap();
815
816        FakeGlobalToolConfig::deserialize(manifest.application.tool["lint"].clone()).unwrap();
817        let fake_id: KebabId = "fake".to_owned().try_into().unwrap();
818        FakeComponentToolConfig::deserialize(manifest.components[&fake_id].tool["clean"].clone())
819            .unwrap();
820    }
821
822    #[test]
823    fn deserializing_labels() {
824        AppManifest::deserialize(toml! {
825            spin_manifest_version = 2
826            [application]
827            name = "trigger-configs"
828            [[trigger.fake]]
829            something = "something else"
830            [component.fake]
831            source = "dummy"
832            key_value_stores = ["default", "snake_case", "kebab-case"]
833            sqlite_databases = ["default", "snake_case", "kebab-case"]
834        })
835        .unwrap();
836    }
837
838    #[test]
839    fn deserializing_labels_fails_for_non_kebab_or_snake() {
840        assert!(AppManifest::deserialize(toml! {
841            spin_manifest_version = 2
842            [application]
843            name = "trigger-configs"
844            [[trigger.fake]]
845            something = "something else"
846            [component.fake]
847            source = "dummy"
848            key_value_stores = ["b@dlabel"]
849        })
850        .is_err());
851    }
852
853    fn get_test_component_with_labels(labels: Vec<String>) -> Component {
854        #[allow(deprecated)]
855        Component {
856            source: ComponentSource::Local("dummy".to_string()),
857            description: "".to_string(),
858            variables: Map::new(),
859            environment: Map::new(),
860            files: vec![],
861            exclude_files: vec![],
862            allowed_http_hosts: vec![],
863            allowed_outbound_hosts: vec![],
864            key_value_stores: labels.clone(),
865            sqlite_databases: labels,
866            ai_models: vec![],
867            targets: None,
868            build: None,
869            tool: Map::new(),
870            dependencies_inherit_configuration: false,
871            dependencies: Default::default(),
872            profile: Default::default(),
873        }
874    }
875
876    #[test]
877    fn serialize_labels() {
878        let stores = vec![
879            "default".to_string(),
880            "snake_case".to_string(),
881            "kebab-case".to_string(),
882        ];
883        let component = get_test_component_with_labels(stores.clone());
884        let serialized = toml::to_string(&component).unwrap();
885        let deserialized = toml::from_str::<Component>(&serialized).unwrap();
886        assert_eq!(deserialized.key_value_stores, stores);
887    }
888
889    #[test]
890    fn serialize_labels_fails_for_non_kebab_or_snake() {
891        let component = get_test_component_with_labels(vec!["camelCase".to_string()]);
892        assert!(toml::to_string(&component).is_err());
893    }
894
895    #[test]
896    fn test_valid_snake_ids() {
897        for valid in ["default", "mixed_CASE_words", "letters1_then2_numbers345"] {
898            if let Err(err) = SnakeId::try_from(valid.to_string()) {
899                panic!("{valid:?} should be value: {err:?}");
900            }
901        }
902    }
903
904    #[test]
905    fn test_invalid_snake_ids() {
906        for invalid in [
907            "",
908            "kebab-case",
909            "_leading_underscore",
910            "trailing_underscore_",
911            "double__underscore",
912            "1initial_number",
913            "unicode_snowpeople☃☃☃",
914            "mIxEd_case",
915            "MiXeD_case",
916        ] {
917            if SnakeId::try_from(invalid.to_string()).is_ok() {
918                panic!("{invalid:?} should not be a valid SnakeId");
919            }
920        }
921    }
922
923    #[test]
924    fn test_check_disjoint() {
925        for (a, b) in [
926            ("foo:bar@0.1.0", "foo:bar@0.2.0"),
927            ("foo:bar/baz@0.1.0", "foo:bar/baz@0.2.0"),
928            ("foo:bar/baz@0.1.0", "foo:bar/bub@0.1.0"),
929            ("foo:bar@0.1.0", "foo:bar/bub@0.2.0"),
930            ("foo:bar@1.0.0", "foo:bar@2.0.0"),
931            ("foo:bar@0.1.0", "foo:bar@1.0.0"),
932            ("foo:bar/baz", "foo:bar/bub"),
933            ("foo:bar/baz@0.1.0-alpha", "foo:bar/baz@0.1.0-beta"),
934        ] {
935            let a: DependencyPackageName = a.parse().expect(a);
936            let b: DependencyPackageName = b.parse().expect(b);
937            ComponentDependencies::check_disjoint(&a, &b).unwrap();
938        }
939
940        for (a, b) in [
941            ("foo:bar@0.1.0", "foo:bar@0.1.1"),
942            ("foo:bar/baz@0.1.0", "foo:bar@0.1.0"),
943            ("foo:bar/baz@0.1.0", "foo:bar@0.1.0"),
944            ("foo:bar", "foo:bar@0.1.0"),
945            ("foo:bar@0.1.0-pre", "foo:bar@0.1.0-pre"),
946        ] {
947            let a: DependencyPackageName = a.parse().expect(a);
948            let b: DependencyPackageName = b.parse().expect(b);
949            assert!(
950                ComponentDependencies::check_disjoint(&a, &b).is_err(),
951                "{a} should conflict with {b}",
952            );
953        }
954    }
955
956    #[test]
957    fn test_validate_dependencies() {
958        // Specifying a dependency name as a plain-name without a package is an error
959        assert!(ComponentDependencies::deserialize(toml! {
960            "plain-name" = "0.1.0"
961        })
962        .unwrap()
963        .validate()
964        .is_err());
965
966        // Specifying a dependency name as a plain-name without a package is an error
967        assert!(ComponentDependencies::deserialize(toml! {
968            "plain-name" = { version = "0.1.0" }
969        })
970        .unwrap()
971        .validate()
972        .is_err());
973
974        // Specifying an export to satisfy a package dependency name is an error
975        assert!(ComponentDependencies::deserialize(toml! {
976            "foo:baz@0.1.0" = { path = "foo.wasm", export = "foo"}
977        })
978        .unwrap()
979        .validate()
980        .is_err());
981
982        // Two compatible versions of the same package is an error
983        assert!(ComponentDependencies::deserialize(toml! {
984            "foo:baz@0.1.0" = "0.1.0"
985            "foo:bar@0.2.1" = "0.2.1"
986            "foo:bar@0.2.2" = "0.2.2"
987        })
988        .unwrap()
989        .validate()
990        .is_err());
991
992        // Two disjoint versions of the same package is ok
993        assert!(ComponentDependencies::deserialize(toml! {
994            "foo:bar@0.1.0" = "0.1.0"
995            "foo:bar@0.2.0" = "0.2.0"
996            "foo:baz@0.2.0" = "0.1.0"
997        })
998        .unwrap()
999        .validate()
1000        .is_ok());
1001
1002        // Unversioned and versioned dependencies of the same package is an error
1003        assert!(ComponentDependencies::deserialize(toml! {
1004            "foo:bar@0.1.0" = "0.1.0"
1005            "foo:bar" = ">= 0.2.0"
1006        })
1007        .unwrap()
1008        .validate()
1009        .is_err());
1010
1011        // Two interfaces of two disjoint versions of a package is ok
1012        assert!(ComponentDependencies::deserialize(toml! {
1013            "foo:bar/baz@0.1.0" = "0.1.0"
1014            "foo:bar/baz@0.2.0" = "0.2.0"
1015        })
1016        .unwrap()
1017        .validate()
1018        .is_ok());
1019
1020        // A versioned interface and a different versioned package is ok
1021        assert!(ComponentDependencies::deserialize(toml! {
1022            "foo:bar/baz@0.1.0" = "0.1.0"
1023            "foo:bar@0.2.0" = "0.2.0"
1024        })
1025        .unwrap()
1026        .validate()
1027        .is_ok());
1028
1029        // A versioned interface and package of the same version is an error
1030        assert!(ComponentDependencies::deserialize(toml! {
1031            "foo:bar/baz@0.1.0" = "0.1.0"
1032            "foo:bar@0.1.0" = "0.1.0"
1033        })
1034        .unwrap()
1035        .validate()
1036        .is_err());
1037
1038        // A versioned interface and unversioned package is an error
1039        assert!(ComponentDependencies::deserialize(toml! {
1040            "foo:bar/baz@0.1.0" = "0.1.0"
1041            "foo:bar" = "0.1.0"
1042        })
1043        .unwrap()
1044        .validate()
1045        .is_err());
1046
1047        // An unversioned interface and versioned package is an error
1048        assert!(ComponentDependencies::deserialize(toml! {
1049            "foo:bar/baz" = "0.1.0"
1050            "foo:bar@0.1.0" = "0.1.0"
1051        })
1052        .unwrap()
1053        .validate()
1054        .is_err());
1055
1056        // An unversioned interface and unversioned package is an error
1057        assert!(ComponentDependencies::deserialize(toml! {
1058            "foo:bar/baz" = "0.1.0"
1059            "foo:bar" = "0.1.0"
1060        })
1061        .unwrap()
1062        .validate()
1063        .is_err());
1064    }
1065
1066    fn normalized_component(
1067        manifest: &AppManifest,
1068        component: &str,
1069        profile: Option<&str>,
1070    ) -> Component {
1071        use crate::normalize::normalize_manifest;
1072
1073        let id =
1074            KebabId::try_from(component.to_owned()).expect("component ID should have been kebab");
1075
1076        let mut manifest = manifest.clone();
1077        normalize_manifest(&mut manifest, profile).expect("should have normalised");
1078        manifest
1079            .components
1080            .get(&id)
1081            .expect("should have compopnent with id profile-test")
1082            .clone()
1083    }
1084
1085    #[test]
1086    fn profiles_override_source() {
1087        let manifest = AppManifest::deserialize(toml! {
1088            spin_manifest_version = 2
1089            [application]
1090            name = "trigger-configs"
1091            [[trigger.fake]]
1092            component = "profile-test"
1093            [component.profile-test]
1094            source = "original"
1095            [component.profile-test.profile.fancy]
1096            source = "fancy-schmancy"
1097        })
1098        .expect("manifest should be valid");
1099
1100        let id = "profile-test";
1101
1102        let component = normalized_component(&manifest, id, None);
1103        assert!(matches!(&component.source, ComponentSource::Local(p) if p == "original"));
1104
1105        let component = normalized_component(&manifest, id, Some("fancy"));
1106        assert!(matches!(&component.source, ComponentSource::Local(p) if p == "fancy-schmancy"));
1107
1108        let component = normalized_component(&manifest, id, Some("non-existent"));
1109        assert!(matches!(&component.source, ComponentSource::Local(p) if p == "original"));
1110    }
1111
1112    #[test]
1113    fn profiles_override_build_command() {
1114        let manifest = AppManifest::deserialize(toml! {
1115            spin_manifest_version = 2
1116            [application]
1117            name = "trigger-configs"
1118            [[trigger.fake]]
1119            component = "profile-test"
1120            [component.profile-test]
1121            source = "original"
1122            build.command = "buildme --release"
1123            [component.profile-test.profile.fancy]
1124            source = "fancy-schmancy"
1125            build.command = ["buildme --fancy", "lintme"]
1126        })
1127        .expect("manifest should be valid");
1128
1129        let id = "profile-test";
1130
1131        let build = normalized_component(&manifest, id, None)
1132            .build
1133            .expect("should have default build");
1134        assert_eq!(1, build.commands().len());
1135        assert_eq!("buildme --release", build.commands().next().unwrap());
1136
1137        let build = normalized_component(&manifest, id, Some("fancy"))
1138            .build
1139            .expect("should have fancy build");
1140        assert_eq!(2, build.commands().len());
1141        assert_eq!("buildme --fancy", build.commands().next().unwrap());
1142        assert_eq!("lintme", build.commands().nth(1).unwrap());
1143
1144        let build = normalized_component(&manifest, id, Some("non-existent"))
1145            .build
1146            .expect("should fall back to default build");
1147        assert_eq!(1, build.commands().len());
1148        assert_eq!("buildme --release", build.commands().next().unwrap());
1149    }
1150
1151    #[test]
1152    fn profiles_can_have_build_command_when_default_doesnt() {
1153        let manifest = AppManifest::deserialize(toml! {
1154            spin_manifest_version = 2
1155            [application]
1156            name = "trigger-configs"
1157            [[trigger.fake]]
1158            component = "profile-test"
1159            [component.profile-test]
1160            source = "original"
1161            [component.profile-test.profile.fancy]
1162            source = "fancy-schmancy"
1163            build.command = ["buildme --fancy", "lintme"]
1164        })
1165        .expect("manifest should be valid");
1166
1167        let component = normalized_component(&manifest, "profile-test", None);
1168        assert!(component.build.is_none(), "shouldn't have default build");
1169
1170        let component = normalized_component(&manifest, "profile-test", Some("fancy"));
1171        assert!(component.build.is_some(), "should have fancy build");
1172
1173        let build = component.build.expect("should have fancy build");
1174
1175        assert_eq!(2, build.commands().len());
1176        assert_eq!("buildme --fancy", build.commands().next().unwrap());
1177        assert_eq!("lintme", build.commands().nth(1).unwrap());
1178    }
1179
1180    #[test]
1181    fn profiles_override_env_vars() {
1182        let manifest = AppManifest::deserialize(toml! {
1183            spin_manifest_version = 2
1184            [application]
1185            name = "trigger-configs"
1186            [[trigger.fake]]
1187            component = "profile-test"
1188            [component.profile-test]
1189            source = "original"
1190            environment = { DB_URL = "pg://production" }
1191            [component.profile-test.profile.fancy]
1192            environment = { DB_URL = "pg://fancy", FANCINESS = "1" }
1193        })
1194        .expect("manifest should be valid");
1195
1196        let id = "profile-test";
1197
1198        let component = normalized_component(&manifest, id, None);
1199
1200        assert_eq!(1, component.environment.len());
1201        assert_eq!(
1202            "pg://production",
1203            component
1204                .environment
1205                .get("DB_URL")
1206                .expect("DB_URL should have been set")
1207        );
1208
1209        let component = normalized_component(&manifest, id, Some("fancy"));
1210
1211        assert_eq!(2, component.environment.len());
1212        assert_eq!(
1213            "pg://fancy",
1214            component
1215                .environment
1216                .get("DB_URL")
1217                .expect("DB_URL should have been set")
1218        );
1219        assert_eq!(
1220            "1",
1221            component
1222                .environment
1223                .get("FANCINESS")
1224                .expect("FANCINESS should have been set")
1225        );
1226    }
1227
1228    #[test]
1229    fn profiles_dependencies() {
1230        let manifest = AppManifest::deserialize(toml! {
1231            spin_manifest_version = 2
1232            [application]
1233            name = "trigger-configs"
1234            [[trigger.fake]]
1235            component = "profile-test"
1236            [component.profile-test]
1237            source = "original"
1238            [component.profile-test.dependencies]
1239            "foo-bar" = "1.0.0"
1240            [component.profile-test.profile.fancy]
1241            dependencies = { "foo-bar" = { path = "local.wasm" }, "fancy-thing" = "1.2.3" }
1242        })
1243        .expect("manifest should be valid");
1244
1245        let id = "profile-test";
1246
1247        let component = normalized_component(&manifest, id, None);
1248
1249        assert_eq!(1, component.dependencies.inner.len());
1250        assert!(matches!(
1251            component
1252                .dependencies
1253                .inner
1254                .get(&DependencyName::Plain(KebabId::try_from("foo-bar".to_owned()).unwrap()))
1255                .expect("foo-bar dep should have been set"),
1256            ComponentDependency::Version(v) if v == "1.0.0",
1257        ));
1258
1259        let component = normalized_component(&manifest, id, Some("fancy"));
1260
1261        assert_eq!(2, component.dependencies.inner.len());
1262        assert!(matches!(
1263            component
1264                .dependencies
1265                .inner
1266                .get(&DependencyName::Plain(KebabId::try_from("foo-bar".to_owned()).unwrap()))
1267                .expect("foo-bar dep should have been set"),
1268            ComponentDependency::Local { path, .. } if path == &PathBuf::from("local.wasm"),
1269        ));
1270        assert!(matches!(
1271            component
1272                .dependencies
1273                .inner
1274                .get(&DependencyName::Plain(KebabId::try_from("fancy-thing".to_owned()).unwrap()))
1275                .expect("fancy-thing dep should have been set"),
1276            ComponentDependency::Version(v) if v == "1.2.3",
1277        ));
1278    }
1279}