Skip to main content

spin_manifest/schema/v2/
component.rs

1use anyhow::anyhow;
2use itertools::Itertools;
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use super::{
7    ComponentBuildConfig, ComponentDependency, ComponentSource, DependencyName,
8    DependencyPackageName, LowerSnakeId, Map, TargetEnvironmentRef, WasiFilesMount, json_schema,
9    kebab_or_snake_case,
10};
11
12/// A Spin component.
13#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
14#[serde(deny_unknown_fields)]
15pub struct Component {
16    /// The file, package, or URL containing the component Wasm binary.
17    ///
18    /// Example: `source = "bin/cart.wasm"`
19    ///
20    /// Learn more: https://spinframework.dev/writing-apps#the-component-source
21    pub source: ComponentSource,
22    /// A human-readable description of the component.
23    ///
24    /// Example: `description = "Shopping cart"`
25    #[serde(default, skip_serializing_if = "String::is_empty")]
26    pub description: String,
27    /// Configuration variables available to the component. Names must be
28    /// in `lower_snake_case`. Values are strings, and may refer
29    /// to application variables using `{{ ... }}` syntax.
30    ///
31    /// `variables = { users_endpoint = "https://{{ api_host }}/users"}`
32    ///
33    /// Learn more: https://spinframework.dev/variables#adding-variables-to-your-applications
34    #[serde(default, skip_serializing_if = "Map::is_empty")]
35    pub variables: Map<LowerSnakeId, String>,
36    /// Environment variables to be set for the Wasm module.
37    ///
38    /// `environment = { DB_URL = "mysql://spin:spin@localhost/dev" }`
39    #[serde(default, skip_serializing_if = "Map::is_empty")]
40    pub environment: Map<String, String>,
41    /// The files the component is allowed to read. Each list entry is either:
42    ///
43    /// - a glob pattern (e.g. "assets/**/*.jpg"); or
44    ///
45    /// - a source-destination pair indicating where a host directory should be mapped in the guest (e.g. { source = "assets", destination = "/" })
46    ///
47    /// Learn more: https://spinframework.dev/writing-apps#including-files-with-components
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    pub files: Vec<WasiFilesMount>,
50    /// Any files or glob patterns that should not be available to the
51    /// Wasm module at runtime, even though they match a `files`` entry.
52    ///
53    /// Example: `exclude_files = ["secrets/*"]`
54    ///
55    /// Learn more: https://spinframework.dev/writing-apps#including-files-with-components
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub exclude_files: Vec<String>,
58    /// Deprecated. Use `allowed_outbound_hosts` instead.
59    ///
60    /// Example: `allowed_http_hosts = ["example.com"]`
61    #[serde(default, skip_serializing_if = "Vec::is_empty")]
62    #[deprecated]
63    pub allowed_http_hosts: Vec<String>,
64    /// The network destinations which the component is allowed to access.
65    /// Each entry is in the form "(scheme)://(host)[:port]". Each element
66    /// allows * as a wildcard e.g. "https://\*" (HTTPS on the default port
67    /// to any destination) or "\*://localhost:\*" (any protocol to any port on
68    /// localhost). The host part allows segment wildcards for subdomains
69    /// e.g. "https://\*.example.com". Application variables are allowed using
70    /// `{{ my_var }}`` syntax.
71    ///
72    /// Example: `allowed_outbound_hosts = ["redis://myredishost.com:6379"]`
73    ///
74    /// Learn more: https://spinframework.dev/http-outbound#granting-http-permissions-to-components
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    #[schemars(with = "Vec<json_schema::AllowedOutboundHost>")]
77    pub allowed_outbound_hosts: Vec<String>,
78    /// The key-value stores which the component is allowed to access. Stores are identified
79    /// by label e.g. "default" or "customer". Stores other than "default" must be mapped
80    /// to a backing store in the runtime config.
81    ///
82    /// Example: `key_value_stores = ["default", "my-store"]`
83    ///
84    /// Learn more: https://spinframework.dev/kv-store-api-guide#custom-key-value-stores
85    #[serde(
86        default,
87        with = "kebab_or_snake_case",
88        skip_serializing_if = "Vec::is_empty"
89    )]
90    #[schemars(with = "Vec<json_schema::KeyValueStore>")]
91    pub key_value_stores: Vec<String>,
92    /// The SQLite databases which the component is allowed to access. Databases are identified
93    /// by label e.g. "default" or "analytics". Databases other than "default" must be mapped
94    /// to a backing store in the runtime config. Use "spin up --sqlite" to run database setup scripts.
95    ///
96    /// Example: `sqlite_databases = ["default", "my-database"]`
97    ///
98    /// Learn more: https://spinframework.dev/sqlite-api-guide#preparing-an-sqlite-database
99    #[serde(
100        default,
101        with = "kebab_or_snake_case",
102        skip_serializing_if = "Vec::is_empty"
103    )]
104    #[schemars(with = "Vec<json_schema::SqliteDatabase>")]
105    pub sqlite_databases: Vec<String>,
106    /// The AI models which the component is allowed to access. For local execution, you must
107    /// download all models; for hosted execution, you should check which models are available
108    /// in your target environment.
109    ///
110    /// Example: `ai_models = ["llama2-chat"]`
111    ///
112    /// Learn more: https://spinframework.dev/serverless-ai-api-guide#using-serverless-ai-from-applications
113    #[serde(default, skip_serializing_if = "Vec::is_empty")]
114    #[schemars(with = "Vec<json_schema::AIModel>")]
115    pub ai_models: Vec<String>,
116    /// The Spin environments with which the component must be compatible.
117    /// If present, this overrides the default application targets (they are not combined).
118    ///
119    /// Example: `targets = ["spin-up:3.3", "spinkube:0.4"]`
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub targets: Option<Vec<TargetEnvironmentRef>>,
122    /// The component build configuration.
123    ///
124    /// Learn more: https://spinframework.dev/build
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub build: Option<ComponentBuildConfig>,
127    /// Settings for custom tools or plugins. Spin ignores this field.
128    #[serde(default, skip_serializing_if = "Map::is_empty")]
129    #[schemars(schema_with = "json_schema::map_of_toml_tables")]
130    pub tool: Map<String, toml::Table>,
131    /// If true, dependencies can invoke Spin APIs with the same permissions as the main
132    /// component. If false, dependencies have no permissions (e.g. network,
133    /// key-value stores, SQLite databases).
134    ///
135    /// Learn more: https://spinframework.dev/writing-apps#dependency-permissions
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub dependencies_inherit_configuration: Option<bool>,
138    /// Specifies how to satisfy Wasm Component Model imports of this component.
139    ///
140    /// Learn more: https://spinframework.dev/writing-apps#using-component-dependencies
141    #[serde(default, skip_serializing_if = "ComponentDependencies::is_empty")]
142    pub dependencies: ComponentDependencies,
143    /// Override values to use when building or running a named build profile.
144    ///
145    /// Example: `profile.debug.build.command = "npm run build-debug"`
146    #[serde(default, skip_serializing_if = "Map::is_empty")]
147    pub(crate) profile: Map<String, ComponentProfileOverride>,
148}
149
150impl Component {
151    /// Combine `allowed_outbound_hosts` with the deprecated `allowed_http_hosts` into
152    /// one array all normalized to the syntax of `allowed_outbound_hosts`.
153    pub fn normalized_allowed_outbound_hosts(&self) -> anyhow::Result<Vec<String>> {
154        #[allow(deprecated)]
155        let normalized =
156            crate::compat::convert_allowed_http_to_allowed_hosts(&self.allowed_http_hosts, false)?;
157        if !normalized.is_empty() {
158            terminal::warn!(
159                "Use of the deprecated field `allowed_http_hosts` - to fix, \
160            replace `allowed_http_hosts` with `allowed_outbound_hosts = {normalized:?}`",
161            )
162        }
163
164        Ok(self
165            .allowed_outbound_hosts
166            .iter()
167            .cloned()
168            .chain(normalized)
169            .collect())
170    }
171}
172
173/// Customisations for a Spin component in a non-default profile.
174#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
175#[serde(deny_unknown_fields)]
176pub struct ComponentProfileOverride {
177    /// The file, package, or URL containing the component Wasm binary.
178    ///
179    /// Example: `source = "bin/debug/cart.wasm"`
180    ///
181    /// Learn more: https://spinframework.dev/writing-apps#the-component-source
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub(crate) source: Option<ComponentSource>,
184
185    /// Environment variables for the Wasm module to be overridden in this profile.
186    /// Environment variables specified in the default profile will still be set
187    /// if not overridden here.
188    ///
189    /// `environment = { DB_URL = "mysql://spin:spin@localhost/dev" }`
190    #[serde(default, skip_serializing_if = "Map::is_empty")]
191    pub(crate) environment: Map<String, String>,
192
193    /// Wasm Component Model imports to be overridden in this profile.
194    /// Dependencies specified in the default profile will still be composed
195    /// if not overridden here.
196    ///
197    /// Learn more: https://spinframework.dev/writing-apps#using-component-dependencies
198    #[serde(default, skip_serializing_if = "ComponentDependencies::is_empty")]
199    pub(crate) dependencies: ComponentDependencies,
200
201    /// The command or commands for building the component in non-default profiles.
202    /// If a component has no special build instructions for a profile, the
203    /// default build command is used.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub(crate) build: Option<ComponentProfileBuildOverride>,
206}
207
208/// Customisations for a Spin component build in a non-default profile.
209#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
210#[serde(deny_unknown_fields)]
211pub struct ComponentProfileBuildOverride {
212    /// The command or commands to build the component in a named profile. If multiple commands
213    /// are specified, they are run sequentially from left to right.
214    ///
215    /// Example: `build.command = "cargo build"`
216    ///
217    /// Learn more: https://spinframework.dev/build#setting-up-for-spin-build
218    pub(crate) command: crate::schema::common::Commands,
219}
220
221/// Component dependencies
222#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
223#[serde(transparent)]
224pub struct ComponentDependencies {
225    /// `dependencies = { "foo:bar" = ">= 0.1.0" }`
226    pub inner: Map<DependencyName, ComponentDependency>,
227}
228
229impl ComponentDependencies {
230    /// This method validates the correct specification of dependencies in a
231    /// component section of the manifest. See the documentation on the methods
232    /// called for more information on the specific checks.
233    pub(crate) fn validate(&self) -> anyhow::Result<()> {
234        self.ensure_plain_names_have_package()?;
235        self.ensure_package_names_no_export()?;
236        self.ensure_disjoint()?;
237        Ok(())
238    }
239
240    /// This method ensures that all dependency names in plain form (e.g.
241    /// "foo-bar") do not map to a `ComponentDependency::Version`, or a
242    /// `ComponentDependency::Package` where the `package` is `None`.
243    fn ensure_plain_names_have_package(&self) -> anyhow::Result<()> {
244        for (dependency_name, dependency) in self.inner.iter() {
245            let DependencyName::Plain(plain) = dependency_name else {
246                continue;
247            };
248            match dependency {
249                ComponentDependency::Package { package, .. } if package.is_none() => {}
250                ComponentDependency::Version(_) => {}
251                _ => continue,
252            }
253            anyhow::bail!("dependency {plain:?} must specify a package name");
254        }
255        Ok(())
256    }
257
258    /// This method ensures that dependency names in the package form (e.g.
259    /// "foo:bar" or "foo:bar@0.1.0") do not map to specific exported
260    /// interfaces, e.g. `"foo:bar = { ..., export = "my-export" }"` is invalid.
261    fn ensure_package_names_no_export(&self) -> anyhow::Result<()> {
262        for (dependency_name, dependency) in self.inner.iter() {
263            if let DependencyName::Package(name) = dependency_name
264                && name.interface.is_none()
265            {
266                let export = match dependency {
267                    ComponentDependency::Package { export, .. } => export,
268                    ComponentDependency::Local { export, .. } => export,
269                    _ => continue,
270                };
271
272                anyhow::ensure!(
273                    export.is_none(),
274                    "using an export to satisfy the package dependency {dependency_name:?} is not currently permitted",
275                );
276            }
277        }
278        Ok(())
279    }
280
281    /// This method ensures that dependencies names do not conflict with each other. That is to say
282    /// that two dependencies of the same package must have disjoint versions or interfaces.
283    fn ensure_disjoint(&self) -> anyhow::Result<()> {
284        for [this, other] in self.inner.keys().array_combinations::<2>() {
285            let (DependencyName::Package(this), DependencyName::Package(other)) = (this, other)
286            else {
287                continue;
288            };
289
290            if this.package == other.package {
291                Self::check_disjoint(this, other)?;
292            }
293        }
294        Ok(())
295    }
296
297    pub(crate) fn check_disjoint(
298        this: &DependencyPackageName,
299        other: &DependencyPackageName,
300    ) -> anyhow::Result<()> {
301        assert_eq!(this.package, other.package);
302
303        if let (Some(this_ver), Some(other_ver)) = (this.version.clone(), other.version.clone())
304            && Self::normalize_compatible_version(this_ver)
305                != Self::normalize_compatible_version(other_ver)
306        {
307            return Ok(());
308        }
309
310        if let (Some(this_itf), Some(other_itf)) =
311            (this.interface.as_ref(), other.interface.as_ref())
312            && this_itf != other_itf
313        {
314            return Ok(());
315        }
316
317        Err(anyhow!("{this:?} dependency conflicts with {other:?}"))
318    }
319
320    /// Normalize version to perform a compatibility check against another version.
321    ///
322    /// See backwards comptabilitiy rules at https://semver.org/
323    fn normalize_compatible_version(mut version: semver::Version) -> semver::Version {
324        version.build = semver::BuildMetadata::EMPTY;
325
326        if version.pre != semver::Prerelease::EMPTY {
327            return version;
328        }
329        if version.major > 0 {
330            version.minor = 0;
331            version.patch = 0;
332            return version;
333        }
334
335        if version.minor > 0 {
336            version.patch = 0;
337            return version;
338        }
339
340        version
341    }
342
343    fn is_empty(&self) -> bool {
344        self.inner.is_empty()
345    }
346}