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