8000 feat(minvmd): add lifecycle state machine and state persistence by gominimal-aw-bot[bot] · Pull Request #332 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ codespan-reporting = { version = "0.13", features = ["serialization"] } # Keep i
constcat = "0.6"
copy_dir = "0.1.3"
dirs = "6"
fd-lock = "4"
either = { version = "1.16", default-features = false }
flate2 = "1.1"
futures = "0.3"
Expand Down
7 changes: 7 additions & 0 deletions crates/minvmd/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ publish.workspace = true
anyhow.workspace = true
clap.workspace = true
clap_complete.workspace = true
dirs.workspace = true
fd-lock.workspace = true
libc.workspace = true
serde = { workspace = true }
toml.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true

[dev-dependencies]
tempfile.workspace = true
2 changes: 2 additions & 0 deletions crates/minvmd/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
//! compiles on both platforms.

pub mod error;
pub mod lifecycle;
pub mod state;

#[cfg(target_os = "macos")]
pub mod krun;
Expand Down
298 changes: 298 additions & 0 deletions crates/minvmd/src/lifecycle.rs
C66F
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
//! Pure lifecycle state machine for `minvmd` (R4.7).
//!
//! All state transitions pass through [`next_state`], which has no I/O and is
//! exhaustively tested. Illegal transitions return [`TransitionError`].

use std::fmt;

use serde::{Deserialize, Serialize};

/// The lifecycle states of the `minvmd` daemon.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Lifecycle {
/// No state file exists; daemon has never been provisioned.
NotProvisioned,
/// Daemon is stopped and ready to start.
Stopped,
/// Daemon is in the process of starting (VM booting).
Starting,
/// Daemon is running and the VM is up.
Running,
/// Daemon is shutting down.
Stopping,
}

/// An action that drives a lifecycle transition.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
/// Initialise state storage; `NotProvisioned → Stopped`.
Provision,
/// Begin startup sequence; `Stopped → Starting`.
Start,
/// VM is confirmed up; `Starting → Running`.
MarkRunning,
/// Begin graceful shutdown; `Running → Stopping`.
Stop,
/// Shutdown complete; `Stopping → Stopped`.
MarkStopped,
/// An unexpected failure; `Starting | Running | Stopping → Stopped`.
Fail,
}

/// Error produced by an illegal (`current`, `action`) combination.
#[derive(Debug)]
pub struct TransitionError {
pub current: Lifecycle,
pub action: Action,
}

impl fmt::Display for TransitionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"illegal lifecycle transition: {:?} + {:?}",
self.current, self.action
)
}
}

impl std::error::Error for TransitionError {}

/// Compute the next lifecycle state given `current` and `action` (R4.7).
///
/// This function is pure — no I/O, no side effects. Every caller that wants
/// to persist the new state must write it to disk themselves.
#[must_use = "the resulting state must be persisted"]
pub fn next_state(current: Lifecycle, action: Action) -> Result<Lifecycle, TransitionError> {
use Action::*;
use Lifecycle::*;

match (current, action) {
(NotProvisioned, Provision) => Ok(Stopped),
(Stopped, Start) => Ok(Starting),
(Starting, MarkRunning) => Ok(Running),
(Starting, Fail) => Ok(Stopped),
(Running, Stop) => Ok(Stopping),
(Running, Fail) => Ok(Stopped),
(Stopping, MarkStopped) => Ok(Stopped),
(Stopping, Fail) => Ok(Stopped),
_ => Err(TransitionError { current, action }),
}
}

#[cfg(test)]
mod tests {
use super::*;

struct Case {
current: Lifecycle,
action: Action,
expected: Result<Lifecycle, ()>,
}

fn ok(s: Lifecycle) -> Result<Lifecycle, ()> {
Ok(s)
}
fn err() -> Result<Lifecycle, ()> {
Err(())
}

fn run(cases: &[Case]) {
for c in cases {
let got = next_state(c.current, c.action).map_err(|_| ());
assert_eq!(
got, c.expected,
"next_state({:?}, {:?})",
c.current, c.action
);
}
}

#[test]
fn legal_transitions() {
use Action::*;
use Lifecycle::*;

run(&[
Case {
current: NotProvisioned,
action: Provision,
expected: ok(Stopped),
},
Case {
current: Stopped,
action: Start,
expected: ok(Starting),
},
Case {
current: Starting,
action: MarkRunning,
expected: ok(Running),
},
Case {
current: Starting,
action: Fail,
expected: ok(Stopped),
},
Case {
current: Running,
action: Stop,
expected: ok(Stopping),
},
Case {
current: Running,
action: Fail,
expected: ok(Stopped),
},
Case {
current: Stopping,
action: MarkStopped,
expected: ok(Stopped),
},
Case {
current: Stopping,
action: Fail,
expected: ok(Stopped),
},
]);
}

#[test]
fn illegal_transitions() {
use Action::*;
use Lifecycle::*;

run(&[
// Can't re-provision once initialised
Case {
current: Stopped,
action: Provision,
expected: err(),
},
Case {
current: Starting,
action: Provision,
expected: err(),
},
Case {
current: Running,
action: Provision,
expected: err(),
},
Case {
current: Stopping,
action: Provision,
expected: err(),
},
// Can't start from non-Stopped states
Case {
current: NotProvisioned,
action: Start,
expected: err(),
},
Case {
current: Starting,
action: Start,
expected: err(),
},
Case {
current: Running,
action: Start,
expected: err(),
},
Case {
current: Stopping,
action: Start,
expected: err(),
},
// MarkRunning only valid from Starting
Case {
current: NotProvisioned,
action: MarkRunning,
expected: err(),
},
Case {
current: Stopped,
action: MarkRunning,
expected: err(),
},
Case {
current: Running,
action: MarkRunning,
expected: err(),
},
Case {
current: Stopping,
action: MarkRunning,
expected: err(),
},
// Stop only valid from Running
Case {
current: NotProvisioned,
action: Stop,
expected: err(),
},
Case {
current: Stopped,
action: Stop,
expected: err(),
},
Case {
current: Starting,
action: Stop,
expected: err(),
},
Case {
current: Stopping,
action: Stop,
expected: err(),
},
// MarkStopped only valid from Stopping
Case {
current: NotProvisioned,
action: MarkStopped,
expected: err(),
},
Case {
current: Stopped,
action: MarkStopped,
expected: err(),
},
Case {
current: Starting,
action: MarkStopped,
expected: err(),
},
Case {
current: Running,
action: MarkStopped,
expected: err(),
},
// Fail not valid from NotProvisioned or Stopped
Case {
current: NotProvisioned,
action: Fail,
expected: err(),
},
Case {
current: Stopped,
action: Fail,
expected: err(),
},
]);
}

#[test]
fn transition_error_display() {
let err = TransitionError {
current: Lifecycle::Stopped,
action: Action::MarkRunning,
};
let s = format!("{err}");
assert!(s.contains("illegal"), "display: {s}");
assert!(s.contains("Stopped"), "display: {s}");
assert!(s.contains("MarkRunning"), "display: {s}");
}
}
Loading
Loading
0