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