Skip to main content

spin_locked_app/
locked.rs

1//! Spin lock file (spin.lock) serialization models.
2
3use std::{collections::HashSet, path::PathBuf};
4
5use itertools::Itertools;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use spin_serde::{DependencyName, FixedVersionBackwardCompatible};
9use std::collections::BTreeMap;
10
11use crate::{
12    metadata::MetadataExt,
13    values::{ValuesMap, ValuesMapBuilder},
14};
15
16/// A String-keyed map with deterministic serialization order.
17pub type LockedMap<T> = std::collections::BTreeMap<String, T>;
18
19/// If present and required in `host_requirements`, the host must support
20/// local service chaining (*.spin.internal) or reject the app.
21pub const SERVICE_CHAINING_KEY: &str = "local_service_chaining";
22
23/// Indicates that a host feature is optional. This is the default and is
24/// equivalent to omitting the feature from `host_requirements`.
25pub const HOST_REQ_OPTIONAL: &str = "optional";
26/// Indicates that a host feature is required.
27pub const HOST_REQ_REQUIRED: &str = "required";
28
29// TODO: it turns out that using an enum for this results in bad
30// errors by non-understanders (unknown variant rather than "I'm sorry
31// Dave I can't do that")
32/// Identifies fields in the LockedApp that the host must process if present.
33#[derive(Clone, Debug, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum MustUnderstand {
36    /// If present in `must_understand`, the host must support all features
37    /// in the app's `host_requirements` section.
38    HostRequirements,
39    /// If present in `must_understand`, the host must support all features
40    /// in components' `host_requirements` section.
41    ComponentHostRequirements,
42}
43
44/// A LockedApp represents a "fully resolved" Spin application.
45#[derive(Clone, Debug, Deserialize)]
46pub struct LockedApp {
47    /// Locked schema version
48    pub spin_lock_version: FixedVersionBackwardCompatible<1>,
49    /// Identifies fields in the LockedApp that the host must process if present.
50    #[serde(default, skip_serializing_if = "Vec::is_empty")]
51    pub must_understand: Vec<MustUnderstand>,
52    /// Application metadata
53    #[serde(default, skip_serializing_if = "ValuesMap::is_empty")]
54    pub metadata: ValuesMap,
55    /// Host requirements
56    #[serde(
57        default,
58        skip_serializing_if = "ValuesMap::is_empty",
59        deserialize_with = "deserialize_host_requirements"
60    )]
61    pub host_requirements: ValuesMap,
62    /// Custom config variables
63    #[serde(default, skip_serializing_if = "LockedMap::is_empty")]
64    pub variables: LockedMap<Variable>,
65    /// Application triggers
66    pub triggers: Vec<LockedTrigger>,
67    /// Application components
68    pub components: Vec<LockedComponent>,
69}
70
71fn deserialize_host_requirements<'de, D>(deserializer: D) -> Result<ValuesMap, D::Error>
72where
73    D: serde::Deserializer<'de>,
74{
75    struct HostRequirementsVisitor;
76    impl<'de> serde::de::Visitor<'de> for HostRequirementsVisitor {
77        type Value = ValuesMap;
78
79        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
80            formatter.write_str("struct ValuesMap")
81        }
82
83        fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
84        where
85            A: serde::de::MapAccess<'de>,
86        {
87            use serde::de::Error;
88
89            let mut hr = ValuesMapBuilder::new();
90
91            while let Some(key) = map.next_key::<String>()? {
92                let value: serde_json::Value = map.next_value()?;
93                if value.as_str() == Some(HOST_REQ_OPTIONAL) {
94                    continue;
95                }
96
97                hr.serializable(key, value).map_err(A::Error::custom)?;
98            }
99
100            Ok(hr.build())
101        }
102    }
103    let m = deserializer.deserialize_map(HostRequirementsVisitor)?;
104    let unsupported: Vec<_> = m
105        .keys()
106        .filter(|k| !SUPPORTED_HOST_REQS.contains(&k.as_str()))
107        .map(|k| k.to_string())
108        .collect();
109    if unsupported.is_empty() {
110        Ok(m)
111    } else {
112        let msg = format!(
113            "This version of Spin does not support the following features required by this application: {}",
114            unsupported.join(", ")
115        );
116        Err(serde::de::Error::custom(msg))
117    }
118}
119
120const SUPPORTED_HOST_REQS: &[&str] = &[SERVICE_CHAINING_KEY];
121
122impl Serialize for LockedApp {
123    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
124    where
125        S: serde::Serializer,
126    {
127        use serde::ser::SerializeStruct;
128
129        let version = if self.must_understand.is_empty() && self.host_requirements.is_empty() {
130            0
131        } else {
132            1
133        };
134
135        let mut la = serializer.serialize_struct("LockedApp", 7)?;
136        la.serialize_field("spin_lock_version", &version)?;
137        if !self.must_understand.is_empty() {
138            la.serialize_field("must_understand", &self.must_understand)?;
139        }
140        if !self.metadata.is_empty() {
141            la.serialize_field("metadata", &self.metadata)?;
142        }
143        if !self.host_requirements.is_empty() {
144            la.serialize_field("host_requirements", &self.host_requirements)?;
145        }
146        if !self.variables.is_empty() {
147            la.serialize_field("variables", &self.variables)?;
148        }
149        la.serialize_field("triggers", &self.triggers)?;
150        la.serialize_field("components", &self.components)?;
151        la.end()
152    }
153}
154
155impl LockedApp {
156    /// Deserializes a [`LockedApp`] from the given JSON data.
157    pub fn from_json(contents: &[u8]) -> serde_json::Result<Self> {
158        serde_json::from_slice(contents)
159    }
160
161    /// Serializes the [`LockedApp`] into JSON data.
162    pub fn to_json(&self) -> serde_json::Result<Vec<u8>> {
163        serde_json::to_vec_pretty(&self)
164    }
165
166    /// Deserializes typed metadata for this app.
167    ///
168    /// Returns `Ok(None)` if there is no metadata for the given `key` and an
169    /// `Err` only if there _is_ a value for the `key` but the typed
170    /// deserialization failed.
171    pub fn get_metadata<'this, T: Deserialize<'this>>(
172        &'this self,
173        key: crate::MetadataKey<T>,
174    ) -> crate::Result<Option<T>> {
175        self.metadata.get_typed(key)
176    }
177
178    /// Deserializes typed metadata for this app.
179    ///
180    /// Like [`LockedApp::get_metadata`], but returns an error if there is
181    /// no metadata for the given `key`.
182    pub fn require_metadata<'this, T: Deserialize<'this>>(
183        &'this self,
184        key: crate::MetadataKey<T>,
185    ) -> crate::Result<T> {
186        self.metadata.require_typed(key)
187    }
188
189    /// Checks that the application does not have any host requirements
190    /// outside the supported set. The error case returns a comma-separated
191    /// list of unmet requirements.
192    pub fn ensure_needs_only(&self, trigger_type: &str, supported: &[&str]) -> Result<(), String> {
193        let app_host_requirements = self.host_requirements.keys();
194
195        let component_ids = self
196            .triggers
197            .iter()
198            .filter(|t| t.trigger_type == trigger_type)
199            .flat_map(|t| t.trigger_config.get("component"))
200            .filter_map(|v| v.as_str())
201            .collect::<HashSet<_>>();
202        let components = self
203            .components
204            .iter()
205            .filter(|c| component_ids.contains(c.id.as_str()));
206        let component_host_requirements = components.flat_map(|c| c.host_requirements.keys());
207
208        let all_host_requirements = app_host_requirements.chain(component_host_requirements);
209
210        let unmet_requirements = all_host_requirements
211            .unique()
212            .filter(|hr| !supported.contains(&hr.as_str()))
213            .map(|s| s.to_string())
214            .collect::<Vec<_>>();
215        if unmet_requirements.is_empty() {
216            Ok(())
217        } else {
218            let message = unmet_requirements.join(", ");
219            Err(message)
220        }
221    }
222}
223
224/// A LockedComponent represents a "fully resolved" Spin component.
225#[derive(Clone, Debug, Serialize, Deserialize)]
226pub struct LockedComponent {
227    /// Application-unique component identifier
228    pub id: String,
229    /// Component metadata
230    #[serde(default, skip_serializing_if = "ValuesMap::is_empty")]
231    pub metadata: ValuesMap,
232    /// Wasm source
233    pub source: LockedComponentSource,
234    /// WASI environment variables
235    #[serde(default, skip_serializing_if = "LockedMap::is_empty")]
236    pub env: LockedMap<String>,
237    /// WASI filesystem contents
238    #[serde(default, skip_serializing_if = "Vec::is_empty")]
239    pub files: Vec<ContentPath>,
240    /// Custom config values
241    #[serde(default, skip_serializing_if = "LockedMap::is_empty")]
242    pub config: LockedMap<String>,
243    /// Component dependencies
244    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
245    pub dependencies: BTreeMap<DependencyName, LockedComponentDependency>,
246    /// Host requirements
247    #[serde(
248        default,
249        skip_serializing_if = "ValuesMap::is_empty",
250        deserialize_with = "deserialize_host_requirements"
251    )]
252    pub host_requirements: ValuesMap,
253}
254
255/// A LockedDependency represents a "fully resolved" Spin component dependency.
256#[derive(Clone, Debug, Serialize, Deserialize)]
257pub struct LockedComponentDependency {
258    /// Locked dependency source
259    pub source: LockedComponentSource,
260    /// The specific export to use from the dependency, if any.
261    pub export: Option<String>,
262    /// Which configurations to inherit from parent
263    #[serde(default, skip_serializing_if = "InheritConfiguration::is_none")]
264    pub inherit: InheritConfiguration,
265}
266
267/// InheritConfiguration specifies which configurations to inherit from parent.
268#[derive(Clone, Debug, Serialize, Deserialize)]
269pub enum InheritConfiguration {
270    /// Dependencies will inherit all configurations from parent.
271    All,
272    /// Dependencies will inherit only the specified configurations from parent
273    /// (if empty then deny-all is enforced).
274    Some(Vec<String>),
275}
276
277impl Default for InheritConfiguration {
278    fn default() -> Self {
279        InheritConfiguration::Some(vec![])
280    }
281}
282
283impl InheritConfiguration {
284    fn is_none(&self) -> bool {
285        matches!(self, InheritConfiguration::Some(configs) if configs.is_empty())
286    }
287}
288
289/// A LockedComponentSource specifies a Wasm source.
290#[derive(Clone, Debug, Serialize, Deserialize)]
291pub struct LockedComponentSource {
292    /// Wasm source content type (e.g. "application/wasm")
293    pub content_type: String,
294    /// Wasm source content specification
295    #[serde(flatten)]
296    pub content: ContentRef,
297}
298
299/// A ContentPath specifies content mapped to a WASI path.
300#[derive(Clone, Debug, Serialize, Deserialize)]
301pub struct ContentPath {
302    /// Content specification
303    #[serde(flatten)]
304    pub content: ContentRef,
305    /// WASI mount path
306    pub path: PathBuf,
307}
308
309/// A ContentRef represents content used by an application.
310///
311/// At least one of `source`, `inline`, or `digest` must be specified. Implementations may
312/// require one or the other (or both).
313#[derive(Clone, Debug, Default, Serialize, Deserialize)]
314pub struct ContentRef {
315    /// A URI where the content can be accessed. Implementations may support
316    /// different URI schemes.
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub source: Option<String>,
319    /// The content itself, base64-encoded.
320    ///
321    /// NOTE: This is both an optimization for small content and a workaround
322    /// for certain OCI implementations that don't support 0 or 1 byte blobs.
323    #[serde(
324        default,
325        skip_serializing_if = "Option::is_none",
326        with = "spin_serde::base64"
327    )]
328    pub inline: Option<Vec<u8>>,
329    /// If set, the content must have the given SHA-256 digest.
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub digest: Option<String>,
332}
333
334/// A LockedTrigger specifies configuration for an application trigger.
335#[derive(Clone, Debug, Serialize, Deserialize)]
336pub struct LockedTrigger {
337    /// Application-unique trigger identifier
338    pub id: String,
339    /// Trigger type (e.g. "http")
340    pub trigger_type: String,
341    /// Trigger-type-specific configuration
342    pub trigger_config: Value,
343}
344
345/// A Variable specifies a custom configuration variable.
346#[derive(Clone, Debug, Serialize, Deserialize)]
347pub struct Variable {
348    /// A brief description of the variable.
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub description: Option<String>,
351    /// The variable's default value. If unset, the variable is required.
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub default: Option<String>,
354    /// If set, the variable's value may be sensitive and e.g. shouldn't be logged.
355    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
356    pub secret: bool,
357}
358
359#[cfg(test)]
360mod test {
361    use super::*;
362
363    use crate::values::ValuesMapBuilder;
364
365    #[test]
366    fn locked_app_with_no_host_reqs_serialises_as_v0_and_v0_deserialises_as_v1() {
367        let locked_app = LockedApp {
368            spin_lock_version: Default::default(),
369            must_understand: Default::default(),
370            metadata: Default::default(),
371            host_requirements: Default::default(),
372            variables: Default::default(),
373            triggers: Default::default(),
374            components: Default::default(),
375        };
376
377        let json = locked_app.to_json().unwrap();
378
379        assert!(String::from_utf8_lossy(&json).contains(r#""spin_lock_version": 0"#));
380
381        let reloaded = LockedApp::from_json(&json).unwrap();
382
383        assert_eq!(1, Into::<usize>::into(reloaded.spin_lock_version));
384    }
385
386    #[test]
387    fn locked_app_with_host_reqs_serialises_as_v1() {
388        let mut host_requirements = ValuesMapBuilder::new();
389        host_requirements.string(SERVICE_CHAINING_KEY, "bar");
390        let host_requirements = host_requirements.build();
391
392        let locked_app = LockedApp {
393            spin_lock_version: Default::default(),
394            must_understand: vec![MustUnderstand::HostRequirements],
395            metadata: Default::default(),
396            host_requirements,
397            variables: Default::default(),
398            triggers: Default::default(),
399            components: Default::default(),
400        };
401
402        let json = locked_app.to_json().unwrap();
403
404        assert!(String::from_utf8_lossy(&json).contains(r#""spin_lock_version": 1"#));
405
406        let reloaded = LockedApp::from_json(&json).unwrap();
407
408        assert_eq!(1, Into::<usize>::into(reloaded.spin_lock_version));
409        assert_eq!(1, reloaded.must_understand.len());
410        assert_eq!(1, reloaded.host_requirements.len());
411    }
412
413    #[test]
414    fn deserialising_ignores_unknown_fields() {
415        use serde_json::json;
416        let j = serde_json::to_vec_pretty(&json!({
417            "spin_lock_version": 1,
418            "triggers": [],
419            "components": [],
420            "never_create_field_with_this_name": 123
421        }))
422        .unwrap();
423        let locked = LockedApp::from_json(&j).unwrap();
424        assert_eq!(0, locked.triggers.len());
425    }
426
427    #[test]
428    fn deserialising_does_not_ignore_must_understand_unknown_fields() {
429        use serde_json::json;
430        let j = serde_json::to_vec_pretty(&json!({
431            "spin_lock_version": 1,
432            "must_understand": vec!["never_create_field_with_this_name"],
433            "triggers": [],
434            "components": [],
435            "never_create_field_with_this_name": 123
436        }))
437        .unwrap();
438        let err = LockedApp::from_json(&j).expect_err(
439            "Should have refused to deserialise due to non-understood must-understand field",
440        );
441        assert!(
442            err.to_string()
443                .contains("never_create_field_with_this_name")
444        );
445    }
446
447    #[test]
448    fn deserialising_accepts_must_understands_that_it_does_understand() {
449        use serde_json::json;
450        let j = serde_json::to_vec_pretty(&json!({
451            "spin_lock_version": 1,
452            "must_understand": vec!["host_requirements"],
453            "host_requirements": {
454                SERVICE_CHAINING_KEY: HOST_REQ_REQUIRED,
455            },
456            "triggers": [],
457            "components": [],
458            "never_create_field_with_this_name": 123
459        }))
460        .unwrap();
461        let locked = LockedApp::from_json(&j).unwrap();
462        assert_eq!(1, locked.must_understand.len());
463        assert_eq!(1, locked.host_requirements.len());
464    }
465
466    #[test]
467    fn deserialising_rejects_host_requirements_that_are_not_supported() {
468        use serde_json::json;
469        let j = serde_json::to_vec_pretty(&json!({
470            "spin_lock_version": 1,
471            "must_understand": vec!["host_requirements"],
472            "host_requirements": {
473                SERVICE_CHAINING_KEY: HOST_REQ_REQUIRED,
474                "accelerated_spline_reticulation": HOST_REQ_REQUIRED
475            },
476            "triggers": [],
477            "components": []
478        }))
479        .unwrap();
480        let err = LockedApp::from_json(&j).expect_err(
481            "Should have refused to deserialise due to non-understood host requirement",
482        );
483        assert!(err.to_string().contains("accelerated_spline_reticulation"));
484    }
485
486    #[test]
487    fn deserialising_skips_optional_host_requirements() {
488        use serde_json::json;
489        let j = serde_json::to_vec_pretty(&json!({
490            "spin_lock_version": 1,
491            "must_understand": vec!["host_requirements"],
492            "host_requirements": {
493                SERVICE_CHAINING_KEY: HOST_REQ_REQUIRED,
494                "accelerated_spline_reticulation": HOST_REQ_OPTIONAL
495            },
496            "triggers": [],
497            "components": []
498        }))
499        .unwrap();
500        let locked = LockedApp::from_json(&j).unwrap();
501        assert_eq!(1, locked.must_understand.len());
502        assert_eq!(1, locked.host_requirements.len());
503    }
504}