Skip to main content

arcbox_oci/
state.rs

1//! OCI container state management.
2//!
3//! This module defines the container state as per OCI runtime specification.
4//! Reference: <https://github.com/opencontainers/runtime-spec/blob/main/runtime.md#state>
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13use crate::error::{OciError, Result};
14
15/// OCI container state.
16///
17/// This is the state structure as defined by the OCI runtime specification.
18/// It is passed to hooks via stdin and returned by the `state` command.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase")]
21pub struct State {
22    /// OCI specification version.
23    pub oci_version: String,
24
25    /// Container ID (unique identifier).
26    pub id: String,
27
28    /// Container status.
29    pub status: Status,
30
31    /// Process ID of the container's init process (if running).
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub pid: Option<u32>,
34
35    /// Absolute path to the bundle directory.
36    pub bundle: PathBuf,
37
38    /// Annotations from the container configuration.
39    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
40    pub annotations: HashMap<String, String>,
41}
42
43impl State {
44    /// Create a new container state.
45    #[must_use]
46    pub fn new(id: String, bundle: PathBuf) -> Self {
47        Self {
48            oci_version: crate::config::OCI_VERSION.to_string(),
49            id,
50            status: Status::Creating,
51            pid: None,
52            bundle,
53            annotations: HashMap::new(),
54        }
55    }
56
57    /// Create a new container state with generated ID.
58    #[must_use]
59    pub fn with_generated_id(bundle: PathBuf) -> Self {
60        Self::new(Uuid::new_v4().to_string(), bundle)
61    }
62
63    /// Load state from JSON file.
64    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
65        let content = std::fs::read_to_string(path)?;
66        Ok(serde_json::from_str(&content)?)
67    }
68
69    /// Save state to JSON file.
70    pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
71        let json = serde_json::to_string_pretty(self)?;
72        std::fs::write(path, json)?;
73        Ok(())
74    }
75
76    /// Convert to JSON string.
77    pub fn to_json(&self) -> Result<String> {
78        Ok(serde_json::to_string_pretty(self)?)
79    }
80
81    /// Set the container PID.
82    pub const fn set_pid(&mut self, pid: u32) {
83        self.pid = Some(pid);
84    }
85
86    /// Clear the container PID.
87    pub const fn clear_pid(&mut self) {
88        self.pid = None;
89    }
90
91    /// Transition to a new status.
92    ///
93    /// Returns an error if the transition is invalid.
94    pub fn transition_to(&mut self, new_status: Status) -> Result<()> {
95        if !self.status.can_transition_to(new_status) {
96            return Err(OciError::Common(arcbox_error::CommonError::invalid_state(
97                format!(
98                    "expected one of [{}], got {}",
99                    self.status.valid_transitions().join(", "),
100                    new_status.as_str()
101                ),
102            )));
103        }
104        self.status = new_status;
105        Ok(())
106    }
107}
108
109/// Container status as defined by OCI.
110///
111/// Valid state transitions:
112/// - Creating -> Created
113/// - Created -> Running
114/// - Running -> Stopped
115/// - Any -> Stopped (on error)
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "lowercase")]
118pub enum Status {
119    /// Container is being created.
120    Creating,
121    /// Container has been created (create command has finished).
122    Created,
123    /// Container is running (start command has been invoked).
124    Running,
125    /// Container has stopped.
126    Stopped,
127}
128
129impl Status {
130    /// Get the status string.
131    #[must_use]
132    pub const fn as_str(&self) -> &'static str {
133        match self {
134            Self::Creating => "creating",
135            Self::Created => "created",
136            Self::Running => "running",
137            Self::Stopped => "stopped",
138        }
139    }
140
141    /// Check if transition to the given status is valid.
142    #[must_use]
143    pub const fn can_transition_to(&self, target: Self) -> bool {
144        matches!(
145            (self, target),
146            (Self::Creating, Self::Created | Self::Stopped)
147                | (Self::Created, Self::Running | Self::Stopped)
148                | (Self::Running, Self::Stopped)
149        )
150    }
151
152    /// Get valid transitions from current status.
153    #[must_use]
154    pub fn valid_transitions(&self) -> Vec<&'static str> {
155        match self {
156            Self::Creating => vec!["created", "stopped"],
157            Self::Created => vec!["running", "stopped"],
158            Self::Running => vec!["stopped"],
159            Self::Stopped => vec![],
160        }
161    }
162
163    /// Check if container is in a running state.
164    #[must_use]
165    pub const fn is_running(&self) -> bool {
166        matches!(self, Self::Running)
167    }
168
169    /// Check if container can be started.
170    #[must_use]
171    pub const fn can_start(&self) -> bool {
172        matches!(self, Self::Created)
173    }
174
175    /// Check if container can be killed.
176    #[must_use]
177    pub const fn can_kill(&self) -> bool {
178        matches!(self, Self::Created | Self::Running)
179    }
180
181    /// Check if container can be deleted.
182    #[must_use]
183    pub const fn can_delete(&self) -> bool {
184        matches!(self, Self::Stopped)
185    }
186}
187
188impl std::fmt::Display for Status {
189    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190        write!(f, "{}", self.as_str())
191    }
192}
193
194impl std::str::FromStr for Status {
195    type Err = OciError;
196
197    fn from_str(s: &str) -> Result<Self> {
198        match s.to_lowercase().as_str() {
199            "creating" => Ok(Self::Creating),
200            "created" => Ok(Self::Created),
201            "running" => Ok(Self::Running),
202            "stopped" => Ok(Self::Stopped),
203            _ => Err(OciError::InvalidConfig(format!("unknown status: {s}"))),
204        }
205    }
206}
207
208/// Extended container state with additional metadata.
209///
210/// This extends the OCI state with ArcBox-specific information.
211#[derive(Debug, Clone, Serialize, Deserialize)]
212#[serde(rename_all = "camelCase")]
213pub struct ContainerState {
214    /// OCI state.
215    #[serde(flatten)]
216    pub oci_state: State,
217
218    /// Creation timestamp.
219    pub created: DateTime<Utc>,
220
221    /// Start timestamp (if started).
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub started: Option<DateTime<Utc>>,
224
225    /// Exit timestamp (if stopped).
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub finished: Option<DateTime<Utc>>,
228
229    /// Exit code (if stopped).
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub exit_code: Option<i32>,
232
233    /// Container name (if named).
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub name: Option<String>,
236
237    /// Image reference.
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub image: Option<String>,
240
241    /// Root filesystem path.
242    pub rootfs: PathBuf,
243}
244
245impl ContainerState {
246    /// Create a new container state.
247    #[must_use]
248    pub fn new(id: String, bundle: PathBuf, rootfs: PathBuf) -> Self {
249        Self {
250            oci_state: State::new(id, bundle),
251            created: Utc::now(),
252            started: None,
253            finished: None,
254            exit_code: None,
255            name: None,
256            image: None,
257            rootfs,
258        }
259    }
260
261    /// Get the container ID.
262    #[must_use]
263    pub fn id(&self) -> &str {
264        &self.oci_state.id
265    }
266
267    /// Get the container status.
268    #[must_use]
269    pub const fn status(&self) -> Status {
270        self.oci_state.status
271    }
272
273    /// Get the bundle path.
274    #[must_use]
275    pub fn bundle(&self) -> &Path {
276        &self.oci_state.bundle
277    }
278
279    /// Mark container as created.
280    pub fn mark_created(&mut self) -> Result<()> {
281        self.oci_state.transition_to(Status::Created)
282    }
283
284    /// Mark container as started.
285    pub fn mark_started(&mut self, pid: u32) -> Result<()> {
286        self.oci_state.set_pid(pid);
287        self.oci_state.transition_to(Status::Running)?;
288        self.started = Some(Utc::now());
289        Ok(())
290    }
291
292    /// Mark container as stopped.
293    pub fn mark_stopped(&mut self, exit_code: i32) -> Result<()> {
294        self.oci_state.clear_pid();
295        self.oci_state.transition_to(Status::Stopped)?;
296        self.finished = Some(Utc::now());
297        self.exit_code = Some(exit_code);
298        Ok(())
299    }
300
301    /// Get the OCI state for hooks.
302    #[must_use]
303    pub const fn oci_state(&self) -> &State {
304        &self.oci_state
305    }
306
307    /// Load from JSON file.
308    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
309        let content = std::fs::read_to_string(path)?;
310        Ok(serde_json::from_str(&content)?)
311    }
312
313    /// Save to JSON file.
314    pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
315        let json = serde_json::to_string_pretty(self)?;
316        std::fs::write(path, json)?;
317        Ok(())
318    }
319}
320
321/// Container state store.
322///
323/// Manages container state persistence in a directory.
324pub struct StateStore {
325    /// Root directory for state files.
326    root: PathBuf,
327}
328
329impl StateStore {
330    /// Create a new state store.
331    pub fn new<P: Into<PathBuf>>(root: P) -> Result<Self> {
332        let root = root.into();
333        std::fs::create_dir_all(&root)?;
334        Ok(Self { root })
335    }
336
337    /// Get the state file path for a container.
338    fn state_path(&self, id: &str) -> PathBuf {
339        self.root.join(id).join("state.json")
340    }
341
342    /// Get the container directory path.
343    fn container_dir(&self, id: &str) -> PathBuf {
344        self.root.join(id)
345    }
346
347    /// Save container state.
348    pub fn save(&self, state: &ContainerState) -> Result<()> {
349        let dir = self.container_dir(state.id());
350        std::fs::create_dir_all(&dir)?;
351        state.save(self.state_path(state.id()))
352    }
353
354    /// Load container state.
355    pub fn load(&self, id: &str) -> Result<ContainerState> {
356        let path = self.state_path(id);
357        if !path.exists() {
358            return Err(OciError::ContainerNotFound(id.to_string()));
359        }
360        ContainerState::load(path)
361    }
362
363    /// Check if container exists.
364    #[must_use]
365    pub fn exists(&self, id: &str) -> bool {
366        self.state_path(id).exists()
367    }
368
369    /// Delete container state.
370    pub fn delete(&self, id: &str) -> Result<()> {
371        let dir = self.container_dir(id);
372        if dir.exists() {
373            std::fs::remove_dir_all(dir)?;
374        }
375        Ok(())
376    }
377
378    /// List all container IDs.
379    pub fn list(&self) -> Result<Vec<String>> {
380        let mut ids = Vec::new();
381        if self.root.exists() {
382            for entry in std::fs::read_dir(&self.root)? {
383                let entry = entry?;
384                if entry.file_type()?.is_dir() {
385                    if let Some(name) = entry.file_name().to_str() {
386                        if self.state_path(name).exists() {
387                            ids.push(name.to_string());
388                        }
389                    }
390                }
391            }
392        }
393        Ok(ids)
394    }
395
396    /// List all containers with their states.
397    pub fn list_states(&self) -> Result<Vec<ContainerState>> {
398        let ids = self.list()?;
399        let mut states = Vec::with_capacity(ids.len());
400        for id in ids {
401            states.push(self.load(&id)?);
402        }
403        Ok(states)
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    #[test]
412    fn test_status_transitions() {
413        assert!(Status::Creating.can_transition_to(Status::Created));
414        assert!(Status::Created.can_transition_to(Status::Running));
415        assert!(Status::Running.can_transition_to(Status::Stopped));
416
417        assert!(!Status::Creating.can_transition_to(Status::Running));
418        assert!(!Status::Stopped.can_transition_to(Status::Running));
419    }
420
421    #[test]
422    fn test_state_transition() {
423        let mut state = State::new("test".to_string(), PathBuf::from("/bundle"));
424
425        assert_eq!(state.status, Status::Creating);
426        assert!(state.transition_to(Status::Created).is_ok());
427        assert_eq!(state.status, Status::Created);
428        assert!(state.transition_to(Status::Running).is_ok());
429        assert_eq!(state.status, Status::Running);
430    }
431
432    #[test]
433    fn test_invalid_transition() {
434        let mut state = State::new("test".to_string(), PathBuf::from("/bundle"));
435        assert!(state.transition_to(Status::Running).is_err());
436    }
437
438    #[test]
439    fn test_state_serialization() {
440        let state = State::new("test-container".to_string(), PathBuf::from("/var/run/test"));
441
442        let json = state.to_json().unwrap();
443        assert!(json.contains("test-container"));
444        assert!(json.contains("creating"));
445    }
446
447    #[test]
448    fn test_container_state_lifecycle() {
449        let mut state = ContainerState::new(
450            "test".to_string(),
451            PathBuf::from("/bundle"),
452            PathBuf::from("/rootfs"),
453        );
454
455        assert_eq!(state.status(), Status::Creating);
456        assert!(state.mark_created().is_ok());
457        assert_eq!(state.status(), Status::Created);
458        assert!(state.mark_started(1234).is_ok());
459        assert_eq!(state.status(), Status::Running);
460        assert!(state.started.is_some());
461        assert!(state.mark_stopped(0).is_ok());
462        assert_eq!(state.status(), Status::Stopped);
463        assert!(state.finished.is_some());
464        assert_eq!(state.exit_code, Some(0));
465    }
466
467    #[test]
468    fn test_status_from_str() {
469        assert_eq!("creating".parse::<Status>().unwrap(), Status::Creating);
470        assert_eq!("RUNNING".parse::<Status>().unwrap(), Status::Running);
471        assert!("invalid".parse::<Status>().is_err());
472    }
473
474    #[test]
475    fn test_status_display() {
476        assert_eq!(Status::Creating.to_string(), "creating");
477        assert_eq!(Status::Created.to_string(), "created");
478        assert_eq!(Status::Running.to_string(), "running");
479        assert_eq!(Status::Stopped.to_string(), "stopped");
480    }
481
482    #[test]
483    fn test_status_helper_methods() {
484        assert!(!Status::Creating.is_running());
485        assert!(Status::Running.is_running());
486
487        assert!(!Status::Creating.can_start());
488        assert!(Status::Created.can_start());
489        assert!(!Status::Running.can_start());
490
491        assert!(!Status::Creating.can_kill());
492        assert!(Status::Created.can_kill());
493        assert!(Status::Running.can_kill());
494        assert!(!Status::Stopped.can_kill());
495
496        assert!(!Status::Creating.can_delete());
497        assert!(!Status::Running.can_delete());
498        assert!(Status::Stopped.can_delete());
499    }
500
501    #[test]
502    fn test_status_valid_transitions() {
503        assert_eq!(
504            Status::Creating.valid_transitions(),
505            vec!["created", "stopped"]
506        );
507        assert_eq!(
508            Status::Created.valid_transitions(),
509            vec!["running", "stopped"]
510        );
511        assert_eq!(Status::Running.valid_transitions(), vec!["stopped"]);
512        assert!(Status::Stopped.valid_transitions().is_empty());
513    }
514
515    #[test]
516    fn test_state_pid_operations() {
517        let mut state = State::new("test".to_string(), PathBuf::from("/bundle"));
518        assert!(state.pid.is_none());
519
520        state.set_pid(1234);
521        assert_eq!(state.pid, Some(1234));
522
523        state.clear_pid();
524        assert!(state.pid.is_none());
525    }
526
527    #[test]
528    fn test_state_with_generated_id() {
529        let state = State::with_generated_id(PathBuf::from("/bundle"));
530        assert!(!state.id.is_empty());
531        // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
532        assert_eq!(state.id.len(), 36);
533        assert!(state.id.contains('-'));
534    }
535
536    #[test]
537    fn test_state_annotations() {
538        let mut state = State::new("test".to_string(), PathBuf::from("/bundle"));
539        assert!(state.annotations.is_empty());
540
541        state
542            .annotations
543            .insert("key1".to_string(), "value1".to_string());
544        state
545            .annotations
546            .insert("key2".to_string(), "value2".to_string());
547
548        assert_eq!(state.annotations.len(), 2);
549        assert_eq!(state.annotations.get("key1"), Some(&"value1".to_string()));
550    }
551
552    #[test]
553    fn test_state_file_operations() {
554        let dir = tempfile::tempdir().unwrap();
555        let state_path = dir.path().join("state.json");
556
557        let state = State::new("test-container".to_string(), PathBuf::from("/bundle"));
558        state.save(&state_path).unwrap();
559
560        assert!(state_path.exists());
561
562        let loaded = State::load(&state_path).unwrap();
563        assert_eq!(loaded.id, "test-container");
564        assert_eq!(loaded.status, Status::Creating);
565    }
566
567    #[test]
568    fn test_container_state_file_operations() {
569        let dir = tempfile::tempdir().unwrap();
570        let state_path = dir.path().join("container_state.json");
571
572        let mut state = ContainerState::new(
573            "test".to_string(),
574            PathBuf::from("/bundle"),
575            PathBuf::from("/rootfs"),
576        );
577        state.name = Some("my-container".to_string());
578        state.image = Some("alpine:latest".to_string());
579
580        state.save(&state_path).unwrap();
581        assert!(state_path.exists());
582
583        let loaded = ContainerState::load(&state_path).unwrap();
584        assert_eq!(loaded.id(), "test");
585        assert_eq!(loaded.name, Some("my-container".to_string()));
586        assert_eq!(loaded.image, Some("alpine:latest".to_string()));
587    }
588
589    #[test]
590    fn test_container_state_accessors() {
591        let state = ContainerState::new(
592            "test-id".to_string(),
593            PathBuf::from("/path/to/bundle"),
594            PathBuf::from("/path/to/rootfs"),
595        );
596
597        assert_eq!(state.id(), "test-id");
598        assert_eq!(state.status(), Status::Creating);
599        assert_eq!(state.bundle(), Path::new("/path/to/bundle"));
600        assert_eq!(state.rootfs, PathBuf::from("/path/to/rootfs"));
601    }
602
603    #[test]
604    fn test_container_state_timestamps() {
605        let mut state = ContainerState::new(
606            "test".to_string(),
607            PathBuf::from("/bundle"),
608            PathBuf::from("/rootfs"),
609        );
610
611        // Created timestamp is set on construction.
612        assert!(state.created <= chrono::Utc::now());
613
614        // Started and finished are initially None.
615        assert!(state.started.is_none());
616        assert!(state.finished.is_none());
617
618        state.mark_created().unwrap();
619        state.mark_started(1234).unwrap();
620        assert!(state.started.is_some());
621        assert!(state.started.unwrap() <= chrono::Utc::now());
622
623        state.mark_stopped(0).unwrap();
624        assert!(state.finished.is_some());
625        assert!(state.finished.unwrap() >= state.started.unwrap());
626    }
627
628    #[test]
629    fn test_container_state_nonzero_exit_code() {
630        let mut state = ContainerState::new(
631            "test".to_string(),
632            PathBuf::from("/bundle"),
633            PathBuf::from("/rootfs"),
634        );
635
636        state.mark_created().unwrap();
637        state.mark_started(1234).unwrap();
638        state.mark_stopped(137).unwrap(); // Killed by signal 9
639
640        assert_eq!(state.exit_code, Some(137));
641    }
642
643    #[test]
644    fn test_state_store_new() {
645        let dir = tempfile::tempdir().unwrap();
646        let store = StateStore::new(dir.path()).unwrap();
647        assert!(dir.path().exists());
648        drop(store);
649    }
650
651    #[test]
652    fn test_state_store_save_and_load() {
653        let dir = tempfile::tempdir().unwrap();
654        let store = StateStore::new(dir.path()).unwrap();
655
656        let state = ContainerState::new(
657            "container-1".to_string(),
658            PathBuf::from("/bundle"),
659            PathBuf::from("/rootfs"),
660        );
661
662        store.save(&state).unwrap();
663        assert!(store.exists("container-1"));
664
665        let loaded = store.load("container-1").unwrap();
666        assert_eq!(loaded.id(), "container-1");
667    }
668
669    #[test]
670    fn test_state_store_not_found() {
671        let dir = tempfile::tempdir().unwrap();
672        let store = StateStore::new(dir.path()).unwrap();
673
674        let result = store.load("nonexistent");
675        assert!(result.is_err());
676    }
677
678    #[test]
679    fn test_state_store_delete() {
680        let dir = tempfile::tempdir().unwrap();
681        let store = StateStore::new(dir.path()).unwrap();
682
683        let state = ContainerState::new(
684            "to-delete".to_string(),
685            PathBuf::from("/bundle"),
686            PathBuf::from("/rootfs"),
687        );
688
689        store.save(&state).unwrap();
690        assert!(store.exists("to-delete"));
691
692        store.delete("to-delete").unwrap();
693        assert!(!store.exists("to-delete"));
694    }
695
696    #[test]
697    fn test_state_store_delete_nonexistent() {
698        let dir = tempfile::tempdir().unwrap();
699        let store = StateStore::new(dir.path()).unwrap();
700
701        // Should not error when deleting non-existent container.
702        let result = store.delete("nonexistent");
703        assert!(result.is_ok());
704    }
705
706    #[test]
707    fn test_state_store_list() {
708        let dir = tempfile::tempdir().unwrap();
709        let store = StateStore::new(dir.path()).unwrap();
710
711        // Initially empty.
712        assert!(store.list().unwrap().is_empty());
713
714        // Add some containers.
715        for i in 1..=3 {
716            let state = ContainerState::new(
717                format!("container-{i}"),
718                PathBuf::from("/bundle"),
719                PathBuf::from("/rootfs"),
720            );
721            store.save(&state).unwrap();
722        }
723
724        let ids = store.list().unwrap();
725        assert_eq!(ids.len(), 3);
726        assert!(ids.contains(&"container-1".to_string()));
727        assert!(ids.contains(&"container-2".to_string()));
728        assert!(ids.contains(&"container-3".to_string()));
729    }
730
731    #[test]
732    fn test_state_store_list_states() {
733        let dir = tempfile::tempdir().unwrap();
734        let store = StateStore::new(dir.path()).unwrap();
735
736        for i in 1..=2 {
737            let mut state = ContainerState::new(
738                format!("container-{i}"),
739                PathBuf::from("/bundle"),
740                PathBuf::from("/rootfs"),
741            );
742            state.name = Some(format!("name-{i}"));
743            store.save(&state).unwrap();
744        }
745
746        let states = store.list_states().unwrap();
747        assert_eq!(states.len(), 2);
748    }
749
750    #[test]
751    fn test_state_store_update() {
752        let dir = tempfile::tempdir().unwrap();
753        let store = StateStore::new(dir.path()).unwrap();
754
755        let mut state = ContainerState::new(
756            "updatable".to_string(),
757            PathBuf::from("/bundle"),
758            PathBuf::from("/rootfs"),
759        );
760
761        store.save(&state).unwrap();
762
763        // Update the state.
764        state.mark_created().unwrap();
765        state.mark_started(9999).unwrap();
766        store.save(&state).unwrap();
767
768        let loaded = store.load("updatable").unwrap();
769        assert_eq!(loaded.status(), Status::Running);
770        assert_eq!(loaded.oci_state.pid, Some(9999));
771    }
772
773    #[test]
774    fn test_state_json_roundtrip() {
775        let mut state = State::new("roundtrip-test".to_string(), PathBuf::from("/bundle"));
776        state.pid = Some(12345);
777        state
778            .annotations
779            .insert("test.key".to_string(), "test.value".to_string());
780
781        let json = state.to_json().unwrap();
782        let parsed: State = serde_json::from_str(&json).unwrap();
783
784        assert_eq!(parsed.id, state.id);
785        assert_eq!(parsed.pid, state.pid);
786        assert_eq!(parsed.annotations, state.annotations);
787    }
788
789    #[test]
790    fn test_transition_creating_to_stopped_on_error() {
791        let mut state = State::new("test".to_string(), PathBuf::from("/bundle"));
792        // Should be able to go directly to stopped on error during creation.
793        assert!(state.transition_to(Status::Stopped).is_ok());
794        assert_eq!(state.status, Status::Stopped);
795    }
796
797    #[test]
798    fn test_transition_created_to_stopped_without_running() {
799        let mut state = State::new("test".to_string(), PathBuf::from("/bundle"));
800        state.transition_to(Status::Created).unwrap();
801        // Can be deleted without ever running.
802        assert!(state.transition_to(Status::Stopped).is_ok());
803        assert_eq!(state.status, Status::Stopped);
804    }
805}