Skip to main content

spin_manifest/schema/
v2.rs

1use anyhow::{Context, anyhow};
2use itertools::Itertools;
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use spin_serde::{DependencyName, DependencyPackageName, FixedVersion, LowerSnakeId};
6pub use spin_serde::{KebabId, SnakeId};
7use std::path::PathBuf;
8
9pub use super::common::{ComponentBuildConfig, ComponentSource, Variable, WasiFilesMount};
10use super::json_schema;
11
12pub(crate) type Map<K, V> = indexmap::IndexMap<K, V>;
13
14/// App manifest
15#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
16#[serde(deny_unknown_fields)]
17pub struct AppManifest {
18    /// `spin_manifest_version = 2`
19    #[schemars(with = "usize", range(min = 2, max = 2))]
20    pub spin_manifest_version: FixedVersion<2>,
21    /// `[application]`
22    pub application: AppDetails,
23    /// Application configuration variables. These can be set via environment variables, or
24    /// from sources such as Hashicorp Vault or Azure KeyVault by using a runtime config file.
25    /// They are not available directly to components: use a component variable to ingest them.
26    ///
27    /// Learn more: https://spinframework.dev/variables, https://spinframework.dev/dynamic-configuration#application-variables-runtime-configuration
28    #[serde(default, skip_serializing_if = "Map::is_empty")]
29    pub variables: Map<LowerSnakeId, Variable>,
30    /// The triggers to which the application responds. Most triggers can appear
31    /// multiple times with different parameters: for example, the `http` trigger may
32    /// appear multiple times with different routes, or the `redis` trigger with
33    /// different channels.
34    ///
35    /// Example: `[[trigger.http]]`
36    #[serde(rename = "trigger")]
37    #[schemars(with = "json_schema::TriggerSchema")]
38    pub triggers: Map<String, Vec<Trigger>>,
39    /// `[component.<id>]`
40    #[serde(rename = "component")]
41    #[serde(default, skip_serializing_if = "Map::is_empty")]
42    pub components: Map<KebabId, Component>,
43}
44
45impl AppManifest {
46    /// This method ensures that the dependencies of each component are valid.
47    pub fn validate_dependencies(&self) -> anyhow::Result<()> {
48        for (component_id, component) in &self.components {
49            component
50                .dependencies
51                .validate()
52                .with_context(|| format!("component {component_id:?} has invalid dependencies"))?;
53        }
54        Ok(())
55    }
56
57    /// Whether any component in the application defines the given profile.
58    /// Not every component defines every profile, and components intentionally
59    /// fall back to the anonymouse profile if they are asked for a profile
60    /// they don't define. So this can be used to detect that a user might have
61    /// mistyped a profile (e.g. `spin up --profile deugb`).
62    pub fn ensure_profile(&self, profile: Option<&str>) -> anyhow::Result<()> {
63        let Some(p) = profile else {
64            return Ok(());
65        };
66
67        let is_defined = self.components.values().any(|c| c.profile.contains_key(p));
68
69        if is_defined {
70            Ok(())
71        } else {
72            Err(anyhow!("Profile {p} is not defined in this application"))
73        }
74    }
75}
76
77/// App details
78#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
79#[serde(deny_unknown_fields)]
80pub struct AppDetails {
81    /// The name of the application.
82    ///
83    /// Example: `name = "my-app"`
84    pub name: String,
85    /// The application version. This should be a valid semver version.
86    ///
87    /// Example: `version = "1.0.0"`
88    #[serde(default, skip_serializing_if = "String::is_empty")]
89    pub version: String,
90    /// A human-readable description of the application.
91    ///
92    /// Example: `description = "App description"`
93    #[serde(default, skip_serializing_if = "String::is_empty")]
94    pub description: String,
95    /// The author(s) of the application.
96    ///
97    /// `authors = ["author@example.com"]`
98    #[serde(default, skip_serializing_if = "Vec::is_empty")]
99    pub authors: Vec<String>,
100    /// The Spin environments with which application components must be compatible
101    /// unless otherwise specified. Individual components may express different
102    /// requirements: these override the application-level default.
103    ///
104    /// Example: `targets = ["spin-up:3.3", "spinkube:0.4"]`
105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
106    pub targets: Vec<TargetEnvironmentRef>,
107    /// Application-level settings for the trigger types used in the application.
108    /// The possible values are trigger type-specific.
109    ///
110    /// Example:
111    ///
112    /// ```ignore
113    /// [application.triggers.redis]
114    /// address = "redis://notifications.example.com:6379"
115    /// ```
116    ///
117    /// Learn more (Redis example): https://spinframework.dev/redis-trigger#setting-a-default-server
118    #[serde(rename = "trigger", default, skip_serializing_if = "Map::is_empty")]
119    #[schemars(schema_with = "json_schema::map_of_toml_tables")]
120    pub trigger_global_configs: Map<String, toml::Table>,
121    /// Settings for custom tools or plugins. Spin ignores this field.
122    #[serde(default, skip_serializing_if = "Map::is_empty")]
123    #[schemars(schema_with = "json_schema::map_of_toml_tables")]
124    pub tool: Map<String, toml::Table>,
125}
126
127/// Trigger configuration. A trigger maps an event of the trigger's type (e.g.
128/// an HTTP request on route `/shop`, a Redis message on channel `orders`) to
129/// a Spin component.
130///
131/// The trigger manifest contains additional fields which depend on the trigger
132/// type. For the `http` type, these additional fields are `route` (required) and
133/// `executor` (optional). For the `redis` type, the additional fields are
134/// `channel` (required) and `address` (optional). For other types, see the trigger
135/// documentation.
136///
137/// Learn more: https://spinframework.dev/http-trigger, https://spinframework.dev/redis-trigger
138#[derive(Clone, Debug, Serialize, Deserialize)]
139pub struct Trigger {
140    /// Optional identifier for the trigger.
141    ///
142    /// Example: `id = "trigger-id"`
143    #[serde(default, skip_serializing_if = "String::is_empty")]
144    pub id: String,
145    /// The component that Spin should run when the trigger occurs. For HTTP triggers,
146    /// this is the HTTP request handler for the trigger route. This is typically
147    /// the ID of an entry in the `[component]` table, although you can also write
148    /// the component out as the value of this field.
149    ///
150    /// Example: `component = "shop-handler"`
151    ///
152    /// Learn more: https://spinframework.dev/triggers#triggers-and-components
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub component: Option<ComponentSpec>,
155    /// Additional components used when the trigger occurs.
156    /// The meaning of entries in this table is trigger-specific.
157    ///
158    /// `components = { ... }`
159    #[serde(default, skip_serializing_if = "Map::is_empty")]
160    pub components: Map<String, OneOrManyComponentSpecs>,
161    /// Additional components to be invoked during trigger processing.
162    /// The meaning of entries in this table is trigger-specific.
163    ///
164    /// `dependencies = { ... }`
165    #[serde(default, skip_serializing_if = "Map::is_empty")]
166    pub dependencies: Map<String, TriggerDependencies>,
167    /// Opaque trigger-type-specific config
168    #[serde(flatten)]
169    pub config: toml::Table,
170}
171
172/// One or many `ComponentSpec`(s)
173#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
174#[serde(transparent)]
175pub struct OneOrManyComponentSpecs(
176    #[serde(with = "one_or_many")]
177    #[schemars(schema_with = "json_schema::one_or_many::<ComponentSpec>")]
178    pub Vec<ComponentSpec>,
179);
180
181/// One or many `ComponentSpec`(s)
182#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
183#[serde(transparent)]
184pub struct TriggerDependencies(pub Vec<TriggerDependency>);
185
186/// Component reference or inline definition
187#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
188#[serde(deny_unknown_fields, untagged, try_from = "toml::Value")]
189#[schemars(schema_with = "json_schema::id_or_component")]
190pub enum ComponentSpec {
191    /// `"component-id"`
192    Reference(KebabId),
193    /// `{ ... }`
194    Inline(Box<Component>),
195}
196
197/// Specifies how to satisfy an import dependency of the component. This may be one of:
198///
199/// - A semantic versioning constraint for the package version to use. Spin fetches the latest matching version of the package whose name matches the dependency name from the default registry.
200///
201/// Example: `"my:dep/import" = ">= 0.1.0"`
202///
203/// - A package from a registry.
204///
205/// Example: `"my:dep/import" = { version = "0.1.0", registry = "registry.io", ...}`
206///
207/// - A package from a filesystem path.
208///
209/// Example: `"my:dependency" = { path = "path/to/component.wasm", export = "my-export" }`
210///
211/// - A component in the application. The referenced component binary is composed: additional
212///   configuration such as files, networking, storage, etc. are ignored. This is intended
213///   primarily as a convenience for including dependencies in the manifest so that they
214///   can be built using `spin build`.
215///
216/// Example: `"my:dependency" = { component = "my-dependency", export = "my-export" }`
217///
218/// - A package from an HTTP URL.
219///
220/// Example: `"my:import" = { url = "https://example.com/component.wasm", sha256 = "sha256:..." }`
221///
222/// Learn more: https://spinframework.dev/v3/writing-apps#using-component-dependencies
223#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
224#[serde(untagged, deny_unknown_fields)]
225pub enum TriggerDependency {
226    /// `... = { version = "0.1.0", registry = "registry.io", ...}`
227    #[schemars(description = "")] // schema docs are on the parent
228    Package {
229        /// A semantic versioning constraint for the package version to use. Required. Spin
230        /// fetches the latest matching version from the specified registry, or from
231        /// the default registry if no registry is specified.
232        ///
233        /// Example: `"my:dep/import" = { version = ">= 0.1.0" }`
234        ///
235        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-registry
236        version: String,
237        /// The registry that hosts the package. If omitted, this defaults to your
238        /// system default registry.
239        ///
240        /// Example: `"my:dep/import" = { registry = "registry.io", version = "0.1.0" }`
241        ///
242        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-registry
243        registry: Option<String>,
244        /// The name of the package to use. If omitted, this defaults to the package name of the
245        /// imported interface.
246        ///
247        /// Example: `"my:dep/import" = { package = "your:implementation", version = "0.1.0" }`
248        ///
249        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-registry
250        package: String,
251        /// The set of configurations to inherit from the parent component. If omitted or set to `false`,
252        /// no configurations will be inherited. If `true`, all configurations will be inherited.
253        /// Selective inheritance can be specified by enumerating the configuration keys the dependency
254        /// would like to inherit.
255        ///
256        /// Examples:
257        ///     `"my:dep/import" = { version = "0.1.0", inherit_configuration = true }`
258        ///     `"my:dep/import" = { version = "0.1.0", inherit_configuration = ["ai_models", "allowed_outbound_hosts"] }`
259        inherit_configuration: Option<InheritConfiguration>,
260    },
261    /// `... = { path = "path/to/component.wasm", export = "my-export" }`
262    #[schemars(description = "")] // schema docs are on the parent
263    Local {
264        /// The path to the Wasm file that implements the dependency.
265        ///
266        /// Example: `"my:dep/import" = { path = "path/to/component.wasm" }`
267        ///
268        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-local-component
269        path: PathBuf,
270        /// The set of configurations to inherit from the parent component. If omitted or set to `false`,
271        /// no configurations will be inherited. If `true`, all configurations will be inherited.
272        /// Selective inheritance can be specified by enumerating the configuration keys the dependency
273        /// would like to inherit.
274        ///
275        /// Examples:
276        ///     `"my:dep/import" = { version = "0.1.0", inherit_configuration = true }`
277        ///     `"my:dep/import" = { version = "0.1.0", inherit_configuration = ["ai_models", "allowed_outbound_hosts"] }`
278        inherit_configuration: Option<InheritConfiguration>,
279    },
280    /// `... = { url = "https://example.com/component.wasm", sha256 = "..." }`
281    #[schemars(description = "")] // schema docs are on the parent
282    HTTP {
283        /// The URL to the Wasm component that implements the dependency.
284        ///
285        /// Example: `"my:dep/import" = { url = "https://example.com/component.wasm", sha256 = "sha256:..." }`
286        ///
287        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-url
288        url: String,
289        /// The SHA256 digest of the Wasm file. This is required for integrity checking. Must begin with `sha256:`.
290        ///
291        /// Example: `"my:dep/import" = { sha256 = "sha256:...", ... }`
292        ///
293        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-url
294        digest: String,
295        /// The set of configurations to inherit from the parent component. If omitted or set to `false`,
296        /// no configurations will be inherited. If `true`, all configurations will be inherited.
297        /// Selective inheritance can be specified by enumerating the configuration keys the dependency
298        /// would like to inherit.
299        ///
300        /// Examples:
301        ///     `"my:dep/import" = { version = "0.1.0", inherit_configuration = true }`
302        ///     `"my:dep/import" = { version = "0.1.0", inherit_configuration = ["ai_models", "allowed_outbound_hosts"] }`
303        inherit_configuration: Option<InheritConfiguration>,
304    },
305    /// `... = { component = "my-dependency" }`
306    #[schemars(description = "")] // schema docs are on the parent
307    AppComponent {
308        /// The ID of the component which implements the dependency.
309        ///
310        /// Example: `"my:dep/import" = { component = "my-dependency" }`
311        ///
312        /// Learn more: https://spinframework.dev/writing-apps#using-component-dependencies
313        component: KebabId,
314        /// The set of configurations to inherit from the parent component. If omitted or set to `false`,
315        /// no configurations will be inherited. If `true`, all configurations will be inherited.
316        /// Selective inheritance can be specified by enumerating the configuration keys the dependency
317        /// would like to inherit.
318        ///
319        /// Examples:
320        ///     `"my:dep/import" = { version = "0.1.0", inherit_configuration = true }`
321        ///     `"my:dep/import" = { version = "0.1.0", inherit_configuration = ["ai_models", "allowed_outbound_hosts"] }`
322        inherit_configuration: Option<InheritConfiguration>,
323    },
324}
325
326impl TryFrom<toml::Value> for ComponentSpec {
327    type Error = toml::de::Error;
328
329    fn try_from(value: toml::Value) -> Result<Self, Self::Error> {
330        if value.is_str() {
331            Ok(ComponentSpec::Reference(KebabId::deserialize(value)?))
332        } else {
333            Ok(ComponentSpec::Inline(Box::new(Component::deserialize(
334                value,
335            )?)))
336        }
337    }
338}
339
340/// Specifies how to satisfy an import dependency of the component. This may be one of:
341///
342/// - 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.
343///
344/// Example: `"my:dep/import" = ">= 0.1.0"`
345///
346/// - A package from a registry.
347///
348/// Example: `"my:dep/import" = { version = "0.1.0", registry = "registry.io", ...}`
349///
350/// - A package from a filesystem path.
351///
352/// Example: `"my:dependency" = { path = "path/to/component.wasm", export = "my-export" }`
353///
354/// - A component in the application. The referenced component binary is composed: additional
355///   configuration such as files, networking, storage, etc. are ignored. This is intended
356///   primarily as a convenience for including dependencies in the manifest so that they
357///   can be built using `spin build`.
358///
359/// Example: `"my:dependency" = { component = "my-dependency", export = "my-export" }`
360///
361/// - A package from an HTTP URL.
362///
363/// Example: `"my:import" = { url = "https://example.com/component.wasm", sha256 = "sha256:..." }`
364///
365/// Learn more: https://spinframework.dev/v3/writing-apps#using-component-dependencies
366#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
367#[serde(untagged, deny_unknown_fields)]
368pub enum ComponentDependency {
369    /// `... = ">= 0.1.0"`
370    #[schemars(description = "")] // schema docs are on the parent
371    Version(String),
372    /// `... = { version = "0.1.0", registry = "registry.io", ...}`
373    #[schemars(description = "")] // schema docs are on the parent
374    Package {
375        /// A semantic versioning constraint for the package version to use. Required. Spin
376        /// fetches the latest matching version from the specified registry, or from
377        /// the default registry if no registry is specified.
378        ///
379        /// Example: `"my:dep/import" = { version = ">= 0.1.0" }`
380        ///
381        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-registry
382        version: String,
383        /// The registry that hosts the package. If omitted, this defaults to your
384        /// system default registry.
385        ///
386        /// Example: `"my:dep/import" = { registry = "registry.io", version = "0.1.0" }`
387        ///
388        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-registry
389        registry: Option<String>,
390        /// The name of the package to use. If omitted, this defaults to the package name of the
391        /// imported interface.
392        ///
393        /// Example: `"my:dep/import" = { package = "your:implementation", version = "0.1.0" }`
394        ///
395        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-registry
396        package: Option<String>,
397        /// The name of the export in the package. If omitted, this defaults to the name of the import.
398        ///
399        /// Example: `"my:dep/import" = { export = "your:impl/export", version = "0.1.0" }`
400        ///
401        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-registry
402        export: Option<String>,
403        /// The set of configurations to inherit from the parent component. If omitted or set to `false`,
404        /// no configurations will be inherited. If `true`, all configurations will be inherited.
405        /// Selective inheritance can be specified by enumerating the configuration keys the dependency
406        /// would like to inherit.
407        ///
408        /// Examples:
409        ///     `"my:dep/import" = { version = "0.1.0", inherit_configuration = true }`
410        ///     `"my:dep/import" = { version = "0.1.0", inherit_configuration = ["ai_models", "allowed_outbound_hosts"] }`
411        inherit_configuration: Option<InheritConfiguration>,
412    },
413    /// `... = { path = "path/to/component.wasm", export = "my-export" }`
414    #[schemars(description = "")] // schema docs are on the parent
415    Local {
416        /// The path to the Wasm file that implements the dependency.
417        ///
418        /// Example: `"my:dep/import" = { path = "path/to/component.wasm" }`
419        ///
420        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-local-component
421        path: PathBuf,
422        /// The name of the export in the package. If omitted, this defaults to the name of the import.
423        ///
424        /// Example: `"my:dep/import" = { export = "your:impl/export", path = "path/to/component.wasm" }`
425        ///
426        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-local-component
427        export: Option<String>,
428        /// The set of configurations to inherit from the parent component. If omitted or set to `false`,
429        /// no configurations will be inherited. If `true`, all configurations will be inherited.
430        /// Selective inheritance can be specified by enumerating the configuration keys the dependency
431        /// would like to inherit.
432        ///
433        /// Examples:
434        ///     `"my:dep/import" = { version = "0.1.0", inherit_configuration = true }`
435        ///     `"my:dep/import" = { version = "0.1.0", inherit_configuration = ["ai_models", "allowed_outbound_hosts"] }`
436        inherit_configuration: Option<InheritConfiguration>,
437    },
438    /// `... = { url = "https://example.com/component.wasm", sha256 = "..." }`
439    #[schemars(description = "")] // schema docs are on the parent
440    HTTP {
441        /// The URL to the Wasm component that implements the dependency.
442        ///
443        /// Example: `"my:dep/import" = { url = "https://example.com/component.wasm", sha256 = "sha256:..." }`
444        ///
445        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-url
446        url: String,
447        /// The SHA256 digest of the Wasm file. This is required for integrity checking. Must begin with `sha256:`.
448        ///
449        /// Example: `"my:dep/import" = { sha256 = "sha256:...", ... }`
450        ///
451        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-url
452        digest: String,
453        /// The name of the export in the package. If omitted, this defaults to the name of the import.
454        ///
455        /// Example: `"my:dep/import" = { export = "your:impl/export", ... }`
456        ///
457        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-url
458        export: Option<String>,
459        /// The set of configurations to inherit from the parent component. If omitted or set to `false`,
460        /// no configurations will be inherited. If `true`, all configurations will be inherited.
461        /// Selective inheritance can be specified by enumerating the configuration keys the dependency
462        /// would like to inherit.
463        ///
464        /// Examples:
465        ///     `"my:dep/import" = { version = "0.1.0", inherit_configuration = true }`
466        ///     `"my:dep/import" = { version = "0.1.0", inherit_configuration = ["ai_models", "allowed_outbound_hosts"] }`
467        inherit_configuration: Option<InheritConfiguration>,
468    },
469    /// `... = { component = "my-dependency" }`
470    #[schemars(description = "")] // schema docs are on the parent
471    AppComponent {
472        /// The ID of the component which implements the dependency.
473        ///
474        /// Example: `"my:dep/import" = { component = "my-dependency" }`
475        ///
476        /// Learn more: https://spinframework.dev/writing-apps#using-component-dependencies
477        component: KebabId,
478        /// The name of the export in the package. If omitted, this defaults to the name of the import.
479        ///
480        /// Example: `"my:dep/import" = { export = "your:impl/export", component = "my-dependency" }`
481        ///
482        /// Learn more: https://spinframework.dev/writing-apps#using-component-dependencies
483        export: Option<String>,
484        /// The set of configurations to inherit from the parent component. If omitted or set to `false`,
485        /// no configurations will be inherited. If `true`, all configurations will be inherited.
486        /// Selective inheritance can be specified by enumerating the configuration keys the dependency
487        /// would like to inherit.
488        ///
489        /// Examples:
490        ///     `"my:dep/import" = { version = "0.1.0", inherit_configuration = true }`
491        ///     `"my:dep/import" = { version = "0.1.0", inherit_configuration = ["ai_models", "allowed_outbound_hosts"] }`
492        inherit_configuration: Option<InheritConfiguration>,
493    },
494}
495
496/// The set of configurations to inherit from the parent component.
497///
498/// Can be specified as:
499/// - `true` — inherit all configurations
500/// - `false` — inherit no configurations (equivalent to omitting the field)
501/// - `["key1", "key2"]` — inherit only the specified configuration keys
502#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
503#[serde(untagged)]
504pub enum InheritConfiguration {
505    /// All or no configurations will be inherited, specified as `true` or `false`.
506    All(bool),
507    /// Only the specified configuration keys will be inherited from the parent component.
508    Some(Vec<String>),
509}
510
511impl ComponentDependency {
512    /// Returns the `inherit_configuration` field if present on this dependency variant.
513    pub fn inherit_configuration(&self) -> Option<&InheritConfiguration> {
514        match self {
515            ComponentDependency::Version(_) => None,
516            ComponentDependency::Package {
517                inherit_configuration,
518                ..
519            }
520            | ComponentDependency::Local {
521                inherit_configuration,
522                ..
523            }
524            | ComponentDependency::HTTP {
525                inherit_configuration,
526                ..
527            }
528            | ComponentDependency::AppComponent {
529                inherit_configuration,
530                ..
531            } => inherit_configuration.as_ref(),
532        }
533    }
534
535    /// Sets the `inherit_configuration` field on this dependency variant.
536    /// No-op for `Version` variants.
537    pub fn set_inherit_configuration(&mut self, value: InheritConfiguration) {
538        match self {
539            ComponentDependency::Version(_) => {}
540            ComponentDependency::Package {
541                inherit_configuration,
542                ..
543            }
544            | ComponentDependency::Local {
545                inherit_configuration,
546                ..
547            }
548            | ComponentDependency::HTTP {
549                inherit_configuration,
550                ..
551            }
552            | ComponentDependency::AppComponent {
553                inherit_configuration,
554                ..
555            } => {
556                *inherit_configuration = Some(value);
557            }
558        }
559    }
560}
561
562/// A Spin component.
563#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
564#[serde(deny_unknown_fields)]
565pub struct Component {
566    /// The file, package, or URL containing the component Wasm binary.
567    ///
568    /// Example: `source = "bin/cart.wasm"`
569    ///
570    /// Learn more: https://spinframework.dev/writing-apps#the-component-source
571    pub source: ComponentSource,
572    /// A human-readable description of the component.
573    ///
574    /// Example: `description = "Shopping cart"`
575    #[serde(default, skip_serializing_if = "String::is_empty")]
576    pub description: String,
577    /// Configuration variables available to the component. Names must be
578    /// in `lower_snake_case`. Values are strings, and may refer
579    /// to application variables using `{{ ... }}` syntax.
580    ///
581    /// `variables = { users_endpoint = "https://{{ api_host }}/users"}`
582    ///
583    /// Learn more: https://spinframework.dev/variables#adding-variables-to-your-applications
584    #[serde(default, skip_serializing_if = "Map::is_empty")]
585    pub variables: Map<LowerSnakeId, String>,
586    /// Environment variables to be set for the Wasm module.
587    ///
588    /// `environment = { DB_URL = "mysql://spin:spin@localhost/dev" }`
589    #[serde(default, skip_serializing_if = "Map::is_empty")]
590    pub environment: Map<String, String>,
591    /// The files the component is allowed to read. Each list entry is either:
592    ///
593    /// - a glob pattern (e.g. "assets/**/*.jpg"); or
594    ///
595    /// - a source-destination pair indicating where a host directory should be mapped in the guest (e.g. { source = "assets", destination = "/" })
596    ///
597    /// Learn more: https://spinframework.dev/writing-apps#including-files-with-components
598    #[serde(default, skip_serializing_if = "Vec::is_empty")]
599    pub files: Vec<WasiFilesMount>,
600    /// Any files or glob patterns that should not be available to the
601    /// Wasm module at runtime, even though they match a `files`` entry.
602    ///
603    /// Example: `exclude_files = ["secrets/*"]`
604    ///
605    /// Learn more: https://spinframework.dev/writing-apps#including-files-with-components
606    #[serde(default, skip_serializing_if = "Vec::is_empty")]
607    pub exclude_files: Vec<String>,
608    /// Deprecated. Use `allowed_outbound_hosts` instead.
609    ///
610    /// Example: `allowed_http_hosts = ["example.com"]`
611    #[serde(default, skip_serializing_if = "Vec::is_empty")]
612    #[deprecated]
613    pub allowed_http_hosts: Vec<String>,
614    /// The network destinations which the component is allowed to access.
615    /// Each entry is in the form "(scheme)://(host)[:port]". Each element
616    /// allows * as a wildcard e.g. "https://\*" (HTTPS on the default port
617    /// to any destination) or "\*://localhost:\*" (any protocol to any port on
618    /// localhost). The host part allows segment wildcards for subdomains
619    /// e.g. "https://\*.example.com". Application variables are allowed using
620    /// `{{ my_var }}`` syntax.
621    ///
622    /// Example: `allowed_outbound_hosts = ["redis://myredishost.com:6379"]`
623    ///
624    /// Learn more: https://spinframework.dev/http-outbound#granting-http-permissions-to-components
625    #[serde(default, skip_serializing_if = "Vec::is_empty")]
626    #[schemars(with = "Vec<json_schema::AllowedOutboundHost>")]
627    pub allowed_outbound_hosts: Vec<String>,
628    /// The key-value stores which the component is allowed to access. Stores are identified
629    /// by label e.g. "default" or "customer". Stores other than "default" must be mapped
630    /// to a backing store in the runtime config.
631    ///
632    /// Example: `key_value_stores = ["default", "my-store"]`
633    ///
634    /// Learn more: https://spinframework.dev/kv-store-api-guide#custom-key-value-stores
635    #[serde(
636        default,
637        with = "kebab_or_snake_case",
638        skip_serializing_if = "Vec::is_empty"
639    )]
640    #[schemars(with = "Vec<json_schema::KeyValueStore>")]
641    pub key_value_stores: Vec<String>,
642    /// The SQLite databases which the component is allowed to access. Databases are identified
643    /// by label e.g. "default" or "analytics". Databases other than "default" must be mapped
644    /// to a backing store in the runtime config. Use "spin up --sqlite" to run database setup scripts.
645    ///
646    /// Example: `sqlite_databases = ["default", "my-database"]`
647    ///
648    /// Learn more: https://spinframework.dev/sqlite-api-guide#preparing-an-sqlite-database
649    #[serde(
650        default,
651        with = "kebab_or_snake_case",
652        skip_serializing_if = "Vec::is_empty"
653    )]
654    #[schemars(with = "Vec<json_schema::SqliteDatabase>")]
655    pub sqlite_databases: Vec<String>,
656    /// The AI models which the component is allowed to access. For local execution, you must
657    /// download all models; for hosted execution, you should check which models are available
658    /// in your target environment.
659    ///
660    /// Example: `ai_models = ["llama2-chat"]`
661    ///
662    /// Learn more: https://spinframework.dev/serverless-ai-api-guide#using-serverless-ai-from-applications
663    #[serde(default, skip_serializing_if = "Vec::is_empty")]
664    #[schemars(with = "Vec<json_schema::AIModel>")]
665    pub ai_models: Vec<String>,
666    /// The Spin environments with which the component must be compatible.
667    /// If present, this overrides the default application targets (they are not combined).
668    ///
669    /// Example: `targets = ["spin-up:3.3", "spinkube:0.4"]`
670    #[serde(default, skip_serializing_if = "Option::is_none")]
671    pub targets: Option<Vec<TargetEnvironmentRef>>,
672    /// The component build configuration.
673    ///
674    /// Learn more: https://spinframework.dev/build
675    #[serde(default, skip_serializing_if = "Option::is_none")]
676    pub build: Option<ComponentBuildConfig>,
677    /// Settings for custom tools or plugins. Spin ignores this field.
678    #[serde(default, skip_serializing_if = "Map::is_empty")]
679    #[schemars(schema_with = "json_schema::map_of_toml_tables")]
680    pub tool: Map<String, toml::Table>,
681    /// If true, dependencies can invoke Spin APIs with the same permissions as the main
682    /// component. If false, dependencies have no permissions (e.g. network,
683    /// key-value stores, SQLite databases).
684    ///
685    /// Learn more: https://spinframework.dev/writing-apps#dependency-permissions
686    #[serde(default, skip_serializing_if = "Option::is_none")]
687    pub dependencies_inherit_configuration: Option<bool>,
688    /// Specifies how to satisfy Wasm Component Model imports of this component.
689    ///
690    /// Learn more: https://spinframework.dev/writing-apps#using-component-dependencies
691    #[serde(default, skip_serializing_if = "ComponentDependencies::is_empty")]
692    pub dependencies: ComponentDependencies,
693    /// Override values to use when building or running a named build profile.
694    ///
695    /// Example: `profile.debug.build.command = "npm run build-debug"`
696    #[serde(default, skip_serializing_if = "Map::is_empty")]
697    pub(crate) profile: Map<String, ComponentProfileOverride>,
698}
699
700/// Customisations for a Spin component in a non-default profile.
701#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
702#[serde(deny_unknown_fields)]
703pub struct ComponentProfileOverride {
704    /// The file, package, or URL containing the component Wasm binary.
705    ///
706    /// Example: `source = "bin/debug/cart.wasm"`
707    ///
708    /// Learn more: https://spinframework.dev/writing-apps#the-component-source
709    #[serde(default, skip_serializing_if = "Option::is_none")]
710    pub(crate) source: Option<ComponentSource>,
711
712    /// Environment variables for the Wasm module to be overridden in this profile.
713    /// Environment variables specified in the default profile will still be set
714    /// if not overridden here.
715    ///
716    /// `environment = { DB_URL = "mysql://spin:spin@localhost/dev" }`
717    #[serde(default, skip_serializing_if = "Map::is_empty")]
718    pub(crate) environment: Map<String, String>,
719
720    /// Wasm Component Model imports to be overridden in this profile.
721    /// Dependencies specified in the default profile will still be composed
722    /// if not overridden here.
723    ///
724    /// Learn more: https://spinframework.dev/writing-apps#using-component-dependencies
725    #[serde(default, skip_serializing_if = "ComponentDependencies::is_empty")]
726    pub(crate) dependencies: ComponentDependencies,
727
728    /// The command or commands for building the component in non-default profiles.
729    /// If a component has no special build instructions for a profile, the
730    /// default build command is used.
731    #[serde(default, skip_serializing_if = "Option::is_none")]
732    pub(crate) build: Option<ComponentProfileBuildOverride>,
733}
734
735/// Customisations for a Spin component build in a non-default profile.
736#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
737#[serde(deny_unknown_fields)]
738pub struct ComponentProfileBuildOverride {
739    /// The command or commands to build the component in a named profile. If multiple commands
740    /// are specified, they are run sequentially from left to right.
741    ///
742    /// Example: `build.command = "cargo build"`
743    ///
744    /// Learn more: https://spinframework.dev/build#setting-up-for-spin-build
745    pub(crate) command: super::common::Commands,
746}
747
748/// Component dependencies
749#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
750#[serde(transparent)]
751pub struct ComponentDependencies {
752    /// `dependencies = { "foo:bar" = ">= 0.1.0" }`
753    pub inner: Map<DependencyName, ComponentDependency>,
754}
755
756impl ComponentDependencies {
757    /// This method validates the correct specification of dependencies in a
758    /// component section of the manifest. See the documentation on the methods
759    /// called for more information on the specific checks.
760    fn validate(&self) -> anyhow::Result<()> {
761        self.ensure_plain_names_have_package()?;
762        self.ensure_package_names_no_export()?;
763        self.ensure_disjoint()?;
764        Ok(())
765    }
766
767    /// This method ensures that all dependency names in plain form (e.g.
768    /// "foo-bar") do not map to a `ComponentDependency::Version`, or a
769    /// `ComponentDependency::Package` where the `package` is `None`.
770    fn ensure_plain_names_have_package(&self) -> anyhow::Result<()> {
771        for (dependency_name, dependency) in self.inner.iter() {
772            let DependencyName::Plain(plain) = dependency_name else {
773                continue;
774            };
775            match dependency {
776                ComponentDependency::Package { package, .. } if package.is_none() => {}
777                ComponentDependency::Version(_) => {}
778                _ => continue,
779            }
780            anyhow::bail!("dependency {plain:?} must specify a package name");
781        }
782        Ok(())
783    }
784
785    /// This method ensures that dependency names in the package form (e.g.
786    /// "foo:bar" or "foo:bar@0.1.0") do not map to specific exported
787    /// interfaces, e.g. `"foo:bar = { ..., export = "my-export" }"` is invalid.
788    fn ensure_package_names_no_export(&self) -> anyhow::Result<()> {
789        for (dependency_name, dependency) in self.inner.iter() {
790            if let DependencyName::Package(name) = dependency_name
791                && name.interface.is_none()
792            {
793                let export = match dependency {
794                    ComponentDependency::Package { export, .. } => export,
795                    ComponentDependency::Local { export, .. } => export,
796                    _ => continue,
797                };
798
799                anyhow::ensure!(
800                    export.is_none(),
801                    "using an export to satisfy the package dependency {dependency_name:?} is not currently permitted",
802                );
803            }
804        }
805        Ok(())
806    }
807
808    /// This method ensures that dependencies names do not conflict with each other. That is to say
809    /// that two dependencies of the same package must have disjoint versions or interfaces.
810    fn ensure_disjoint(&self) -> anyhow::Result<()> {
811        for [this, other] in self.inner.keys().array_combinations::<2>() {
812            let (DependencyName::Package(this), DependencyName::Package(other)) = (this, other)
813            else {
814                continue;
815            };
816
817            if this.package == other.package {
818                Self::check_disjoint(this, other)?;
819            }
820        }
821        Ok(())
822    }
823
824    fn check_disjoint(
825        this: &DependencyPackageName,
826        other: &DependencyPackageName,
827    ) -> anyhow::Result<()> {
828        assert_eq!(this.package, other.package);
829
830        if let (Some(this_ver), Some(other_ver)) = (this.version.clone(), other.version.clone())
831            && Self::normalize_compatible_version(this_ver)
832                != Self::normalize_compatible_version(other_ver)
833        {
834            return Ok(());
835        }
836
837        if let (Some(this_itf), Some(other_itf)) =
838            (this.interface.as_ref(), other.interface.as_ref())
839            && this_itf != other_itf
840        {
841            return Ok(());
842        }
843
844        Err(anyhow!("{this:?} dependency conflicts with {other:?}"))
845    }
846
847    /// Normalize version to perform a compatibility check against another version.
848    ///
849    /// See backwards comptabilitiy rules at https://semver.org/
850    fn normalize_compatible_version(mut version: semver::Version) -> semver::Version {
851        version.build = semver::BuildMetadata::EMPTY;
852
853        if version.pre != semver::Prerelease::EMPTY {
854            return version;
855        }
856        if version.major > 0 {
857            version.minor = 0;
858            version.patch = 0;
859            return version;
860        }
861
862        if version.minor > 0 {
863            version.patch = 0;
864            return version;
865        }
866
867        version
868    }
869
870    fn is_empty(&self) -> bool {
871        self.inner.is_empty()
872    }
873}
874
875/// Identifies a deployment target.
876#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
877#[serde(untagged, deny_unknown_fields)]
878pub enum TargetEnvironmentRef {
879    /// Environment definition doc reference e.g. `spin-up:3.2`, `my-host`. This is looked up
880    /// in the default environment catalogue (the `spin-environments` repo, `env` directory).
881    Catalogue(String),
882    /// An environment definition doc HTTP URL
883    Http {
884        /// The environment document URL e.g. `https://github.com/me/environments/blob/main/target-envs/spin-up.3.6.toml`.
885        url: String,
886    },
887    /// A local environment document file. This is expected to contain a serialised
888    /// EnvironmentDefinition in TOML format.
889    File {
890        /// The file path of the document.
891        path: PathBuf,
892    },
893}
894
895impl std::fmt::Display for TargetEnvironmentRef {
896    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
897        match self {
898            Self::Catalogue(e) => e.fmt(f),
899            Self::Http { url } => url.fmt(f),
900            Self::File { path } => path.display().fmt(f),
901        }
902    }
903}
904
905mod kebab_or_snake_case {
906    use serde::{Deserialize, Serialize};
907    pub use spin_serde::{KebabId, SnakeId};
908    pub fn serialize<S>(value: &[String], serializer: S) -> Result<S::Ok, S::Error>
909    where
910        S: serde::ser::Serializer,
911    {
912        if value.iter().all(|s| {
913            KebabId::try_from(s.clone()).is_ok() || SnakeId::try_from(s.to_owned()).is_ok()
914        }) {
915            value.serialize(serializer)
916        } else {
917            Err(serde::ser::Error::custom(
918                "expected kebab-case or snake_case",
919            ))
920        }
921    }
922
923    pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
924    where
925        D: serde::Deserializer<'de>,
926    {
927        let value = toml::Value::deserialize(deserializer)?;
928        let list: Vec<String> = Vec::deserialize(value).map_err(serde::de::Error::custom)?;
929        if list.iter().all(|s| {
930            KebabId::try_from(s.clone()).is_ok() || SnakeId::try_from(s.to_owned()).is_ok()
931        }) {
932            Ok(list)
933        } else {
934            Err(serde::de::Error::custom(
935                "expected kebab-case or snake_case",
936            ))
937        }
938    }
939}
940
941impl Component {
942    /// Combine `allowed_outbound_hosts` with the deprecated `allowed_http_hosts` into
943    /// one array all normalized to the syntax of `allowed_outbound_hosts`.
944    pub fn normalized_allowed_outbound_hosts(&self) -> anyhow::Result<Vec<String>> {
945        #[allow(deprecated)]
946        let normalized =
947            crate::compat::convert_allowed_http_to_allowed_hosts(&self.allowed_http_hosts, false)?;
948        if !normalized.is_empty() {
949            terminal::warn!(
950                "Use of the deprecated field `allowed_http_hosts` - to fix, \
951            replace `allowed_http_hosts` with `allowed_outbound_hosts = {normalized:?}`",
952            )
953        }
954
955        Ok(self
956            .allowed_outbound_hosts
957            .iter()
958            .cloned()
959            .chain(normalized)
960            .collect())
961    }
962}
963
964mod one_or_many {
965    use serde::{Deserialize, Deserializer, Serialize, Serializer};
966
967    pub fn serialize<T, S>(vec: &Vec<T>, serializer: S) -> Result<S::Ok, S::Error>
968    where
969        T: Serialize,
970        S: Serializer,
971    {
972        if vec.len() == 1 {
973            vec[0].serialize(serializer)
974        } else {
975            vec.serialize(serializer)
976        }
977    }
978
979    pub fn deserialize<'de, T, D>(deserializer: D) -> Result<Vec<T>, D::Error>
980    where
981        T: Deserialize<'de>,
982        D: Deserializer<'de>,
983    {
984        let value = toml::Value::deserialize(deserializer)?;
985        // NOTE: We explicitly check for array first rather than trying T::deserialize
986        // first, because toml's serde impl will treat an array as a sequence of fields
987        // to be assigned to struct members (e.g. Component), producing nonsensical results.
988        if let Some(arr) = value.as_array() {
989            arr.iter()
990                .map(|v| T::deserialize(v.clone()))
991                .collect::<Result<Vec<_>, _>>()
992                .map_err(serde::de::Error::custom)
993        } else {
994            T::deserialize(value)
995                .map(|v| vec![v])
996                .map_err(serde::de::Error::custom)
997        }
998    }
999}
1000
1001#[cfg(test)]
1002mod tests {
1003    use toml::toml;
1004
1005    use super::*;
1006
1007    #[derive(Deserialize)]
1008    #[allow(dead_code)]
1009    struct FakeGlobalTriggerConfig {
1010        global_option: bool,
1011    }
1012
1013    #[derive(Deserialize)]
1014    #[allow(dead_code)]
1015    struct FakeTriggerConfig {
1016        option: Option<bool>,
1017    }
1018
1019    fn as_reference(spec: &ComponentSpec) -> Option<&str> {
1020        match spec {
1021            ComponentSpec::Reference(id) => Some(id.as_ref()),
1022            ComponentSpec::Inline(_) => None,
1023        }
1024    }
1025
1026    fn as_inline(spec: &ComponentSpec) -> Option<&Component> {
1027        match spec {
1028            ComponentSpec::Reference(_) => None,
1029            ComponentSpec::Inline(c) => Some(c),
1030        }
1031    }
1032
1033    fn as_local(source: &ComponentSource) -> Option<&str> {
1034        match source {
1035            ComponentSource::Local(path) => Some(path),
1036            _ => None,
1037        }
1038    }
1039
1040    #[test]
1041    fn deserializing_trigger_configs() {
1042        let manifest = AppManifest::deserialize(toml! {
1043            spin_manifest_version = 2
1044            [application]
1045            name = "trigger-configs"
1046            [application.trigger.fake]
1047            global_option = true
1048            [[trigger.fake]]
1049            component = { source = "inline.wasm" }
1050            option = true
1051        })
1052        .unwrap();
1053
1054        FakeGlobalTriggerConfig::deserialize(
1055            manifest.application.trigger_global_configs["fake"].clone(),
1056        )
1057        .unwrap();
1058
1059        FakeTriggerConfig::deserialize(manifest.triggers["fake"][0].config.clone()).unwrap();
1060    }
1061
1062    #[derive(Deserialize)]
1063    #[allow(dead_code)]
1064    struct FakeGlobalToolConfig {
1065        lint_level: String,
1066    }
1067
1068    #[derive(Deserialize)]
1069    #[allow(dead_code)]
1070    struct FakeComponentToolConfig {
1071        command: String,
1072    }
1073
1074    #[test]
1075    fn deserialising_custom_tool_settings() {
1076        let manifest = AppManifest::deserialize(toml! {
1077            spin_manifest_version = 2
1078            [application]
1079            name = "trigger-configs"
1080            [application.tool.lint]
1081            lint_level = "savage"
1082            [[trigger.fake]]
1083            something = "something else"
1084            [component.fake]
1085            source = "dummy"
1086            [component.fake.tool.clean]
1087            command = "cargo clean"
1088        })
1089        .unwrap();
1090
1091        FakeGlobalToolConfig::deserialize(manifest.application.tool["lint"].clone()).unwrap();
1092        let fake_id: KebabId = "fake".to_owned().try_into().unwrap();
1093        FakeComponentToolConfig::deserialize(manifest.components[&fake_id].tool["clean"].clone())
1094            .unwrap();
1095    }
1096
1097    #[test]
1098    fn deserializing_labels() {
1099        AppManifest::deserialize(toml! {
1100            spin_manifest_version = 2
1101            [application]
1102            name = "trigger-configs"
1103            [[trigger.fake]]
1104            something = "something else"
1105            [component.fake]
1106            source = "dummy"
1107            key_value_stores = ["default", "snake_case", "kebab-case"]
1108            sqlite_databases = ["default", "snake_case", "kebab-case"]
1109        })
1110        .unwrap();
1111    }
1112
1113    #[test]
1114    fn deserializing_labels_fails_for_non_kebab_or_snake() {
1115        assert!(
1116            AppManifest::deserialize(toml! {
1117                spin_manifest_version = 2
1118                [application]
1119                name = "trigger-configs"
1120                [[trigger.fake]]
1121                something = "something else"
1122                [component.fake]
1123                source = "dummy"
1124                key_value_stores = ["b@dlabel"]
1125            })
1126            .is_err()
1127        );
1128    }
1129
1130    fn get_test_component_with_labels(labels: Vec<String>) -> Component {
1131        #[allow(deprecated)]
1132        Component {
1133            source: ComponentSource::Local("dummy".to_string()),
1134            description: "".to_string(),
1135            variables: Map::new(),
1136            environment: Map::new(),
1137            files: vec![],
1138            exclude_files: vec![],
1139            allowed_http_hosts: vec![],
1140            allowed_outbound_hosts: vec![],
1141            key_value_stores: labels.clone(),
1142            sqlite_databases: labels,
1143            ai_models: vec![],
1144            targets: None,
1145            build: None,
1146            tool: Map::new(),
1147            dependencies_inherit_configuration: None,
1148            dependencies: Default::default(),
1149            profile: Default::default(),
1150        }
1151    }
1152
1153    #[test]
1154    fn serialize_labels() {
1155        let stores = vec![
1156            "default".to_string(),
1157            "snake_case".to_string(),
1158            "kebab-case".to_string(),
1159        ];
1160        let component = get_test_component_with_labels(stores.clone());
1161        let serialized = toml::to_string(&component).unwrap();
1162        let deserialized = toml::from_str::<Component>(&serialized).unwrap();
1163        assert_eq!(deserialized.key_value_stores, stores);
1164    }
1165
1166    #[test]
1167    fn serialize_labels_fails_for_non_kebab_or_snake() {
1168        let component = get_test_component_with_labels(vec!["camelCase".to_string()]);
1169        assert!(toml::to_string(&component).is_err());
1170    }
1171
1172    #[test]
1173    fn test_valid_snake_ids() {
1174        for valid in ["default", "mixed_CASE_words", "letters1_then2_numbers345"] {
1175            if let Err(err) = SnakeId::try_from(valid.to_string()) {
1176                panic!("{valid:?} should be value: {err:?}");
1177            }
1178        }
1179    }
1180
1181    #[test]
1182    fn test_invalid_snake_ids() {
1183        for invalid in [
1184            "",
1185            "kebab-case",
1186            "_leading_underscore",
1187            "trailing_underscore_",
1188            "double__underscore",
1189            "1initial_number",
1190            "unicode_snowpeople☃☃☃",
1191            "mIxEd_case",
1192            "MiXeD_case",
1193        ] {
1194            if SnakeId::try_from(invalid.to_string()).is_ok() {
1195                panic!("{invalid:?} should not be a valid SnakeId");
1196            }
1197        }
1198    }
1199
1200    #[test]
1201    fn test_check_disjoint() {
1202        for (a, b) in [
1203            ("foo:bar@0.1.0", "foo:bar@0.2.0"),
1204            ("foo:bar/baz@0.1.0", "foo:bar/baz@0.2.0"),
1205            ("foo:bar/baz@0.1.0", "foo:bar/bub@0.1.0"),
1206            ("foo:bar@0.1.0", "foo:bar/bub@0.2.0"),
1207            ("foo:bar@1.0.0", "foo:bar@2.0.0"),
1208            ("foo:bar@0.1.0", "foo:bar@1.0.0"),
1209            ("foo:bar/baz", "foo:bar/bub"),
1210            ("foo:bar/baz@0.1.0-alpha", "foo:bar/baz@0.1.0-beta"),
1211        ] {
1212            let a: DependencyPackageName = a.parse().expect(a);
1213            let b: DependencyPackageName = b.parse().expect(b);
1214            ComponentDependencies::check_disjoint(&a, &b).unwrap();
1215        }
1216
1217        for (a, b) in [
1218            ("foo:bar@0.1.0", "foo:bar@0.1.1"),
1219            ("foo:bar/baz@0.1.0", "foo:bar@0.1.0"),
1220            ("foo:bar/baz@0.1.0", "foo:bar@0.1.0"),
1221            ("foo:bar", "foo:bar@0.1.0"),
1222            ("foo:bar@0.1.0-pre", "foo:bar@0.1.0-pre"),
1223        ] {
1224            let a: DependencyPackageName = a.parse().expect(a);
1225            let b: DependencyPackageName = b.parse().expect(b);
1226            assert!(
1227                ComponentDependencies::check_disjoint(&a, &b).is_err(),
1228                "{a} should conflict with {b}",
1229            );
1230        }
1231    }
1232
1233    #[test]
1234    fn test_validate_dependencies() {
1235        // Specifying a dependency name as a plain-name without a package is an error
1236        assert!(
1237            ComponentDependencies::deserialize(toml! {
1238                "plain-name" = "0.1.0"
1239            })
1240            .unwrap()
1241            .validate()
1242            .is_err()
1243        );
1244
1245        // Specifying a dependency name as a plain-name without a package is an error
1246        assert!(
1247            ComponentDependencies::deserialize(toml! {
1248                "plain-name" = { version = "0.1.0" }
1249            })
1250            .unwrap()
1251            .validate()
1252            .is_err()
1253        );
1254
1255        // Specifying an export to satisfy a package dependency name is an error
1256        assert!(
1257            ComponentDependencies::deserialize(toml! {
1258                "foo:baz@0.1.0" = { path = "foo.wasm", export = "foo"}
1259            })
1260            .unwrap()
1261            .validate()
1262            .is_err()
1263        );
1264
1265        // Two compatible versions of the same package is an error
1266        assert!(
1267            ComponentDependencies::deserialize(toml! {
1268                "foo:baz@0.1.0" = "0.1.0"
1269                "foo:bar@0.2.1" = "0.2.1"
1270                "foo:bar@0.2.2" = "0.2.2"
1271            })
1272            .unwrap()
1273            .validate()
1274            .is_err()
1275        );
1276
1277        // Two disjoint versions of the same package is ok
1278        assert!(
1279            ComponentDependencies::deserialize(toml! {
1280                "foo:bar@0.1.0" = "0.1.0"
1281                "foo:bar@0.2.0" = "0.2.0"
1282                "foo:baz@0.2.0" = "0.1.0"
1283            })
1284            .unwrap()
1285            .validate()
1286            .is_ok()
1287        );
1288
1289        // Unversioned and versioned dependencies of the same package is an error
1290        assert!(
1291            ComponentDependencies::deserialize(toml! {
1292                "foo:bar@0.1.0" = "0.1.0"
1293                "foo:bar" = ">= 0.2.0"
1294            })
1295            .unwrap()
1296            .validate()
1297            .is_err()
1298        );
1299
1300        // Two interfaces of two disjoint versions of a package is ok
1301        assert!(
1302            ComponentDependencies::deserialize(toml! {
1303                "foo:bar/baz@0.1.0" = "0.1.0"
1304                "foo:bar/baz@0.2.0" = "0.2.0"
1305            })
1306            .unwrap()
1307            .validate()
1308            .is_ok()
1309        );
1310
1311        // A versioned interface and a different versioned package is ok
1312        assert!(
1313            ComponentDependencies::deserialize(toml! {
1314                "foo:bar/baz@0.1.0" = "0.1.0"
1315                "foo:bar@0.2.0" = "0.2.0"
1316            })
1317            .unwrap()
1318            .validate()
1319            .is_ok()
1320        );
1321
1322        // A versioned interface and package of the same version is an error
1323        assert!(
1324            ComponentDependencies::deserialize(toml! {
1325                "foo:bar/baz@0.1.0" = "0.1.0"
1326                "foo:bar@0.1.0" = "0.1.0"
1327            })
1328            .unwrap()
1329            .validate()
1330            .is_err()
1331        );
1332
1333        // A versioned interface and unversioned package is an error
1334        assert!(
1335            ComponentDependencies::deserialize(toml! {
1336                "foo:bar/baz@0.1.0" = "0.1.0"
1337                "foo:bar" = "0.1.0"
1338            })
1339            .unwrap()
1340            .validate()
1341            .is_err()
1342        );
1343
1344        // An unversioned interface and versioned package is an error
1345        assert!(
1346            ComponentDependencies::deserialize(toml! {
1347                "foo:bar/baz" = "0.1.0"
1348                "foo:bar@0.1.0" = "0.1.0"
1349            })
1350            .unwrap()
1351            .validate()
1352            .is_err()
1353        );
1354
1355        // An unversioned interface and unversioned package is an error
1356        assert!(
1357            ComponentDependencies::deserialize(toml! {
1358                "foo:bar/baz" = "0.1.0"
1359                "foo:bar" = "0.1.0"
1360            })
1361            .unwrap()
1362            .validate()
1363            .is_err()
1364        );
1365    }
1366
1367    fn normalized_component(
1368        manifest: &AppManifest,
1369        component: &str,
1370        profile: Option<&str>,
1371    ) -> Component {
1372        use crate::normalize::normalize_manifest;
1373
1374        let id =
1375            KebabId::try_from(component.to_owned()).expect("component ID should have been kebab");
1376
1377        let mut manifest = manifest.clone();
1378        normalize_manifest(&mut manifest, profile).expect("should have normalised");
1379        manifest
1380            .components
1381            .get(&id)
1382            .expect("should have compopnent with id profile-test")
1383            .clone()
1384    }
1385
1386    #[test]
1387    fn profiles_override_source() {
1388        let manifest = AppManifest::deserialize(toml! {
1389            spin_manifest_version = 2
1390            [application]
1391            name = "trigger-configs"
1392            [[trigger.fake]]
1393            component = "profile-test"
1394            [component.profile-test]
1395            source = "original"
1396            [component.profile-test.profile.fancy]
1397            source = "fancy-schmancy"
1398        })
1399        .expect("manifest should be valid");
1400
1401        let id = "profile-test";
1402
1403        let component = normalized_component(&manifest, id, None);
1404        assert!(matches!(&component.source, ComponentSource::Local(p) if p == "original"));
1405
1406        let component = normalized_component(&manifest, id, Some("fancy"));
1407        assert!(matches!(&component.source, ComponentSource::Local(p) if p == "fancy-schmancy"));
1408
1409        let component = normalized_component(&manifest, id, Some("non-existent"));
1410        assert!(matches!(&component.source, ComponentSource::Local(p) if p == "original"));
1411    }
1412
1413    #[test]
1414    fn profiles_override_build_command() {
1415        let manifest = AppManifest::deserialize(toml! {
1416            spin_manifest_version = 2
1417            [application]
1418            name = "trigger-configs"
1419            [[trigger.fake]]
1420            component = "profile-test"
1421            [component.profile-test]
1422            source = "original"
1423            build.command = "buildme --release"
1424            [component.profile-test.profile.fancy]
1425            source = "fancy-schmancy"
1426            build.command = ["buildme --fancy", "lintme"]
1427        })
1428        .expect("manifest should be valid");
1429
1430        let id = "profile-test";
1431
1432        let build = normalized_component(&manifest, id, None)
1433            .build
1434            .expect("should have default build");
1435        assert_eq!(1, build.commands().len());
1436        assert_eq!("buildme --release", build.commands().next().unwrap());
1437
1438        let build = normalized_component(&manifest, id, Some("fancy"))
1439            .build
1440            .expect("should have fancy build");
1441        assert_eq!(2, build.commands().len());
1442        assert_eq!("buildme --fancy", build.commands().next().unwrap());
1443        assert_eq!("lintme", build.commands().nth(1).unwrap());
1444
1445        let build = normalized_component(&manifest, id, Some("non-existent"))
1446            .build
1447            .expect("should fall back to default build");
1448        assert_eq!(1, build.commands().len());
1449        assert_eq!("buildme --release", build.commands().next().unwrap());
1450    }
1451
1452    #[test]
1453    fn profiles_can_have_build_command_when_default_doesnt() {
1454        let manifest = AppManifest::deserialize(toml! {
1455            spin_manifest_version = 2
1456            [application]
1457            name = "trigger-configs"
1458            [[trigger.fake]]
1459            component = "profile-test"
1460            [component.profile-test]
1461            source = "original"
1462            [component.profile-test.profile.fancy]
1463            source = "fancy-schmancy"
1464            build.command = ["buildme --fancy", "lintme"]
1465        })
1466        .expect("manifest should be valid");
1467
1468        let component = normalized_component(&manifest, "profile-test", None);
1469        assert!(component.build.is_none(), "shouldn't have default build");
1470
1471        let component = normalized_component(&manifest, "profile-test", Some("fancy"));
1472        assert!(component.build.is_some(), "should have fancy build");
1473
1474        let build = component.build.expect("should have fancy build");
1475
1476        assert_eq!(2, build.commands().len());
1477        assert_eq!("buildme --fancy", build.commands().next().unwrap());
1478        assert_eq!("lintme", build.commands().nth(1).unwrap());
1479    }
1480
1481    #[test]
1482    fn profiles_override_env_vars() {
1483        let manifest = AppManifest::deserialize(toml! {
1484            spin_manifest_version = 2
1485            [application]
1486            name = "trigger-configs"
1487            [[trigger.fake]]
1488            component = "profile-test"
1489            [component.profile-test]
1490            source = "original"
1491            environment = { DB_URL = "pg://production" }
1492            [component.profile-test.profile.fancy]
1493            environment = { DB_URL = "pg://fancy", FANCINESS = "1" }
1494        })
1495        .expect("manifest should be valid");
1496
1497        let id = "profile-test";
1498
1499        let component = normalized_component(&manifest, id, None);
1500
1501        assert_eq!(1, component.environment.len());
1502        assert_eq!(
1503            "pg://production",
1504            component
1505                .environment
1506                .get("DB_URL")
1507                .expect("DB_URL should have been set")
1508        );
1509
1510        let component = normalized_component(&manifest, id, Some("fancy"));
1511
1512        assert_eq!(2, component.environment.len());
1513        assert_eq!(
1514            "pg://fancy",
1515            component
1516                .environment
1517                .get("DB_URL")
1518                .expect("DB_URL should have been set")
1519        );
1520        assert_eq!(
1521            "1",
1522            component
1523                .environment
1524                .get("FANCINESS")
1525                .expect("FANCINESS should have been set")
1526        );
1527    }
1528
1529    #[test]
1530    fn profiles_dependencies() {
1531        let manifest = AppManifest::deserialize(toml! {
1532            spin_manifest_version = 2
1533            [application]
1534            name = "trigger-configs"
1535            [[trigger.fake]]
1536            component = "profile-test"
1537            [component.profile-test]
1538            source = "original"
1539            [component.profile-test.dependencies]
1540            "foo-bar" = "1.0.0"
1541            [component.profile-test.profile.fancy]
1542            dependencies = { "foo-bar" = { path = "local.wasm" }, "fancy-thing" = "1.2.3" }
1543        })
1544        .expect("manifest should be valid");
1545
1546        let id = "profile-test";
1547
1548        let component = normalized_component(&manifest, id, None);
1549
1550        assert_eq!(1, component.dependencies.inner.len());
1551        assert!(matches!(
1552            component
1553                .dependencies
1554                .inner
1555                .get(&DependencyName::Plain(KebabId::try_from("foo-bar".to_owned()).unwrap()))
1556                .expect("foo-bar dep should have been set"),
1557            ComponentDependency::Version(v) if v == "1.0.0",
1558        ));
1559
1560        let component = normalized_component(&manifest, id, Some("fancy"));
1561
1562        assert_eq!(2, component.dependencies.inner.len());
1563        assert!(matches!(
1564            component
1565                .dependencies
1566                .inner
1567                .get(&DependencyName::Plain(KebabId::try_from("foo-bar".to_owned()).unwrap()))
1568                .expect("foo-bar dep should have been set"),
1569            ComponentDependency::Local { path, .. } if path == &PathBuf::from("local.wasm"),
1570        ));
1571        assert!(matches!(
1572            component
1573                .dependencies
1574                .inner
1575                .get(&DependencyName::Plain(KebabId::try_from("fancy-thing".to_owned()).unwrap()))
1576                .expect("fancy-thing dep should have been set"),
1577            ComponentDependency::Version(v) if v == "1.2.3",
1578        ));
1579    }
1580
1581    #[test]
1582    fn can_deserialise_one_or_many_one_ref() {
1583        let manifest = AppManifest::deserialize(toml! {
1584            spin_manifest_version = 2
1585            [application]
1586            name = "test"
1587            [[trigger.fake]]
1588            component = "test1"
1589            components = { babble = "test2" }
1590        })
1591        .expect("manifest should be valid");
1592
1593        let trigger = manifest.triggers.get("fake").unwrap()[0].clone();
1594
1595        assert_eq!(
1596            Some("test1"),
1597            as_reference(trigger.component.as_ref().unwrap())
1598        );
1599        assert_eq!(1, trigger.components.len());
1600        let babble_comps = &trigger.components.get("babble").as_ref().unwrap().0;
1601        assert_eq!(1, babble_comps.len());
1602        assert_eq!(Some("test2"), as_reference(&babble_comps[0]));
1603    }
1604
1605    #[test]
1606    fn can_deserialise_one_or_many_one_inline() {
1607        let manifest = AppManifest::deserialize(toml! {
1608            spin_manifest_version = 2
1609            [application]
1610            name = "test"
1611            [[trigger.fake]]
1612            component = "test1"
1613            components = { babble = { source = "fie.wasm", allowed_outbound_hosts = ["http://example.com"] } }
1614        })
1615        .expect("manifest should be valid");
1616
1617        let trigger = manifest.triggers.get("fake").unwrap()[0].clone();
1618
1619        assert_eq!(1, trigger.components.len());
1620        let babble_comps = &trigger.components.get("babble").as_ref().unwrap().0;
1621        assert_eq!(1, babble_comps.len());
1622        let single = as_inline(&babble_comps[0]).expect("should have deserialised to inline");
1623        assert_eq!(Some("fie.wasm"), as_local(&single.source));
1624        assert_eq!(1, single.allowed_outbound_hosts.len());
1625    }
1626
1627    #[test]
1628    fn can_deserialise_one_or_many_many() {
1629        let manifest = AppManifest::deserialize(toml! {
1630            spin_manifest_version = 2
1631            [application]
1632            name = "test"
1633            [[trigger.fake]]
1634            component = "test1"
1635            components = { babble = ["test2", { source = "fie.wasm", allowed_outbound_hosts = ["http://example.com"] }, "test3"] }
1636        })
1637        .expect("manifest should be valid");
1638
1639        let trigger = manifest.triggers.get("fake").unwrap()[0].clone();
1640
1641        assert_eq!(1, trigger.components.len());
1642        let babble_comps = &trigger.components.get("babble").as_ref().unwrap().0;
1643        assert_eq!(3, babble_comps.len());
1644
1645        assert_eq!(Some("test2"), as_reference(&babble_comps[0]));
1646
1647        let inline = as_inline(&babble_comps[1]).expect("should have deserialised to inline");
1648        assert_eq!(Some("fie.wasm"), as_local(&inline.source));
1649        assert_eq!(1, inline.allowed_outbound_hosts.len());
1650
1651        assert_eq!(Some("test3"), as_reference(&babble_comps[2]));
1652    }
1653}