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