scl_lib/api_objects/
virtual_machine.rs

1// SPDX-License-Identifier: EUPL-1.2
2use crate::api_objects::{
3    Error, MetaData, Node, Resources, Result, SclName, SclObject, SeparationContext,
4    TransitionError, Url, Volume,
5};
6use crate::LogError;
7use serde::{Deserialize, Serialize};
8use std::str::FromStr;
9
10#[cfg(feature = "openapi")]
11use schemars::JsonSchema;
12
13#[derive(Clone, Debug, Default, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
14#[cfg_attr(feature = "openapi", derive(JsonSchema))]
15#[serde(rename_all = "camelCase")]
16pub enum TargetVmStatus {
17    #[default]
18    Running,
19    Paused,
20    Stopped,
21}
22
23impl FromStr for TargetVmStatus {
24    type Err = String;
25
26    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
27        use TargetVmStatus::*;
28        match s {
29            "running" => Ok(Running),
30            "paused" => Ok(Paused),
31            "stopped" => Ok(Stopped),
32            _ => Err("Valid variants are: running, paused, stopped.".to_string()),
33        }
34    }
35}
36
37// Important: Make sure to update `deny_illegal_field_changes` when new fields are added.
38#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
39#[cfg_attr(feature = "openapi", derive(JsonSchema))]
40#[serde(rename_all = "camelCase")]
41pub struct VirtualMachine {
42    #[serde(flatten)]
43    pub metadata: MetaData,
44    pub separation_context: SclName,
45    pub spec: VmSpec,
46    /// Latest status of the virtual machine. Does not need to be specified by the user when
47    /// creating (POST), as it is initialized with the default values.
48    #[serde(default)]
49    pub status: VmStatus,
50}
51
52use std::cmp::Ordering;
53
54impl Ord for VirtualMachine {
55    fn cmp(&self, other: &Self) -> Ordering {
56        (&self.separation_context, &self.metadata.name)
57            .cmp(&(&other.separation_context, &other.metadata.name))
58    }
59}
60
61impl PartialOrd for VirtualMachine {
62    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
63        Some(self.cmp(other))
64    }
65}
66
67impl SclObject for VirtualMachine {
68    /// Prefix for all [VirtualMachine]s (`"/vms"`).
69    const PREFIX: &'static str = "/vms";
70
71    fn name(&self) -> &SclName {
72        &self.metadata.name
73    }
74
75    fn separation_context(&self) -> Option<&SclName> {
76        Some(&self.separation_context)
77    }
78
79    fn metadata(&self) -> &MetaData {
80        &self.metadata
81    }
82
83    fn metadata_mut(&mut self) -> &mut MetaData {
84        &mut self.metadata
85    }
86
87    fn referenced_db_keys(&self) -> Vec<String> {
88        use VmStatus::*;
89
90        let mut references = vec![SeparationContext::db_key(
91            self.separation_context.as_str(),
92            None,
93        )];
94
95        if let BootVolume::Volume(ref vol) = self.spec.boot_volume {
96            references.push(Volume::db_key(
97                self.separation_context.as_str(),
98                vol.as_str(),
99            ))
100        }
101
102        match &self.status {
103            NotAssigned(_) => references,
104            Prepared(c) | Scheduled(c) | Running(c) | Paused(c) | Stopped(c) | Error(c) => {
105                let node_key = Node::db_key(None, c.assigned_node.as_str());
106                references.push(node_key);
107                references
108            }
109        }
110    }
111
112    fn validate_fields_before_create(&self) -> Result<()> {
113        self.metadata.validate_fields_before_create()?;
114
115        if self.status != VmStatus::default() {
116            return Err(Error::IllegalInitialValue(
117                "Invalid initial VM status!".to_string(),
118            ));
119        }
120
121        if self.spec.target_state != TargetVmStatus::default() {
122            return Err(Error::IllegalInitialValue(
123                "Invalid initial VM target state!".to_string(),
124            ));
125        }
126
127        if !self.spec.resources.all_resources_are_greater_than_zero() {
128            return Err(Error::IllegalInitialValue(
129                "All resources must be greater than 0!".to_string(),
130            ));
131        }
132
133        Ok(())
134    }
135
136    fn validate_fields_before_update(
137        current_db_state: &Self,
138        proposed_new_state: &Self,
139    ) -> Result<()> {
140        MetaData::validate_fields_before_regular_update(
141            &current_db_state.metadata,
142            &proposed_new_state.metadata,
143        )?;
144
145        if current_db_state.separation_context != proposed_new_state.separation_context
146            || current_db_state.spec.resources != proposed_new_state.spec.resources
147            || current_db_state.spec.boot_volume != proposed_new_state.spec.boot_volume
148            || current_db_state.spec.network_device_name
149                != proposed_new_state.spec.network_device_name
150            || current_db_state.spec.cloud_init_config != proposed_new_state.spec.cloud_init_config
151        {
152            return Err(TransitionError::Other(
153                "Only vm.status and vm.spec.target_state fields may be updated!".to_string(),
154            )
155            .into());
156        }
157
158        if let (VmStatus::NotAssigned(_), VmStatus::Scheduled(cond)) =
159            (&current_db_state.status, &proposed_new_state.status)
160        {
161            if current_db_state.spec.resources != cond.reserved_resources {
162                return Err(TransitionError::Other(
163                    "Reserved resources must match specified resources".to_string(),
164                )
165                .into());
166            }
167
168            if !cond
169                .reserved_resources
170                .all_resources_are_greater_than_zero()
171            {
172                // Given that the specified resources and reserved resources are equal,
173                // it was possible to insert vcpu / ram_mib values with 0, which must not happen.
174                return Err(Error::Application);
175            }
176        }
177
178        validate_status_transition(&current_db_state.status, &proposed_new_state.status).log_err()
179    }
180}
181
182#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
183#[cfg_attr(feature = "openapi", derive(JsonSchema))]
184#[serde(rename_all = "camelCase")]
185pub enum VmStatus {
186    NotAssigned(NotAssignedStatus),
187    Prepared(VmCondition),
188    Scheduled(VmCondition),
189    Running(VmCondition),
190    Paused(VmCondition),
191    Stopped(VmCondition),
192    Error(VmCondition),
193}
194
195impl VmStatus {
196    pub fn condition(&self) -> Option<&VmCondition> {
197        use VmStatus::*;
198        match self {
199            Scheduled(c) | Prepared(c) | Running(c) | Paused(c) | Stopped(c) | Error(c) => Some(c),
200            NotAssigned(_) => None,
201        }
202    }
203
204    pub fn condition_mut(&mut self) -> Option<&mut VmCondition> {
205        use VmStatus::*;
206        match self {
207            Scheduled(c) | Prepared(c) | Running(c) | Paused(c) | Stopped(c) | Error(c) => Some(c),
208            NotAssigned(_) => None,
209        }
210    }
211}
212
213#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
214#[cfg_attr(feature = "openapi", derive(JsonSchema))]
215#[serde(rename_all = "camelCase")]
216pub enum NotAssignedStatus {
217    Pending,
218    InsufficientResources,
219}
220
221#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
222#[cfg_attr(feature = "openapi", derive(JsonSchema))]
223#[serde(rename_all = "camelCase")]
224pub struct VmCondition {
225    pub reserved_resources: Resources,
226    pub assigned_node: SclName,
227    pub transition_info: Option<TransitionInfo>,
228}
229
230#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
231#[cfg_attr(feature = "openapi", derive(JsonSchema))]
232#[serde(rename_all = "camelCase")]
233pub struct TransitionInfo {
234    pub transition_time: u64,      // TODO do we need to change this?
235    pub previous_vm_state: String, // Don't use `VmStatus` here in order to prevent infinite recursion.
236    pub error_description: String, // Like "VM won't boot". TODO use proper error type instead.
237}
238
239impl Default for VmStatus {
240    fn default() -> Self {
241        VmStatus::NotAssigned(NotAssignedStatus::Pending)
242    }
243}
244
245#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
246#[cfg_attr(feature = "openapi", derive(JsonSchema))]
247#[serde(rename_all = "camelCase")]
248pub struct LocalStorage {
249    /// Size in mebibyte reserved for the volume.
250    #[cfg_attr(feature = "openapi", validate(range(min = 1)))]
251    #[serde(rename = "sizeMiB")]
252    pub size_mib: u64,
253    // URL pointing to initial data that should be copied into the new volume.
254    /// Make sure that the `size_mib` is at least as large as the required disk space.
255    pub image: Url,
256}
257
258#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
259#[cfg_attr(feature = "openapi", derive(JsonSchema))]
260#[serde(rename_all = "camelCase")]
261pub enum BootVolume {
262    Volume(SclName),
263    Local(LocalStorage),
264}
265
266#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
267#[cfg_attr(feature = "openapi", derive(JsonSchema))]
268#[serde(rename_all = "camelCase")]
269pub struct VmSpec {
270    pub resources: Resources,
271    #[serde(default)]
272    pub target_state: TargetVmStatus,
273    pub boot_volume: BootVolume,
274    pub network_device_name: String,
275    pub cloud_init_config: Option<serde_json::Value>,
276}
277
278/// Does **not** consider additional context (e.g, specified resources vs. reserved resources
279/// in the [VmCondition]).
280fn validate_status_transition(old_state: &VmStatus, new_state: &VmStatus) -> Result<()> {
281    use NotAssignedStatus::*;
282    use VmStatus::*;
283    match (old_state, new_state) {
284        (NotAssigned(_), Scheduled(_))
285        | (NotAssigned(Pending), NotAssigned(InsufficientResources))
286        | (Scheduled(_), NotAssigned(Pending))
287        | (Stopped(_), NotAssigned(Pending)) => Ok(()),
288
289        // Assigned node and reserved resources must stay the same.
290        (Scheduled(a), Prepared(b))
291        | (Scheduled(a), Scheduled(b))
292        | (Prepared(a), Running(b))
293        | (Prepared(a), Prepared(b))
294        | (Running(a), Paused(b))
295        | (Running(a), Stopped(b))
296        | (Running(a), Running(b))
297        | (Paused(a), Stopped(b))
298        | (Paused(a), Running(b))
299        | (Paused(a), Paused(b))
300        | (Stopped(a), Running(b))
301        | (Stopped(a), Stopped(b))
302            if a.assigned_node == b.assigned_node
303                && a.reserved_resources == b.reserved_resources =>
304        {
305            Ok(())
306        }
307        (_, Error(_)) => Ok(()),
308        (_, _) => Err(TransitionError::Other(format!(
309            "Invalid transition: Old({:?}) / New({:?})",
310            old_state, new_state
311        ))
312        .into()),
313    }
314}
315
316#[cfg(test)]
317mod test {
318    use super::{TargetVmStatus, VirtualMachine, VmSpec};
319    use crate::api_objects::test::{
320        detect_invalid_create_mutations, detect_invalid_metadata_create_mutations,
321        detect_invalid_metadata_update_mutations, detect_invalid_update_mutations, parse_json_dir,
322    };
323    use crate::api_objects::virtual_machine::validate_status_transition;
324    use crate::api_objects::{
325        BootVolume, MetaData, Node, NotAssignedStatus, Resources, SclName, SclObject,
326        SeparationContext, VmCondition, VmStatus, Volume,
327    };
328    use std::path::Path;
329
330    #[test]
331    fn parse_sample_json() {
332        parse_json_dir::<VirtualMachine>(Path::new("../test/sample_json/vms"));
333    }
334
335    fn example_vm() -> VirtualMachine {
336        VirtualMachine {
337            metadata: MetaData::new(SclName::try_from("example").unwrap()),
338            separation_context: SclName::try_from("sc-123").unwrap(),
339            spec: VmSpec {
340                resources: Resources {
341                    vcpu: 10,
342                    ram_mib: 5000,
343                },
344                boot_volume: BootVolume::Volume(SclName::try_from("vol-333").unwrap()),
345                target_state: TargetVmStatus::default(),
346                network_device_name: "tapvm".to_string(),
347                cloud_init_config: None,
348            },
349            status: Default::default(),
350        }
351    }
352
353    fn example_condition() -> VmCondition {
354        VmCondition {
355            reserved_resources: Resources {
356                vcpu: 1,
357                ram_mib: 1,
358            },
359            assigned_node: SclName::try_from("some-node").unwrap(),
360            transition_info: None,
361        }
362    }
363
364    #[test]
365    fn metadata_references() {
366        use std::convert::TryFrom;
367        let mut vm = example_vm();
368        let mut expected_refs = vec![
369            SeparationContext::db_key(None, "sc-123"),
370            Volume::db_key("sc-123", "vol-333"),
371        ];
372        assert_eq!(vm.referenced_db_keys(), expected_refs);
373
374        vm.status = VmStatus::Scheduled(VmCondition {
375            reserved_resources: Default::default(),
376            assigned_node: SclName::try_from("node-456").unwrap(),
377            transition_info: None,
378        });
379
380        expected_refs.push(Node::db_key(None, "node-456"));
381        assert_eq!(vm.referenced_db_keys(), expected_refs);
382    }
383
384    /// Does not consider any [VmCondition] details.
385    #[test]
386    fn test_validate_status_transition() {
387        use NotAssignedStatus::{InsufficientResources as IR, Pending as P}; // Better formatting.
388        use VmStatus::*;
389
390        let cond = example_condition();
391        let inputs: Vec<(VmStatus, VmStatus, bool)> = vec![
392            (NotAssigned(P), NotAssigned(P), false),
393            (NotAssigned(P), NotAssigned(IR), true),
394            (NotAssigned(P), Scheduled(cond.clone()), true),
395            (NotAssigned(P), Running(cond.clone()), false),
396            (NotAssigned(P), Paused(cond.clone()), false),
397            (NotAssigned(P), Stopped(cond.clone()), false),
398            (NotAssigned(IR), NotAssigned(P), false),
399            (NotAssigned(IR), NotAssigned(IR), false),
400            (NotAssigned(IR), Scheduled(cond.clone()), true),
401            (NotAssigned(IR), Running(cond.clone()), false),
402            (NotAssigned(IR), Paused(cond.clone()), false),
403            (NotAssigned(IR), Stopped(cond.clone()), false),
404            (Scheduled(cond.clone()), NotAssigned(P), true),
405            (Scheduled(cond.clone()), NotAssigned(IR), false),
406            (Scheduled(cond.clone()), Scheduled(cond.clone()), true),
407            (Scheduled(cond.clone()), Running(cond.clone()), false),
408            (Scheduled(cond.clone()), Paused(cond.clone()), false),
409            (Scheduled(cond.clone()), Stopped(cond.clone()), false),
410            (Scheduled(cond.clone()), Prepared(cond.clone()), true),
411            (Running(cond.clone()), NotAssigned(P), false),
412            (Running(cond.clone()), NotAssigned(IR), false),
413            (Running(cond.clone()), Scheduled(cond.clone()), false),
414            (Running(cond.clone()), Running(cond.clone()), true),
415            (Running(cond.clone()), Paused(cond.clone()), true),
416            (Running(cond.clone()), Stopped(cond.clone()), true),
417            (Running(cond.clone()), Prepared(cond.clone()), false),
418            (Paused(cond.clone()), NotAssigned(P), false),
419            (Paused(cond.clone()), NotAssigned(IR), false),
420            (Paused(cond.clone()), Scheduled(cond.clone()), false),
421            (Paused(cond.clone()), Running(cond.clone()), true),
422            (Paused(cond.clone()), Paused(cond.clone()), true),
423            (Paused(cond.clone()), Stopped(cond.clone()), true),
424            (Paused(cond.clone()), Prepared(cond.clone()), false),
425            (Stopped(cond.clone()), NotAssigned(P), true),
426            (Stopped(cond.clone()), NotAssigned(IR), false),
427            (Stopped(cond.clone()), Scheduled(cond.clone()), false),
428            (Stopped(cond.clone()), Running(cond.clone()), true),
429            (Stopped(cond.clone()), Paused(cond.clone()), false),
430            (Stopped(cond.clone()), Stopped(cond.clone()), true),
431            (Stopped(cond.clone()), Prepared(cond), false),
432        ];
433
434        for (a, b, is_ok) in inputs {
435            assert_eq!(validate_status_transition(&a, &b).is_ok(), is_ok);
436        }
437    }
438
439    #[test]
440    fn test_validate_status_transition_not_assigned_scheduled() {
441        // Specified and reserved *resources* must match.
442        let mut a = example_vm();
443        assert!(matches!(
444            &a.status,
445            VmStatus::NotAssigned(NotAssignedStatus::Pending)
446        ));
447        let mut b = example_vm();
448        b.status = VmStatus::Scheduled(example_condition());
449        assert_ne!(a.spec.resources, example_condition().reserved_resources);
450        assert!(VirtualMachine::validate_fields_before_update(&a, &b).is_err());
451
452        a.spec.resources = example_condition().reserved_resources;
453        b.spec.resources = example_condition().reserved_resources;
454        assert!(VirtualMachine::validate_fields_before_update(&a, &b).is_ok());
455
456        // Example: status validation is performed.
457        a.status = VmStatus::NotAssigned(NotAssignedStatus::InsufficientResources);
458        assert!(VirtualMachine::validate_fields_before_update(&b, &a).is_err());
459
460        // Reserved resources must be greater than 0. (*specified* resources are validated during
461        // creation and immutable later; *reserved* resources are specified during an update
462        // from NotAssigned to Scheduled and otherwise (in theory) immutable).
463        a.spec.resources = Resources {
464            vcpu: 0,
465            ram_mib: 0,
466        };
467
468        b.spec.resources = a.spec.resources.clone();
469        b.status = VmStatus::Scheduled(VmCondition {
470            reserved_resources: Resources {
471                vcpu: 0,
472                ram_mib: 0,
473            },
474            assigned_node: SclName::try_from("hello").unwrap(),
475            transition_info: None,
476        });
477        assert!(VirtualMachine::validate_fields_before_update(&a, &b).is_err());
478    }
479
480    /// Checks for every [VmStatus] pair with a nested [VmCondition] that changes of its `assigned_node`,
481    /// `reserved_resources.vcpu`, and `reserved_resources.ram_mib` fields are denied.
482    macro_rules! test_invalid_vm_condition_changes {
483        ($($name:ident: $value:expr,)*) => {
484            $(
485                #[test]
486                fn $name() {
487                    let cond = example_condition();
488                    let scheduled: VmStatus = $value.0(cond.clone());
489
490                    // Changed vcpu value must be denied.
491                    let mut v1 = cond.clone();
492                    v1.reserved_resources.vcpu = 0;
493                    let c1: VmStatus = $value.1(v1);
494                    assert!(validate_status_transition(&scheduled, &c1).is_err());
495
496                    // Changed ram_mib value must be denied.
497                    let mut v2 = cond.clone();
498                    v2.reserved_resources.ram_mib = 0;
499                    let c2: VmStatus = $value.1(v2);
500                    assert!(validate_status_transition(&scheduled, &c2).is_err());
501
502                    // Changed assigned host must be denied.
503                    let mut v3 = cond.clone();
504                    v3.assigned_node = SclName::try_from("changed").unwrap();
505                    let c3: VmStatus = $value.1(v3);
506                    assert!(validate_status_transition(&scheduled, &c3).is_err());
507                }
508            )*
509        }
510    }
511
512    test_invalid_vm_condition_changes! {
513        // The over-coverage (resulting from testing on a type level and therefore including
514        // *currently* invalid deemed transitions) reduces the future need for test maintenance.
515        test_vm_condition_changes_sc_sc: (VmStatus::Scheduled, VmStatus::Scheduled),
516        test_vm_condition_changes_sc_ru: (VmStatus::Scheduled, VmStatus::Running),
517        test_vm_condition_changes_sc_pa: (VmStatus::Scheduled, VmStatus::Paused),
518        test_vm_condition_changes_sc_st: (VmStatus::Scheduled, VmStatus::Stopped),
519        test_vm_condition_changes_ru_sc: (VmStatus::Running, VmStatus::Scheduled),
520        test_vm_condition_changes_ru_ru: (VmStatus::Running, VmStatus::Running),
521        test_vm_condition_changes_ru_pa: (VmStatus::Running, VmStatus::Paused),
522        test_vm_condition_changes_ru_st: (VmStatus::Running, VmStatus::Stopped),
523        test_vm_condition_changes_pa_sc: (VmStatus::Paused, VmStatus::Scheduled),
524        test_vm_condition_changes_pa_ru: (VmStatus::Paused, VmStatus::Running),
525        test_vm_condition_changes_pa_pa: (VmStatus::Paused, VmStatus::Paused),
526        test_vm_condition_changes_pa_st: (VmStatus::Paused, VmStatus::Stopped),
527        test_vm_condition_changes_st_sc: (VmStatus::Stopped, VmStatus::Scheduled),
528        test_vm_condition_changes_st_ru: (VmStatus::Stopped, VmStatus::Running),
529        test_vm_condition_changes_st_pa: (VmStatus::Stopped, VmStatus::Paused),
530        test_vm_condition_changes_st_st: (VmStatus::Stopped, VmStatus::Stopped),
531    }
532
533    #[test]
534    fn validate_fields_before_create() {
535        use VmStatus::*;
536
537        let vm = example_vm();
538        assert!(VirtualMachine::validate_fields_before_create(&vm).is_ok());
539
540        #[allow(clippy::type_complexity)]
541        let invalid_mutations: Vec<Box<dyn Fn(&mut VirtualMachine)>> = vec![
542            Box::new(|t| t.spec.resources.vcpu = 0),
543            Box::new(|t| t.spec.resources.ram_mib = 0),
544            Box::new(|t| t.spec.target_state = TargetVmStatus::Paused),
545            Box::new(|t| t.spec.target_state = TargetVmStatus::Stopped),
546            Box::new(|t| t.status = NotAssigned(NotAssignedStatus::InsufficientResources)),
547            Box::new(|t| t.status = Scheduled(example_condition())),
548            Box::new(|t| t.status = Running(example_condition())),
549            Box::new(|t| t.status = Paused(example_condition())),
550            Box::new(|t| t.status = Stopped(example_condition())),
551        ];
552
553        detect_invalid_create_mutations(&vm, invalid_mutations);
554    }
555
556    #[test]
557    fn validate_fields_before_update() {
558        let current = example_vm();
559
560        #[allow(clippy::type_complexity)]
561        let invalid_mutations: Vec<Box<dyn Fn(&mut VirtualMachine)>> = vec![
562            Box::new(|t| t.separation_context = SclName::try_from("changed").unwrap()),
563            Box::new(|t| t.spec.resources.vcpu = 4),
564            Box::new(|t| t.spec.resources.vcpu = 0),
565            Box::new(|t| t.spec.resources.ram_mib = 4),
566            Box::new(|t| t.spec.resources.ram_mib = 0),
567            Box::new(|t| {
568                t.spec.boot_volume = BootVolume::Volume(SclName::try_from("changed").unwrap())
569            }),
570            Box::new(|t| t.spec.network_device_name = String::from("changed")),
571            Box::new(|t| t.spec.cloud_init_config = Some(serde_json::from_str("{}").unwrap())),
572        ];
573
574        detect_invalid_update_mutations(&current, &current, invalid_mutations);
575    }
576
577    #[test]
578    fn detect_invalid_metadata_mutations() {
579        let t = example_vm();
580        detect_invalid_metadata_create_mutations(&t);
581        detect_invalid_metadata_update_mutations(&t);
582    }
583}