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}