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 (registry).
743    DefaultRegistry(String),
744    /// An environment definition doc in an OCI registry other than the default
745    Registry {
746        /// Registry or prefix hosting the environment document e.g. `ghcr.io/my/environments`.
747        registry: String,
748        /// Environment definition document name e.g. `my-spin-env:1.2`. For hosted environments
749        /// where you always want `latest`, omit the version tag e.g. `my-host`.
750        id: String,
751    },
752    /// A local environment document file. This is expected to contain a serialised
753    /// EnvironmentDefinition in TOML format.
754    File {
755        /// The file path of the document.
756        path: PathBuf,
757    },
758}
759
760mod kebab_or_snake_case {
761    use serde::{Deserialize, Serialize};
762    pub use spin_serde::{KebabId, SnakeId};
763    pub fn serialize<S>(value: &[String], serializer: S) -> Result<S::Ok, S::Error>
764    where
765        S: serde::ser::Serializer,
766    {
767        if value.iter().all(|s| {
768            KebabId::try_from(s.clone()).is_ok() || SnakeId::try_from(s.to_owned()).is_ok()
769        }) {
770            value.serialize(serializer)
771        } else {
772            Err(serde::ser::Error::custom(
773                "expected kebab-case or snake_case",
774            ))
775        }
776    }
777
778    pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
779    where
780        D: serde::Deserializer<'de>,
781    {
782        let value = toml::Value::deserialize(deserializer)?;
783        let list: Vec<String> = Vec::deserialize(value).map_err(serde::de::Error::custom)?;
784        if list.iter().all(|s| {
785            KebabId::try_from(s.clone()).is_ok() || SnakeId::try_from(s.to_owned()).is_ok()
786        }) {
787            Ok(list)
788        } else {
789            Err(serde::de::Error::custom(
790                "expected kebab-case or snake_case",
791            ))
792        }
793    }
794}
795
796impl Component {
797    /// Combine `allowed_outbound_hosts` with the deprecated `allowed_http_hosts` into
798    /// one array all normalized to the syntax of `allowed_outbound_hosts`.
799    pub fn normalized_allowed_outbound_hosts(&self) -> anyhow::Result<Vec<String>> {
800        #[allow(deprecated)]
801        let normalized =
802            crate::compat::convert_allowed_http_to_allowed_hosts(&self.allowed_http_hosts, false)?;
803        if !normalized.is_empty() {
804            terminal::warn!(
805                "Use of the deprecated field `allowed_http_hosts` - to fix, \
806            replace `allowed_http_hosts` with `allowed_outbound_hosts = {normalized:?}`",
807            )
808        }
809
810        Ok(self
811            .allowed_outbound_hosts
812            .iter()
813            .cloned()
814            .chain(normalized)
815            .collect())
816    }
817}
818
819mod one_or_many {
820    use serde::{Deserialize, Deserializer, Serialize, Serializer};
821
822    pub fn serialize<T, S>(vec: &Vec<T>, serializer: S) -> Result<S::Ok, S::Error>
823    where
824        T: Serialize,
825        S: Serializer,
826    {
827        if vec.len() == 1 {
828            vec[0].serialize(serializer)
829        } else {
830            vec.serialize(serializer)
831        }
832    }
833
834    pub fn deserialize<'de, T, D>(deserializer: D) -> Result<Vec<T>, D::Error>
835    where
836        T: Deserialize<'de>,
837        D: Deserializer<'de>,
838    {
839        let value = toml::Value::deserialize(deserializer)?;
840        if let Ok(val) = T::deserialize(value.clone()) {
841            Ok(vec![val])
842        } else {
843            Vec::deserialize(value).map_err(serde::de::Error::custom)
844        }
845    }
846}
847
848#[cfg(test)]
849mod tests {
850    use toml::toml;
851
852    use super::*;
853
854    #[derive(Deserialize)]
855    #[allow(dead_code)]
856    struct FakeGlobalTriggerConfig {
857        global_option: bool,
858    }
859
860    #[derive(Deserialize)]
861    #[allow(dead_code)]
862    struct FakeTriggerConfig {
863        option: Option<bool>,
864    }
865
866    #[test]
867    fn deserializing_trigger_configs() {
868        let manifest = AppManifest::deserialize(toml! {
869            spin_manifest_version = 2
870            [application]
871            name = "trigger-configs"
872            [application.trigger.fake]
873            global_option = true
874            [[trigger.fake]]
875            component = { source = "inline.wasm" }
876            option = true
877        })
878        .unwrap();
879
880        FakeGlobalTriggerConfig::deserialize(
881            manifest.application.trigger_global_configs["fake"].clone(),
882        )
883        .unwrap();
884
885        FakeTriggerConfig::deserialize(manifest.triggers["fake"][0].config.clone()).unwrap();
886    }
887
888    #[derive(Deserialize)]
889    #[allow(dead_code)]
890    struct FakeGlobalToolConfig {
891        lint_level: String,
892    }
893
894    #[derive(Deserialize)]
895    #[allow(dead_code)]
896    struct FakeComponentToolConfig {
897        command: String,
898    }
899
900    #[test]
901    fn deserialising_custom_tool_settings() {
902        let manifest = AppManifest::deserialize(toml! {
903            spin_manifest_version = 2
904            [application]
905            name = "trigger-configs"
906            [application.tool.lint]
907            lint_level = "savage"
908            [[trigger.fake]]
909            something = "something else"
910            [component.fake]
911            source = "dummy"
912            [component.fake.tool.clean]
913            command = "cargo clean"
914        })
915        .unwrap();
916
917        FakeGlobalToolConfig::deserialize(manifest.application.tool["lint"].clone()).unwrap();
918        let fake_id: KebabId = "fake".to_owned().try_into().unwrap();
919        FakeComponentToolConfig::deserialize(manifest.components[&fake_id].tool["clean"].clone())
920            .unwrap();
921    }
922
923    #[test]
924    fn deserializing_labels() {
925        AppManifest::deserialize(toml! {
926            spin_manifest_version = 2
927            [application]
928            name = "trigger-configs"
929            [[trigger.fake]]
930            something = "something else"
931            [component.fake]
932            source = "dummy"
933            key_value_stores = ["default", "snake_case", "kebab-case"]
934            sqlite_databases = ["default", "snake_case", "kebab-case"]
935        })
936        .unwrap();
937    }
938
939    #[test]
940    fn deserializing_labels_fails_for_non_kebab_or_snake() {
941        assert!(
942            AppManifest::deserialize(toml! {
943                spin_manifest_version = 2
944                [application]
945                name = "trigger-configs"
946                [[trigger.fake]]
947                something = "something else"
948                [component.fake]
949                source = "dummy"
950                key_value_stores = ["b@dlabel"]
951            })
952            .is_err()
953        );
954    }
955
956    fn get_test_component_with_labels(labels: Vec<String>) -> Component {
957        #[allow(deprecated)]
958        Component {
959            source: ComponentSource::Local("dummy".to_string()),
960            description: "".to_string(),
961            variables: Map::new(),
962            environment: Map::new(),
963            files: vec![],
964            exclude_files: vec![],
965            allowed_http_hosts: vec![],
966            allowed_outbound_hosts: vec![],
967            key_value_stores: labels.clone(),
968            sqlite_databases: labels,
969            ai_models: vec![],
970            targets: None,
971            build: None,
972            tool: Map::new(),
973            dependencies_inherit_configuration: None,
974            dependencies: Default::default(),
975            profile: Default::default(),
976        }
977    }
978
979    #[test]
980    fn serialize_labels() {
981        let stores = vec![
982            "default".to_string(),
983            "snake_case".to_string(),
984            "kebab-case".to_string(),
985        ];
986        let component = get_test_component_with_labels(stores.clone());
987        let serialized = toml::to_string(&component).unwrap();
988        let deserialized = toml::from_str::<Component>(&serialized).unwrap();
989        assert_eq!(deserialized.key_value_stores, stores);
990    }
991
992    #[test]
993    fn serialize_labels_fails_for_non_kebab_or_snake() {
994        let component = get_test_component_with_labels(vec!["camelCase".to_string()]);
995        assert!(toml::to_string(&component).is_err());
996    }
997
998    #[test]
999    fn test_valid_snake_ids() {
1000        for valid in ["default", "mixed_CASE_words", "letters1_then2_numbers345"] {
1001            if let Err(err) = SnakeId::try_from(valid.to_string()) {
1002                panic!("{valid:?} should be value: {err:?}");
1003            }
1004        }
1005    }
1006
1007    #[test]
1008    fn test_invalid_snake_ids() {
1009        for invalid in [
1010            "",
1011            "kebab-case",
1012            "_leading_underscore",
1013            "trailing_underscore_",
1014            "double__underscore",
1015            "1initial_number",
1016            "unicode_snowpeople☃☃☃",
1017            "mIxEd_case",
1018            "MiXeD_case",
1019        ] {
1020            if SnakeId::try_from(invalid.to_string()).is_ok() {
1021                panic!("{invalid:?} should not be a valid SnakeId");
1022            }
1023        }
1024    }
1025
1026    #[test]
1027    fn test_check_disjoint() {
1028        for (a, b) in [
1029            ("foo:bar@0.1.0", "foo:bar@0.2.0"),
1030            ("foo:bar/baz@0.1.0", "foo:bar/baz@0.2.0"),
1031            ("foo:bar/baz@0.1.0", "foo:bar/bub@0.1.0"),
1032            ("foo:bar@0.1.0", "foo:bar/bub@0.2.0"),
1033            ("foo:bar@1.0.0", "foo:bar@2.0.0"),
1034            ("foo:bar@0.1.0", "foo:bar@1.0.0"),
1035            ("foo:bar/baz", "foo:bar/bub"),
1036            ("foo:bar/baz@0.1.0-alpha", "foo:bar/baz@0.1.0-beta"),
1037        ] {
1038            let a: DependencyPackageName = a.parse().expect(a);
1039            let b: DependencyPackageName = b.parse().expect(b);
1040            ComponentDependencies::check_disjoint(&a, &b).unwrap();
1041        }
1042
1043        for (a, b) in [
1044            ("foo:bar@0.1.0", "foo:bar@0.1.1"),
1045            ("foo:bar/baz@0.1.0", "foo:bar@0.1.0"),
1046            ("foo:bar/baz@0.1.0", "foo:bar@0.1.0"),
1047            ("foo:bar", "foo:bar@0.1.0"),
1048            ("foo:bar@0.1.0-pre", "foo:bar@0.1.0-pre"),
1049        ] {
1050            let a: DependencyPackageName = a.parse().expect(a);
1051            let b: DependencyPackageName = b.parse().expect(b);
1052            assert!(
1053                ComponentDependencies::check_disjoint(&a, &b).is_err(),
1054                "{a} should conflict with {b}",
1055            );
1056        }
1057    }
1058
1059    #[test]
1060    fn test_validate_dependencies() {
1061        // Specifying a dependency name as a plain-name without a package is an error
1062        assert!(
1063            ComponentDependencies::deserialize(toml! {
1064                "plain-name" = "0.1.0"
1065            })
1066            .unwrap()
1067            .validate()
1068            .is_err()
1069        );
1070
1071        // Specifying a dependency name as a plain-name without a package is an error
1072        assert!(
1073            ComponentDependencies::deserialize(toml! {
1074                "plain-name" = { version = "0.1.0" }
1075            })
1076            .unwrap()
1077            .validate()
1078            .is_err()
1079        );
1080
1081        // Specifying an export to satisfy a package dependency name is an error
1082        assert!(
1083            ComponentDependencies::deserialize(toml! {
1084                "foo:baz@0.1.0" = { path = "foo.wasm", export = "foo"}
1085            })
1086            .unwrap()
1087            .validate()
1088            .is_err()
1089        );
1090
1091        // Two compatible versions of the same package is an error
1092        assert!(
1093            ComponentDependencies::deserialize(toml! {
1094                "foo:baz@0.1.0" = "0.1.0"
1095                "foo:bar@0.2.1" = "0.2.1"
1096                "foo:bar@0.2.2" = "0.2.2"
1097            })
1098            .unwrap()
1099            .validate()
1100            .is_err()
1101        );
1102
1103        // Two disjoint versions of the same package is ok
1104        assert!(
1105            ComponentDependencies::deserialize(toml! {
1106                "foo:bar@0.1.0" = "0.1.0"
1107                "foo:bar@0.2.0" = "0.2.0"
1108                "foo:baz@0.2.0" = "0.1.0"
1109            })
1110            .unwrap()
1111            .validate()
1112            .is_ok()
1113        );
1114
1115        // Unversioned and versioned dependencies of the same package is an error
1116        assert!(
1117            ComponentDependencies::deserialize(toml! {
1118                "foo:bar@0.1.0" = "0.1.0"
1119                "foo:bar" = ">= 0.2.0"
1120            })
1121            .unwrap()
1122            .validate()
1123            .is_err()
1124        );
1125
1126        // Two interfaces of two disjoint versions of a package is ok
1127        assert!(
1128            ComponentDependencies::deserialize(toml! {
1129                "foo:bar/baz@0.1.0" = "0.1.0"
1130                "foo:bar/baz@0.2.0" = "0.2.0"
1131            })
1132            .unwrap()
1133            .validate()
1134            .is_ok()
1135        );
1136
1137        // A versioned interface and a different versioned package is ok
1138        assert!(
1139            ComponentDependencies::deserialize(toml! {
1140                "foo:bar/baz@0.1.0" = "0.1.0"
1141                "foo:bar@0.2.0" = "0.2.0"
1142            })
1143            .unwrap()
1144            .validate()
1145            .is_ok()
1146        );
1147
1148        // A versioned interface and package of the same version is an error
1149        assert!(
1150            ComponentDependencies::deserialize(toml! {
1151                "foo:bar/baz@0.1.0" = "0.1.0"
1152                "foo:bar@0.1.0" = "0.1.0"
1153            })
1154            .unwrap()
1155            .validate()
1156            .is_err()
1157        );
1158
1159        // A versioned interface and unversioned package is an error
1160        assert!(
1161            ComponentDependencies::deserialize(toml! {
1162                "foo:bar/baz@0.1.0" = "0.1.0"
1163                "foo:bar" = "0.1.0"
1164            })
1165            .unwrap()
1166            .validate()
1167            .is_err()
1168        );
1169
1170        // An unversioned interface and versioned package is an error
1171        assert!(
1172            ComponentDependencies::deserialize(toml! {
1173                "foo:bar/baz" = "0.1.0"
1174                "foo:bar@0.1.0" = "0.1.0"
1175            })
1176            .unwrap()
1177            .validate()
1178            .is_err()
1179        );
1180
1181        // An unversioned interface and unversioned package is an error
1182        assert!(
1183            ComponentDependencies::deserialize(toml! {
1184                "foo:bar/baz" = "0.1.0"
1185                "foo:bar" = "0.1.0"
1186            })
1187            .unwrap()
1188            .validate()
1189            .is_err()
1190        );
1191    }
1192
1193    fn normalized_component(
1194        manifest: &AppManifest,
1195        component: &str,
1196        profile: Option<&str>,
1197    ) -> Component {
1198        use crate::normalize::normalize_manifest;
1199
1200        let id =
1201            KebabId::try_from(component.to_owned()).expect("component ID should have been kebab");
1202
1203        let mut manifest = manifest.clone();
1204        normalize_manifest(&mut manifest, profile).expect("should have normalised");
1205        manifest
1206            .components
1207            .get(&id)
1208            .expect("should have compopnent with id profile-test")
1209            .clone()
1210    }
1211
1212    #[test]
1213    fn profiles_override_source() {
1214        let manifest = AppManifest::deserialize(toml! {
1215            spin_manifest_version = 2
1216            [application]
1217            name = "trigger-configs"
1218            [[trigger.fake]]
1219            component = "profile-test"
1220            [component.profile-test]
1221            source = "original"
1222            [component.profile-test.profile.fancy]
1223            source = "fancy-schmancy"
1224        })
1225        .expect("manifest should be valid");
1226
1227        let id = "profile-test";
1228
1229        let component = normalized_component(&manifest, id, None);
1230        assert!(matches!(&component.source, ComponentSource::Local(p) if p == "original"));
1231
1232        let component = normalized_component(&manifest, id, Some("fancy"));
1233        assert!(matches!(&component.source, ComponentSource::Local(p) if p == "fancy-schmancy"));
1234
1235        let component = normalized_component(&manifest, id, Some("non-existent"));
1236        assert!(matches!(&component.source, ComponentSource::Local(p) if p == "original"));
1237    }
1238
1239    #[test]
1240    fn profiles_override_build_command() {
1241        let manifest = AppManifest::deserialize(toml! {
1242            spin_manifest_version = 2
1243            [application]
1244            name = "trigger-configs"
1245            [[trigger.fake]]
1246            component = "profile-test"
1247            [component.profile-test]
1248            source = "original"
1249            build.command = "buildme --release"
1250            [component.profile-test.profile.fancy]
1251            source = "fancy-schmancy"
1252            build.command = ["buildme --fancy", "lintme"]
1253        })
1254        .expect("manifest should be valid");
1255
1256        let id = "profile-test";
1257
1258        let build = normalized_component(&manifest, id, None)
1259            .build
1260            .expect("should have default build");
1261        assert_eq!(1, build.commands().len());
1262        assert_eq!("buildme --release", build.commands().next().unwrap());
1263
1264        let build = normalized_component(&manifest, id, Some("fancy"))
1265            .build
1266            .expect("should have fancy build");
1267        assert_eq!(2, build.commands().len());
1268        assert_eq!("buildme --fancy", build.commands().next().unwrap());
1269        assert_eq!("lintme", build.commands().nth(1).unwrap());
1270
1271        let build = normalized_component(&manifest, id, Some("non-existent"))
1272            .build
1273            .expect("should fall back to default build");
1274        assert_eq!(1, build.commands().len());
1275        assert_eq!("buildme --release", build.commands().next().unwrap());
1276    }
1277
1278    #[test]
1279    fn profiles_can_have_build_command_when_default_doesnt() {
1280        let manifest = AppManifest::deserialize(toml! {
1281            spin_manifest_version = 2
1282            [application]
1283            name = "trigger-configs"
1284            [[trigger.fake]]
1285            component = "profile-test"
1286            [component.profile-test]
1287            source = "original"
1288            [component.profile-test.profile.fancy]
1289            source = "fancy-schmancy"
1290            build.command = ["buildme --fancy", "lintme"]
1291        })
1292        .expect("manifest should be valid");
1293
1294        let component = normalized_component(&manifest, "profile-test", None);
1295        assert!(component.build.is_none(), "shouldn't have default build");
1296
1297        let component = normalized_component(&manifest, "profile-test", Some("fancy"));
1298        assert!(component.build.is_some(), "should have fancy build");
1299
1300        let build = component.build.expect("should have fancy build");
1301
1302        assert_eq!(2, build.commands().len());
1303        assert_eq!("buildme --fancy", build.commands().next().unwrap());
1304        assert_eq!("lintme", build.commands().nth(1).unwrap());
1305    }
1306
1307    #[test]
1308    fn profiles_override_env_vars() {
1309        let manifest = AppManifest::deserialize(toml! {
1310            spin_manifest_version = 2
1311            [application]
1312            name = "trigger-configs"
1313            [[trigger.fake]]
1314            component = "profile-test"
1315            [component.profile-test]
1316            source = "original"
1317            environment = { DB_URL = "pg://production" }
1318            [component.profile-test.profile.fancy]
1319            environment = { DB_URL = "pg://fancy", FANCINESS = "1" }
1320        })
1321        .expect("manifest should be valid");
1322
1323        let id = "profile-test";
1324
1325        let component = normalized_component(&manifest, id, None);
1326
1327        assert_eq!(1, component.environment.len());
1328        assert_eq!(
1329            "pg://production",
1330            component
1331                .environment
1332                .get("DB_URL")
1333                .expect("DB_URL should have been set")
1334        );
1335
1336        let component = normalized_component(&manifest, id, Some("fancy"));
1337
1338        assert_eq!(2, component.environment.len());
1339        assert_eq!(
1340            "pg://fancy",
1341            component
1342                .environment
1343                .get("DB_URL")
1344                .expect("DB_URL should have been set")
1345        );
1346        assert_eq!(
1347            "1",
1348            component
1349                .environment
1350                .get("FANCINESS")
1351                .expect("FANCINESS should have been set")
1352        );
1353    }
1354
1355    #[test]
1356    fn profiles_dependencies() {
1357        let manifest = AppManifest::deserialize(toml! {
1358            spin_manifest_version = 2
1359            [application]
1360            name = "trigger-configs"
1361            [[trigger.fake]]
1362            component = "profile-test"
1363            [component.profile-test]
1364            source = "original"
1365            [component.profile-test.dependencies]
1366            "foo-bar" = "1.0.0"
1367            [component.profile-test.profile.fancy]
1368            dependencies = { "foo-bar" = { path = "local.wasm" }, "fancy-thing" = "1.2.3" }
1369        })
1370        .expect("manifest should be valid");
1371
1372        let id = "profile-test";
1373
1374        let component = normalized_component(&manifest, id, None);
1375
1376        assert_eq!(1, component.dependencies.inner.len());
1377        assert!(matches!(
1378            component
1379                .dependencies
1380                .inner
1381                .get(&DependencyName::Plain(KebabId::try_from("foo-bar".to_owned()).unwrap()))
1382                .expect("foo-bar dep should have been set"),
1383            ComponentDependency::Version(v) if v == "1.0.0",
1384        ));
1385
1386        let component = normalized_component(&manifest, id, Some("fancy"));
1387
1388        assert_eq!(2, component.dependencies.inner.len());
1389        assert!(matches!(
1390            component
1391                .dependencies
1392                .inner
1393                .get(&DependencyName::Plain(KebabId::try_from("foo-bar".to_owned()).unwrap()))
1394                .expect("foo-bar dep should have been set"),
1395            ComponentDependency::Local { path, .. } if path == &PathBuf::from("local.wasm"),
1396        ));
1397        assert!(matches!(
1398            component
1399                .dependencies
1400                .inner
1401                .get(&DependencyName::Plain(KebabId::try_from("fancy-thing".to_owned()).unwrap()))
1402                .expect("fancy-thing dep should have been set"),
1403            ComponentDependency::Version(v) if v == "1.2.3",
1404        ));
1405    }
1406}