use std::collections::HashMap;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::{OciError, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct State {
pub oci_version: String,
pub id: String,
pub status: Status,
#[serde(skip_serializing_if = "Option::is_none")]
pub pid: Option<u32>,
pub bundle: PathBuf,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub annotations: HashMap<String, String>,
}
impl State {
#[must_use]
pub fn new(id: String, bundle: PathBuf) -> Self {
Self {
oci_version: crate::config::OCI_VERSION.to_string(),
id,
status: Status::Creating,
pid: None,
bundle,
annotations: HashMap::new(),
}
}
#[must_use]
pub fn with_generated_id(bundle: PathBuf) -> Self {
Self::new(Uuid::new_v4().to_string(), bundle)
}
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
let content = std::fs::read_to_string(path)?;
Ok(serde_json::from_str(&content)?)
}
pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
let json = serde_json::to_string_pretty(self)?;
std::fs::write(path, json)?;
Ok(())
}
pub fn to_json(&self) -> Result<String> {
Ok(serde_json::to_string_pretty(self)?)
}
pub const fn set_pid(&mut self, pid: u32) {
self.pid = Some(pid);
}
pub const fn clear_pid(&mut self) {
self.pid = None;
}
pub fn transition_to(&mut self, new_status: Status) -> Result<()> {
if !self.status.can_transition_to(new_status) {
return Err(OciError::Common(arcbox_error::CommonError::invalid_state(
format!(
"expected one of [{}], got {}",
self.status.valid_transitions().join(", "),
new_status.as_str()
),
)));
}
self.status = new_status;
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Creating,
Created,
Running,
Stopped,
}
impl Status {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Creating => "creating",
Self::Created => "created",
Self::Running => "running",
Self::Stopped => "stopped",
}
}
#[must_use]
pub const fn can_transition_to(&self, target: Self) -> bool {
matches!(
(self, target),
(Self::Creating, Self::Created | Self::Stopped)
| (Self::Created, Self::Running | Self::Stopped)
| (Self::Running, Self::Stopped)
)
}
#[must_use]
pub fn valid_transitions(&self) -> Vec<&'static str> {
match self {
Self::Creating => vec!["created", "stopped"],
Self::Created => vec!["running", "stopped"],
Self::Running => vec!["stopped"],
Self::Stopped => vec![],
}
}
#[must_use]
pub const fn is_running(&self) -> bool {
matches!(self, Self::Running)
}
#[must_use]
pub const fn can_start(&self) -> bool {
matches!(self, Self::Created)
}
#[must_use]
pub const fn can_kill(&self) -> bool {
matches!(self, Self::Created | Self::Running)
}
#[must_use]
pub const fn can_delete(&self) -> bool {
matches!(self, Self::Stopped)
}
}
impl std::fmt::Display for Status {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl std::str::FromStr for Status {
type Err = OciError;
fn from_str(s: &str) -> Result<Self> {
match s.to_lowercase().as_str() {
"creating" => Ok(Self::Creating),
"created" => Ok(Self::Created),
"running" => Ok(Self::Running),
"stopped" => Ok(Self::Stopped),
_ => Err(OciError::InvalidConfig(format!("unknown status: {s}"))),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContainerState {
#[serde(flatten)]
pub oci_state: State,
pub created: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub started: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub finished: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
pub rootfs: PathBuf,
}
impl ContainerState {
#[must_use]
pub fn new(id: String, bundle: PathBuf, rootfs: PathBuf) -> Self {
Self {
oci_state: State::new(id, bundle),
created: Utc::now(),
started: None,
finished: None,
exit_code: None,
name: None,
image: None,
rootfs,
}
}
#[must_use]
pub fn id(&self) -> &str {
&self.oci_state.id
}
#[must_use]
pub const fn status(&self) -> Status {
self.oci_state.status
}
#[must_use]
pub fn bundle(&self) -> &Path {
&self.oci_state.bundle
}
pub fn mark_created(&mut self) -> Result<()> {
self.oci_state.transition_to(Status::Created)
}
pub fn mark_started(&mut self, pid: u32) -> Result<()> {
self.oci_state.set_pid(pid);
self.oci_state.transition_to(Status::Running)?;
self.started = Some(Utc::now());
Ok(())
}
pub fn mark_stopped(&mut self, exit_code: i32) -> Result<()> {
self.oci_state.clear_pid();
self.oci_state.transition_to(Status::Stopped)?;
self.finished = Some(Utc::now());
self.exit_code = Some(exit_code);
Ok(())
}
#[must_use]
pub const fn oci_state(&self) -> &State {
&self.oci_state
}
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
let content = std::fs::read_to_string(path)?;
Ok(serde_json::from_str(&content)?)
}
pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
let json = serde_json::to_string_pretty(self)?;
std::fs::write(path, json)?;
Ok(())
}
}
pub struct StateStore {
root: PathBuf,
}
impl StateStore {
pub fn new<P: Into<PathBuf>>(root: P) -> Result<Self> {
let root = root.into();
std::fs::create_dir_all(&root)?;
Ok(Self { root })
}
fn state_path(&self, id: &str) -> PathBuf {
self.root.join(id).join("state.json")
}
fn container_dir(&self, id: &str) -> PathBuf {
self.root.join(id)
}
pub fn save(&self, state: &ContainerState) -> Result<()> {
let dir = self.container_dir(state.id());
std::fs::create_dir_all(&dir)?;
state.save(self.state_path(state.id()))
}
pub fn load(&self, id: &str) -> Result<ContainerState> {
let path = self.state_path(id);
if !path.exists() {
return Err(OciError::ContainerNotFound(id.to_string()));
}
ContainerState::load(path)
}
#[must_use]
pub fn exists(&self, id: &str) -> bool {
self.state_path(id).exists()
}
pub fn delete(&self, id: &str) -> Result<()> {
let dir = self.container_dir(id);
if dir.exists() {
std::fs::remove_dir_all(dir)?;
}
Ok(())
}
pub fn list(&self) -> Result<Vec<String>> {
let mut ids = Vec::new();
if self.root.exists() {
for entry in std::fs::read_dir(&self.root)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
if let Some(name) = entry.file_name().to_str() {
if self.state_path(name).exists() {
ids.push(name.to_string());
}
}
}
}
}
Ok(ids)
}
pub fn list_states(&self) -> Result<Vec<ContainerState>> {
let ids = self.list()?;
let mut states = Vec::with_capacity(ids.len());
for id in ids {
states.push(self.load(&id)?);
}
Ok(states)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_status_transitions() {
assert!(Status::Creating.can_transition_to(Status::Created));
assert!(Status::Created.can_transition_to(Status::Running));
assert!(Status::Running.can_transition_to(Status::Stopped));
assert!(!Status::Creating.can_transition_to(Status::Running));
assert!(!Status::Stopped.can_transition_to(Status::Running));
}
#[test]
fn test_state_transition() {
let mut state = State::new("test".to_string(), PathBuf::from("/bundle"));
assert_eq!(state.status, Status::Creating);
assert!(state.transition_to(Status::Created).is_ok());
assert_eq!(state.status, Status::Created);
assert!(state.transition_to(Status::Running).is_ok());
assert_eq!(state.status, Status::Running);
}
#[test]
fn test_invalid_transition() {
let mut state = State::new("test".to_string(), PathBuf::from("/bundle"));
assert!(state.transition_to(Status::Running).is_err());
}
#[test]
fn test_state_serialization() {
let state = State::new("test-container".to_string(), PathBuf::from("/var/run/test"));
let json = state.to_json().unwrap();
assert!(json.contains("test-container"));
assert!(json.contains("creating"));
}
#[test]
fn test_container_state_lifecycle() {
let mut state = ContainerState::new(
"test".to_string(),
PathBuf::from("/bundle"),
PathBuf::from("/rootfs"),
);
assert_eq!(state.status(), Status::Creating);
assert!(state.mark_created().is_ok());
assert_eq!(state.status(), Status::Created);
assert!(state.mark_started(1234).is_ok());
assert_eq!(state.status(), Status::Running);
assert!(state.started.is_some());
assert!(state.mark_stopped(0).is_ok());
assert_eq!(state.status(), Status::Stopped);
assert!(state.finished.is_some());
assert_eq!(state.exit_code, Some(0));
}
#[test]
fn test_status_from_str() {
assert_eq!("creating".parse::<Status>().unwrap(), Status::Creating);
assert_eq!("RUNNING".parse::<Status>().unwrap(), Status::Running);
assert!("invalid".parse::<Status>().is_err());
}
#[test]
fn test_status_display() {
assert_eq!(Status::Creating.to_string(), "creating");
assert_eq!(Status::Created.to_string(), "created");
assert_eq!(Status::Running.to_string(), "running");
assert_eq!(Status::Stopped.to_string(), "stopped");
}
#[test]
fn test_status_helper_methods() {
assert!(!Status::Creating.is_running());
assert!(Status::Running.is_running());
assert!(!Status::Creating.can_start());
assert!(Status::Created.can_start());
assert!(!Status::Running.can_start());
assert!(!Status::Creating.can_kill());
assert!(Status::Created.can_kill());
assert!(Status::Running.can_kill());
assert!(!Status::Stopped.can_kill());
assert!(!Status::Creating.can_delete());
assert!(!Status::Running.can_delete());
assert!(Status::Stopped.can_delete());
}
#[test]
fn test_status_valid_transitions() {
assert_eq!(
Status::Creating.valid_transitions(),
vec!["created", "stopped"]
);
assert_eq!(
Status::Created.valid_transitions(),
vec!["running", "stopped"]
);
assert_eq!(Status::Running.valid_transitions(), vec!["stopped"]);
assert!(Status::Stopped.valid_transitions().is_empty());
}
#[test]
fn test_state_pid_operations() {
let mut state = State::new("test".to_string(), PathBuf::from("/bundle"));
assert!(state.pid.is_none());
state.set_pid(1234);
assert_eq!(state.pid, Some(1234));
state.clear_pid();
assert!(state.pid.is_none());
}
#[test]
fn test_state_with_generated_id() {
let state = State::with_generated_id(PathBuf::from("/bundle"));
assert!(!state.id.is_empty());
assert_eq!(state.id.len(), 36);
assert!(state.id.contains('-'));
}
#[test]
fn test_state_annotations() {
let mut state = State::new("test".to_string(), PathBuf::from("/bundle"));
assert!(state.annotations.is_empty());
state
.annotations
.insert("key1".to_string(), "value1".to_string());
state
.annotations
.insert("key2".to_string(), "value2".to_string());
assert_eq!(state.annotations.len(), 2);
assert_eq!(state.annotations.get("key1"), Some(&"value1".to_string()));
}
#[test]
fn test_state_file_operations() {
let dir = tempfile::tempdir().unwrap();
let state_path = dir.path().join("state.json");
let state = State::new("test-container".to_string(), PathBuf::from("/bundle"));
state.save(&state_path).unwrap();
assert!(state_path.exists());
let loaded = State::load(&state_path).unwrap();
assert_eq!(loaded.id, "test-container");
assert_eq!(loaded.status, Status::Creating);
}
#[test]
fn test_container_state_file_operations() {
let dir = tempfile::tempdir().unwrap();
let state_path = dir.path().join("container_state.json");
let mut state = ContainerState::new(
"test".to_string(),
PathBuf::from("/bundle"),
PathBuf::from("/rootfs"),
);
state.name = Some("my-container".to_string());
state.image = Some("alpine:latest".to_string());
state.save(&state_path).unwrap();
assert!(state_path.exists());
let loaded = ContainerState::load(&state_path).unwrap();
assert_eq!(loaded.id(), "test");
assert_eq!(loaded.name, Some("my-container".to_string()));
assert_eq!(loaded.image, Some("alpine:latest".to_string()));
}
#[test]
fn test_container_state_accessors() {
let state = ContainerState::new(
"test-id".to_string(),
PathBuf::from("/path/to/bundle"),
PathBuf::from("/path/to/rootfs"),
);
assert_eq!(state.id(), "test-id");
assert_eq!(state.status(), Status::Creating);
assert_eq!(state.bundle(), Path::new("/path/to/bundle"));
assert_eq!(state.rootfs, PathBuf::from("/path/to/rootfs"));
}
#[test]
fn test_container_state_timestamps() {
let mut state = ContainerState::new(
"test".to_string(),
PathBuf::from("/bundle"),
PathBuf::from("/rootfs"),
);
assert!(state.created <= chrono::Utc::now());
assert!(state.started.is_none());
assert!(state.finished.is_none());
state.mark_created().unwrap();
state.mark_started(1234).unwrap();
assert!(state.started.is_some());
assert!(state.started.unwrap() <= chrono::Utc::now());
state.mark_stopped(0).unwrap();
assert!(state.finished.is_some());
assert!(state.finished.unwrap() >= state.started.unwrap());
}
#[test]
fn test_container_state_nonzero_exit_code() {
let mut state = ContainerState::new(
"test".to_string(),
PathBuf::from("/bundle"),
PathBuf::from("/rootfs"),
);
state.mark_created().unwrap();
state.mark_started(1234).unwrap();
state.mark_stopped(137).unwrap();
assert_eq!(state.exit_code, Some(137));
}
#[test]
fn test_state_store_new() {
let dir = tempfile::tempdir().unwrap();
let store = StateStore::new(dir.path()).unwrap();
assert!(dir.path().exists());
drop(store);
}
#[test]
fn test_state_store_save_and_load() {
let dir = tempfile::tempdir().unwrap();
let store = StateStore::new(dir.path()).unwrap();
let state = ContainerState::new(
"container-1".to_string(),
PathBuf::from("/bundle"),
PathBuf::from("/rootfs"),
);
store.save(&state).unwrap();
assert!(store.exists("container-1"));
let loaded = store.load("container-1").unwrap();
assert_eq!(loaded.id(), "container-1");
}
#[test]
fn test_state_store_not_found() {
let dir = tempfile::tempdir().unwrap();
let store = StateStore::new(dir.path()).unwrap();
let result = store.load("nonexistent");
assert!(result.is_err());
}
#[test]
fn test_state_store_delete() {
let dir = tempfile::tempdir().unwrap();
let store = StateStore::new(dir.path()).unwrap();
let state = ContainerState::new(
"to-delete".to_string(),
PathBuf::from("/bundle"),
PathBuf::from("/rootfs"),
);
store.save(&state).unwrap();
assert!(store.exists("to-delete"));
store.delete("to-delete").unwrap();
assert!(!store.exists("to-delete"));
}
#[test]
fn test_state_store_delete_nonexistent() {
let dir = tempfile::tempdir().unwrap();
let store = StateStore::new(dir.path()).unwrap();
let result = store.delete("nonexistent");
assert!(result.is_ok());
}
#[test]
fn test_state_store_list() {
let dir = tempfile::tempdir().unwrap();
let store = StateStore::new(dir.path()).unwrap();
assert!(store.list().unwrap().is_empty());
for i in 1..=3 {
let state = ContainerState::new(
format!("container-{i}"),
PathBuf::from("/bundle"),
PathBuf::from("/rootfs"),
);
store.save(&state).unwrap();
}
let ids = store.list().unwrap();
assert_eq!(ids.len(), 3);
assert!(ids.contains(&"container-1".to_string()));
assert!(ids.contains(&"container-2".to_string()));
assert!(ids.contains(&"container-3".to_string()));
}
#[test]
fn test_state_store_list_states() {
let dir = tempfile::tempdir().unwrap();
let store = StateStore::new(dir.path()).unwrap();
for i in 1..=2 {
let mut state = ContainerState::new(
format!("container-{i}"),
PathBuf::from("/bundle"),
PathBuf::from("/rootfs"),
);
state.name = Some(format!("name-{i}"));
store.save(&state).unwrap();
}
let states = store.list_states().unwrap();
assert_eq!(states.len(), 2);
}
#[test]
fn test_state_store_update() {
let dir = tempfile::tempdir().unwrap();
let store = StateStore::new(dir.path()).unwrap();
let mut state = ContainerState::new(
"updatable".to_string(),
PathBuf::from("/bundle"),
PathBuf::from("/rootfs"),
);
store.save(&state).unwrap();
state.mark_created().unwrap();
state.mark_started(9999).unwrap();
store.save(&state).unwrap();
let loaded = store.load("updatable").unwrap();
assert_eq!(loaded.status(), Status::Running);
assert_eq!(loaded.oci_state.pid, Some(9999));
}
#[test]
fn test_state_json_roundtrip() {
let mut state = State::new("roundtrip-test".to_string(), PathBuf::from("/bundle"));
state.pid = Some(12345);
state
.annotations
.insert("test.key".to_string(), "test.value".to_string());
let json = state.to_json().unwrap();
let parsed: State = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.id, state.id);
assert_eq!(parsed.pid, state.pid);
assert_eq!(parsed.annotations, state.annotations);
}
#[test]
fn test_transition_creating_to_stopped_on_error() {
let mut state = State::new("test".to_string(), PathBuf::from("/bundle"));
assert!(state.transition_to(Status::Stopped).is_ok());
assert_eq!(state.status, Status::Stopped);
}
#[test]
fn test_transition_created_to_stopped_without_running() {
let mut state = State::new("test".to_string(), PathBuf::from("/bundle"));
state.transition_to(Status::Created).unwrap();
assert!(state.transition_to(Status::Stopped).is_ok());
assert_eq!(state.status, Status::Stopped);
}
}