Skip to main content

spin_manifest/schema/
v2.rs

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