some implementing of states and their framework
This commit is contained in:
parent
47eebc7be3
commit
cf563d437c
494
src/sls/state/generic_implementations.rs
Normal file
494
src/sls/state/generic_implementations.rs
Normal 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"
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user