some implementing of states and their framework

This commit is contained in:
Linus Vogel 2026-09-05 12:23:15 +02:00
parent 47eebc7be3
commit cf563d437c
2 changed files with 518 additions and 1 deletions

View File

@ -0,0 +1,494 @@
//! Generic, state-agnostic helpers that operate on [`State`] values.
//!
//! These are deliberately independent of any particular state function
//! (file, service, package, ...) so they apply to every state the same way.
//! They provide:
//! * topological ordering of states for application, and
//! * conditional links deciding whether a state should run based on the
//! outcome of the states it depends on.
use super::{State, StateDirective};
use std::collections::{HashMap, HashSet, VecDeque};
/// The outcome of applying a single state. Used to evaluate conditional gates:
/// whether the state succeeded and whether it reported changes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StateOutcome {
pub success: bool,
pub changed: bool,
}
impl StateOutcome {
pub const fn success_unchanged() -> Self {
Self {
success: true,
changed: false,
}
}
pub const fn success_changed() -> Self {
Self {
success: true,
changed: true,
}
}
pub const fn failure_unchanged() -> Self {
Self {
success: false,
changed: false,
}
}
pub const fn failure_changed() -> Self {
Self {
success: false,
changed: true,
}
}
}
/// How a set of target states gates the state that declares the link.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GateRule {
/// Run only if *every* target succeeded (`require`, `require_in`).
AllSucceeded,
/// Run only if *at least one* target succeeded (`require_any`).
AnySucceeded,
/// Run only if *at least one* target failed (`onfail`, `onfail_in`).
AnyFailed,
/// Run only if *at least one* target failed **and** reported changes
/// (`onfailchanges`, `onfailchanges_in`).
AnyFailedChanged,
/// Run only if *at least one* target reported changes (`watch`, `onchanges`
/// and their `_in` / `_any` forms).
AnyChanged,
}
/// A conditional link: this state should only be applied when the rule holds
/// against the recorded outcomes of its target states.
#[derive(Debug, Clone, PartialEq)]
pub struct Gate {
pub rule: GateRule,
pub targets: Vec<String>,
}
impl Gate {
/// Returns `true` when the rule is satisfied by the given outcomes.
///
/// A target with no recorded outcome never satisfies the rule.
pub fn is_satisfied(&self, outcomes: &HashMap<String, StateOutcome>) -> bool {
match self.rule {
GateRule::AllSucceeded => self
.targets
.iter()
.all(|t| outcomes.get(t).is_some_and(|o| o.success)),
GateRule::AnySucceeded => self
.targets
.iter()
.any(|t| outcomes.get(t).is_some_and(|o| o.success)),
GateRule::AnyFailed => self
.targets
.iter()
.any(|t| outcomes.get(t).is_some_and(|o| !o.success)),
GateRule::AnyFailedChanged => self
.targets
.iter()
.any(|t| outcomes.get(t).is_some_and(|o| !o.success && o.changed)),
GateRule::AnyChanged => self
.targets
.iter()
.any(|t| outcomes.get(t).is_some_and(|o| o.changed)),
}
}
}
/// A state in its scheduled position, together with the conditional links that
/// decide whether it should actually run once its predecessors have applied.
#[derive(Debug, Clone, PartialEq)]
pub struct PlannedState {
pub id: String,
/// Ids of states that must be applied before this one (ordering only).
pub predecessors: Vec<String>,
/// Conditional links; all must be satisfied for the state to run.
pub gates: Vec<Gate>,
}
impl PlannedState {
/// Returns `true` when every gate is satisfied by the given outcomes.
pub fn should_apply(&self, outcomes: &HashMap<String, StateOutcome>) -> bool {
self.gates.iter().all(|g| g.is_satisfied(outcomes))
}
}
/// Errors that can occur while planning state application.
#[derive(Debug, Clone, PartialEq)]
pub enum StatePlanError {
/// A set of states that form a dependency cycle and cannot be ordered.
Cycle(Vec<String>),
/// A dependency references a state id that is not part of the input.
UnknownState(String),
}
impl std::fmt::Display for StatePlanError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StatePlanError::Cycle(states) => {
write!(f, "dependency cycle among states: {}", states.join(", "))
}
StatePlanError::UnknownState(id) => write!(f, "dependency on unknown state id: {id}"),
}
}
}
impl std::error::Error for StatePlanError {}
/// Topologically sorts the states, returning their ids in the order they
/// should be applied.
///
/// Fails if the dependencies form a cycle or reference a state id that is not
/// present in the input.
pub fn topological_sort(states: &[State]) -> Result<Vec<String>, StatePlanError> {
build_graph(states).map(|(order, _)| order)
}
/// Topologically sorts the states and attaches the conditional links to each
/// one, returning the full application plan in scheduling order.
pub fn plan(states: &[State]) -> Result<Vec<PlannedState>, StatePlanError> {
let (order, predecessors) = build_graph(states)?;
let gates = gates_by_state(states);
Ok(order
.into_iter()
.map(|id| PlannedState {
id: id.clone(),
predecessors: predecessors.get(&id).cloned().unwrap_or_default(),
gates: gates.get(&id).cloned().unwrap_or_default(),
})
.collect())
}
/// Collects the conditional gate for each state.
///
/// A forward directive on a state gates that state itself on its targets; a
/// reverse (`*_in`) directive on a state gates each of its targets on that
/// state instead. `use`-style directives produce no gate.
fn gates_by_state(states: &[State]) -> HashMap<String, Vec<Gate>> {
let mut gates: HashMap<String, Vec<Gate>> =
states.iter().map(|s| (s.id.clone(), Vec::new())).collect();
for state in states {
for dep in &state.dependency {
let rule = match rule_for(&dep.directive) {
Some(rule) => rule,
None => continue,
};
if is_reverse(&dep.directive) {
for target in &dep.target {
if let Some(target_gates) = gates.get_mut(target) {
target_gates.push(Gate {
rule,
targets: vec![state.id.clone()],
});
}
}
} else if let Some(self_gates) = gates.get_mut(&state.id) {
self_gates.push(Gate {
rule,
targets: dep.target.clone(),
});
}
}
}
gates
}
impl State {
/// Every state id referenced by this state's dependencies, regardless of
/// the directive used.
pub fn referenced_states(&self) -> Vec<String> {
self.dependency
.iter()
.flat_map(|d| d.target.iter().cloned())
.collect()
}
}
/// A topological ordering of state ids paired with the immediate predecessors
/// of each state.
type OrderAndPredecessors = (Vec<String>, HashMap<String, Vec<String>>);
/// Builds the dependency graph and returns a topological ordering together
/// with the immediate predecessors of each state.
fn build_graph(states: &[State]) -> Result<OrderAndPredecessors, StatePlanError> {
let ids: HashSet<String> = states.iter().map(|s| s.id.clone()).collect();
let mut in_degree: HashMap<String, usize> =
states.iter().map(|s| (s.id.clone(), 0usize)).collect();
let mut adjacency: HashMap<String, Vec<String>> =
states.iter().map(|s| (s.id.clone(), Vec::new())).collect();
let mut predecessors: HashMap<String, Vec<String>> =
states.iter().map(|s| (s.id.clone(), Vec::new())).collect();
for state in states {
for dep in &state.dependency {
if !orders(&dep.directive) {
continue; // `use`-style directives do not impose an ordering
}
let reverse = is_reverse(&dep.directive);
for target in &dep.target {
if !ids.contains(target) {
return Err(StatePlanError::UnknownState(target.clone()));
}
let (from, to) = if reverse {
(state.id.clone(), target.clone())
} else {
(target.clone(), state.id.clone())
};
adjacency
.get_mut(&from)
.expect("id must be part of the input")
.push(to.clone());
*in_degree.get_mut(&to).expect("id must be part of the input") += 1;
predecessors
.get_mut(&to)
.expect("id must be part of the input")
.push(from.clone());
}
}
}
// Deterministic start: sort the initially ready states.
let mut ready: Vec<String> = in_degree
.iter()
.filter(|(_, deg)| **deg == 0)
.map(|(id, _)| id.clone())
.collect();
ready.sort();
let mut queue: VecDeque<String> = ready.into_iter().collect();
let mut order: Vec<String> = Vec::with_capacity(states.len());
while let Some(id) = queue.pop_front() {
order.push(id.clone());
for next in adjacency.get(&id).into_iter().flatten().cloned() {
let deg = in_degree.get_mut(&next).expect("id must be part of the input");
*deg -= 1;
if *deg == 0 {
queue.push_back(next);
}
}
}
if order.len() != states.len() {
let remaining: Vec<String> = in_degree
.iter()
.filter(|(_, deg)| **deg > 0)
.map(|(id, _)| id.clone())
.collect();
return Err(StatePlanError::Cycle(remaining));
}
Ok((order, predecessors))
}
/// Returns `true` for the reverse (`*_in`) ordering directives, which declare
/// that the current state is a prerequisite of the target rather than the
/// other way around.
fn is_reverse(d: &StateDirective) -> bool {
matches!(
d,
StateDirective::RequireIn
| StateDirective::WatchIn
| StateDirective::OnChangesIn
| StateDirective::OnFailIn
| StateDirective::OnFailChangesIn
)
}
/// Returns `true` when the directive imposes an ordering on the states.
fn orders(d: &StateDirective) -> bool {
rule_for(d).is_some()
}
/// Maps a directive to the conditional gate it produces.
///
/// Returns `None` for `use`-style directives, which only share data and
/// neither order the states nor condition their execution.
fn rule_for(d: &StateDirective) -> Option<GateRule> {
match d {
StateDirective::Require | StateDirective::RequireIn => Some(GateRule::AllSucceeded),
StateDirective::RequireAny => Some(GateRule::AnySucceeded),
StateDirective::Watch
| StateDirective::WatchIn
| StateDirective::OnChanges
| StateDirective::OnChangesIn
| StateDirective::WatchAny
| StateDirective::OnChangesAny => Some(GateRule::AnyChanged),
StateDirective::OnFail | StateDirective::OnFailIn | StateDirective::OnFailAny => {
Some(GateRule::AnyFailed)
}
StateDirective::OnFailChanges | StateDirective::OnFailChangesIn => {
Some(GateRule::AnyFailedChanged)
}
StateDirective::Use | StateDirective::UseIn | StateDirective::UseAny => None,
}
}
#[cfg(test)]
mod tests {
use super::{plan, topological_sort, GateRule, StateOutcome, StatePlanError};
use crate::sls::state::package::PackageState;
use crate::sls::state::{State, StateDependency, StateDirective, StateFunction};
use std::collections::HashMap;
fn dep(directive: StateDirective, targets: &[&str]) -> StateDependency {
StateDependency {
directive,
target: targets.iter().map(|t| t.to_string()).collect(),
}
}
fn state(id: &str, dependency: Vec<StateDependency>) -> State {
State {
id: String::from(id),
function: StateFunction::Package(PackageState::Installed {
name: String::from(id),
}),
dependency,
}
}
#[test]
fn require_orders_target_before_dependent() {
let states = vec![
state("a", vec![dep(StateDirective::Require, &["b"])]),
state("b", vec![]),
];
assert_eq!(
topological_sort(&states).unwrap(),
vec!["b".to_string(), "a".to_string()]
);
}
#[test]
fn require_in_orders_reverse() {
let states = vec![
state("a", vec![dep(StateDirective::RequireIn, &["b"])]),
state("b", vec![]),
];
assert_eq!(
topological_sort(&states).unwrap(),
vec!["a".to_string(), "b".to_string()]
);
let planned = plan(&states).unwrap();
let b = planned.iter().find(|p| p.id == "b").unwrap();
assert_eq!(b.predecessors, vec!["a".to_string()]);
assert_eq!(b.gates[0].rule, GateRule::AllSucceeded);
assert_eq!(b.gates[0].targets, vec!["a".to_string()]);
let a = planned.iter().find(|p| p.id == "a").unwrap();
assert!(a.gates.is_empty());
}
#[test]
fn use_is_neither_ordering_nor_gate() {
let states = vec![
state("a", vec![dep(StateDirective::Use, &["b"])]),
state("b", vec![]),
];
let a = plan(&states)
.unwrap()
.into_iter()
.find(|p| p.id == "a")
.unwrap();
assert!(a.predecessors.is_empty());
assert!(a.gates.is_empty());
}
#[test]
fn onfail_gate_depends_on_outcome() {
let states = vec![
state("a", vec![dep(StateDirective::OnFail, &["b"])]),
state("b", vec![]),
];
let a = plan(&states)
.unwrap()
.into_iter()
.find(|p| p.id == "a")
.unwrap();
assert!(!a.should_apply(&HashMap::new()));
let mut o = HashMap::new();
o.insert("b".to_string(), StateOutcome::failure_unchanged());
assert!(a.should_apply(&o));
o.insert("b".to_string(), StateOutcome::success_changed());
assert!(!a.should_apply(&o));
}
#[test]
fn onfailchanges_requires_changed_failure() {
let states = vec![
state("a", vec![dep(StateDirective::OnFailChanges, &["b"])]),
state("b", vec![]),
];
let a = plan(&states)
.unwrap()
.into_iter()
.find(|p| p.id == "a")
.unwrap();
let mut o = HashMap::new();
o.insert("b".to_string(), StateOutcome::failure_changed());
assert!(a.should_apply(&o));
o.insert("b".to_string(), StateOutcome::failure_unchanged());
assert!(!a.should_apply(&o));
}
#[test]
fn require_any_runs_when_one_target_succeeds() {
let states = vec![
state("a", vec![dep(StateDirective::RequireAny, &["b", "c"])]),
state("b", vec![]),
state("c", vec![]),
];
let a = plan(&states)
.unwrap()
.into_iter()
.find(|p| p.id == "a")
.unwrap();
let mut o = HashMap::new();
o.insert("b".to_string(), StateOutcome::failure_unchanged());
o.insert("c".to_string(), StateOutcome::success_unchanged());
assert!(a.should_apply(&o));
o.insert("c".to_string(), StateOutcome::failure_unchanged());
assert!(!a.should_apply(&o));
}
#[test]
fn detects_cycle() {
let states = vec![
state("a", vec![dep(StateDirective::Require, &["b"])]),
state("b", vec![dep(StateDirective::Require, &["a"])]),
];
assert!(matches!(
topological_sort(&states),
Err(StatePlanError::Cycle(_))
));
}
#[test]
fn detects_unknown_target() {
let states = vec![state("a", vec![dep(StateDirective::Require, &["missing"])])];
assert!(matches!(
topological_sort(&states),
Err(StatePlanError::UnknownState(id)) if id == "missing"
));
}
}

View File

@ -11,8 +11,10 @@ pub mod package;
pub mod command;
pub mod user;
pub mod group;
pub mod generic_implementations;
pub struct State {
pub id: String,
pub function: StateFunction,
pub dependency: Vec<StateDependency>,
}
@ -26,6 +28,27 @@ pub enum StateFunction {
Group (GroupState),
}
pub enum StateDependency {
pub struct StateDependency {
pub directive: StateDirective,
pub target: Vec<String>,
}
pub enum StateDirective {
Require,
Watch,
OnChanges,
OnFail,
OnFailChanges,
Use,
RequireIn,
WatchIn,
OnChangesIn,
OnFailIn,
OnFailChangesIn,
UseIn,
RequireAny,
WatchAny,
OnChangesAny,
OnFailAny,
UseAny,
}