scl_lib/api_objects/
volume.rs

1// SPDX-License-Identifier: EUPL-1.2
2use crate::api_objects::{
3    Error, MetaData, Result, SclName, SclObject, SeparationContext, TransitionError, Url,
4};
5use serde::{Deserialize, Serialize};
6use std::path::{Path, PathBuf};
7
8#[cfg(feature = "openapi")]
9use schemars::JsonSchema;
10
11#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
12#[cfg_attr(feature = "openapi", derive(JsonSchema))]
13#[serde(rename_all = "camelCase")]
14pub struct Volume {
15    #[serde(flatten)]
16    pub metadata: MetaData,
17
18    /// Separation context which the volume is belonging to.
19    pub separation_context: SclName,
20
21    /// Size in mebibyte reserved for the volume.
22    #[cfg_attr(feature = "openapi", validate(range(min = 1)))]
23    #[serde(rename = "sizeMiB")]
24    pub size_mib: u64,
25
26    /// Latest status of the volume. Does not need to be specified by the user when
27    /// creating (POST), as it is initialized with the default values.
28    #[serde(default)]
29    pub status: VolumeStatus,
30
31    /// Optional URL pointing to initial data that should be copied into the new volume.
32    /// Make sure that the `size_mib` is at least as large as the required disk space.
33    pub url: Option<Url>,
34}
35
36impl Volume {
37    /// Returns the path where the volume file should be stored.
38    ///
39    /// Convenience wrapper around [derive_volume_file_path], see its documentation for more information.
40    ///
41    /// # Example
42    ///
43    /// ```
44    /// use crate::scl_lib::api_objects::{MetaData, SclName, Volume, derive_volume_file_path};
45    /// use std::path::Path;
46    /// use scl_lib::api_objects::SclObject;
47    ///
48    /// let vol = Volume {
49    ///     separation_context: SclName::try_from("sc01".to_string()).unwrap(),
50    ///     metadata: MetaData::new(SclName::try_from("vol01".to_string()).unwrap()),
51    ///     size_mib: 100,
52    ///     status: Default::default(),
53    ///     url: None,
54    /// };
55    /// let root = Path::new("/tmp/scl");
56    /// assert_eq!(vol.file_path(&root).as_path(), Path::new("/tmp/scl/sc01/volumes/vol01"));
57    /// assert_eq!(vol.file_path(&root), derive_volume_file_path(&root, &vol.separation_context, vol.name()))
58    /// ```
59    pub fn file_path(&self, root: &Path) -> PathBuf {
60        derive_volume_file_path(root, &self.separation_context, self.name())
61    }
62}
63
64/// Returns the path where a volume file should be stored.
65///
66/// Controllers interacting with the volume file must ensure a common understanding
67/// about `root`.
68///
69/// ```
70/// use crate::scl_lib::api_objects::{SclName, Volume, derive_volume_file_path};
71/// use std::path::Path;
72///
73/// let root = Path::new("/tmp/scl");
74/// let sc_name = SclName::try_from("sc01".to_string()).unwrap();
75/// let vol_name =  SclName::try_from("vol01".to_string()).unwrap();
76/// assert_eq!(derive_volume_file_path(&root, &sc_name, &vol_name), Path::new("/tmp/scl/sc01/volumes/vol01"));
77/// ```
78pub fn derive_volume_file_path(root: &Path, sc_name: &SclName, vol_name: &SclName) -> PathBuf {
79    let mut path = PathBuf::from(root);
80    path.push(format!("{}", sc_name));
81    path.push("volumes");
82    path.push(format!("{}", vol_name));
83    path
84}
85
86#[derive(Clone, Debug, Default, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
87#[cfg_attr(feature = "openapi", derive(JsonSchema))]
88#[serde(rename_all = "camelCase")]
89pub enum VolumeStatus {
90    /// The volume needs to be initialized by the controller.
91    #[default]
92    Pending,
93
94    /// The volume was successfully initialized and is ready to use / already in use.
95    Active,
96
97    /// An error occurred during or after initialization. Possible reasons are for example:
98    ///
99    /// - insufficient disk space,
100    /// - the provided `url` could not be reached,
101    /// - the size of the initial data (via `url`) exceeded the `size_mib`.
102    Failed(String),
103}
104
105impl VolumeStatus {
106    fn transition_is_valid(old: &Self, new: &Self) -> bool {
107        use VolumeStatus::*;
108        matches!(
109            (old, new),
110            (&Pending, &Active) | (&Pending, &Failed(_)) | (&Active, &Failed(_))
111        )
112    }
113}
114
115impl SclObject for Volume {
116    /// Prefix for all [Volume]s (`"/volumes"`).
117    const PREFIX: &'static str = "/volumes";
118
119    fn name(&self) -> &SclName {
120        &self.metadata.name
121    }
122
123    fn separation_context(&self) -> Option<&SclName> {
124        Some(&self.separation_context)
125    }
126
127    fn metadata(&self) -> &MetaData {
128        &self.metadata
129    }
130
131    fn metadata_mut(&mut self) -> &mut MetaData {
132        &mut self.metadata
133    }
134
135    fn referenced_db_keys(&self) -> Vec<String> {
136        vec![SeparationContext::db_key(
137            self.separation_context.as_str(),
138            None,
139        )]
140    }
141
142    fn validate_fields_before_create(&self) -> Result<()> {
143        self.metadata.validate_fields_before_create()?;
144
145        if self.status != VolumeStatus::default() {
146            return Err(Error::IllegalInitialValue(
147                "Invalid initial volume status!".to_string(),
148            ));
149        }
150
151        if self.size_mib < 1 {
152            return Err(Error::IllegalInitialValue(
153                "Volume size must be greater than 0 MB!".to_string(),
154            ));
155        }
156
157        // No need to check the url field as the reachability or content size
158        // could change and need to be checked by the controller anyways.
159
160        Ok(())
161    }
162
163    fn validate_fields_before_update(
164        current_db_state: &Self,
165        proposed_new_state: &Self,
166    ) -> Result<()> {
167        MetaData::validate_fields_before_regular_update(
168            &current_db_state.metadata,
169            &proposed_new_state.metadata,
170        )?;
171
172        if current_db_state.separation_context != proposed_new_state.separation_context {
173            return Err(TransitionError::Other(
174                "Field separation_context may not be changed!".to_string(),
175            )
176            .into());
177        }
178
179        if current_db_state.size_mib != proposed_new_state.size_mib {
180            return Err(
181                TransitionError::Other("Field size may not be changed!".to_string()).into(),
182            );
183        }
184
185        if current_db_state.url != proposed_new_state.url {
186            return Err(TransitionError::Other("Field url may not be changed!".to_string()).into());
187        }
188
189        if !VolumeStatus::transition_is_valid(&current_db_state.status, &proposed_new_state.status)
190        {
191            return Err(TransitionError::Other(format!(
192                "Invalid status transition: Old({:?}) / New({:?})",
193                &current_db_state.status, &proposed_new_state.status
194            ))
195            .into());
196        }
197
198        Ok(())
199    }
200}
201
202#[cfg(test)]
203mod test {
204    use crate::api_objects::test::{
205        detect_invalid_metadata_create_mutations, detect_invalid_metadata_update_mutations,
206        detect_invalid_update_mutations, parse_json_dir,
207    };
208    use crate::api_objects::{MetaData, SclName, SclObject, Url, Volume, VolumeStatus};
209    use std::path::Path;
210
211    #[test]
212    fn parse_sample_json() {
213        parse_json_dir::<Volume>(Path::new("../test/sample_json/volumes"));
214    }
215
216    fn volume_status_transition_test_data() -> Vec<(VolumeStatus, VolumeStatus, bool)> {
217        use VolumeStatus::*;
218        vec![
219            (Pending, Pending, false),
220            (Pending, Active, true),
221            (Pending, Failed("foo".into()), true),
222            (Active, Pending, false),
223            (Active, Active, false),
224            (Active, Failed("foo".into()), true),
225            (Failed("foo".into()), Pending, false),
226            (Failed("foo".into()), Active, false),
227            (Failed("foo".into()), Failed("foo".into()), false),
228        ]
229    }
230
231    #[test]
232    fn test_volume_status_transition() {
233        for (old, new, expected_result) in volume_status_transition_test_data() {
234            assert_eq!(
235                VolumeStatus::transition_is_valid(&old, &new),
236                expected_result
237            );
238        }
239    }
240
241    fn example_volume() -> Volume {
242        Volume {
243            metadata: MetaData::new(SclName::try_from("example".to_string()).unwrap()),
244            separation_context: SclName::try_from("example".to_string()).unwrap(),
245            size_mib: 1,
246            status: Default::default(),
247            url: None,
248        }
249    }
250
251    #[test]
252    fn validate_fields_before_create() {
253        let mut vol = example_volume();
254        assert!(vol.validate_fields_before_create().is_ok());
255
256        vol.status = VolumeStatus::Active;
257        assert!(vol.validate_fields_before_create().is_err()); // Invalid initial state.
258
259        let mut vol = example_volume();
260        vol.size_mib = 0;
261        assert!(vol.validate_fields_before_create().is_err()); // Size must be greater than 0.
262    }
263
264    #[test]
265    fn validate_fields_before_update() {
266        let mut current = example_volume();
267        let mut proposed = example_volume();
268        proposed.status = VolumeStatus::Active;
269        assert!(Volume::validate_fields_before_update(&current, &proposed).is_ok());
270
271        #[allow(clippy::type_complexity)]
272        let invalid_mutations: Vec<Box<dyn Fn(&mut Volume)>> = vec![
273            Box::new(|t| t.separation_context = SclName::try_from("different").unwrap()),
274            Box::new(|t| t.size_mib = 9999),
275            Box::new(|t| t.url = Some(Url::try_from("http://d3tn.com").unwrap())),
276        ];
277
278        detect_invalid_update_mutations(&current, &proposed, invalid_mutations);
279
280        // Check that status transition validation is checked.
281        for (old, new, expected_result) in volume_status_transition_test_data() {
282            current.status = old;
283            proposed.status = new;
284            assert_eq!(
285                Volume::validate_fields_before_update(&current, &proposed).is_ok(),
286                expected_result
287            );
288        }
289    }
290
291    #[test]
292    fn detect_invalid_metadata_mutations() {
293        let t = example_volume();
294        detect_invalid_metadata_create_mutations(&t);
295        detect_invalid_metadata_update_mutations(&t);
296    }
297}