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