9.9 KiB
States — The State Model & Dependency Graph
This document describes the state layer of Keel: the data model that represents a desired state, the directives that relate states to one another, and the generic planning engine that turns a set of states into an ordered, gated application plan.
It lives in src/sls/state/.
What was built
- The core state model in
src/sls/state/mod.rs—State,StateFunction,StateDependency, andStateDirective. - Six state function types, each in its own module:
file.rs→FileStateservice.rs→ServiceStatepackage.rs→PackageStatecommand.rs→CommandStateuser.rs→UserStategroup.rs→GroupState
- A generic planning engine in
src/sls/state/generic_implementations.rsthat is deliberately independent of any particular state function. It builds the dependency graph, topologically orders the states, derives conditional gates from the directives, and validates the graph. - A test suite (8 tests) covering ordering, reverse directives,
use, the various gate rules, cycle detection, and unknown-target detection. All pass viacargo test.
The state model
A State is the unit of desired state. It has an identity, a function describing what
to enforce, and a list of dependencies relating it to other states.
pub struct State {
pub id: String, // unique id, used by other states to reference this one
pub function: StateFunction, // what to enforce (file, service, package, ...)
pub dependency: Vec<StateDependency>, // how this state relates to other states
}
StateFunction is a closed enum of the supported state types:
pub enum StateFunction {
File (FileState),
Service (ServiceState),
Package (PackageState),
Command (CommandState),
User (UserState),
Group (GroupState),
}
Each variant wraps a type-specific enum whose variants express the desired outcome
(e.g. PackageState::Installed { name }, ServiceState::Running { enable },
FileState::Managed { source, user, group, mode, contents, template, clean, clean_mode }).
A dependency pairs a directive (the relationship) with one or more targets (the ids of the states it points at):
pub struct StateDependency {
pub directive: StateDirective,
pub target: Vec<String>,
}
How the dependency graph is structured
The graph is derived entirely from the dependency lists; it is never stored explicitly.
It has two concerns that are handled by two different mechanisms:
- Ordering — which states must be applied before which others. This is what the graph's edges represent, and it is solved with a topological sort.
- Gating — whether a state should run at all, based on the outcomes of the states it is linked to. This is not part of the sort order; it is evaluated at apply time against recorded outcomes.
Nodes and edges
- Node = a state, identified by its
id. - Edge = "must be applied before" (
from → to). It is added only by ordering directives.use-style directives add no edge.
Forward vs. reverse directives
A directive declared on state A about target B can be written from either side:
- Forward (
require,watch, ...):Adepends onB.Bruns first and gatesA. - Reverse (
require_in,watch_in, ...):Ais a prerequisite ofB.Aruns first and gatesB.
A require B and B require_in A describe the exact same relationship — same edge
direction, same gate. The reverse forms exist purely for authoring convenience.
use-style directives
use, use_in, and use_any share data between states. They impose no ordering and
no gate. In the graph they are a no-op.
Building the graph (build_graph)
For every ordering directive, build_graph records:
- an adjacency entry
from → to, - an increment to the in-degree of
to, fromas an immediate predecessor ofto.
from/to are chosen by the directive's direction:
| Declared on | Directive style | from |
to |
|---|---|---|---|
state A, target B |
forward | B |
A |
state A, target B |
reverse (*_in) |
A |
B |
It also validates that every referenced target actually exists in the input, returning
StatePlanError::UnknownState otherwise.
Topological sort (Kahn's algorithm)
The ready set (in-degree 0) is sorted before being queued, so the produced order is deterministic for a given input. States are emitted as their in-degree drops to zero.
If the number of emitted states is less than the input size, the remaining states form a
cycle and the result is StatePlanError::Cycle(states).
Gates (gates_by_state)
Each directive that maps to a GateRule becomes a Gate:
- Forward directive on
AaboutB→Agets a gate whose targets areB's ids. - Reverse directive on
AaboutB→Bgets a gate whose targets are[A]. usedirectives produce no gate.
A state runs only if all of its gates are satisfied.
What dependencies (directives) exist
StateDirective has 17 variants. Their effect on ordering and gating:
| Directive | Ordering | Gate rule | Meaning |
|---|---|---|---|
require |
yes | AllSucceeded |
Run only if all targets succeeded. |
require_any |
yes | AnySucceeded |
Run if at least one target succeeded. |
watch |
yes | AnyChanged |
Run if any target reported changes. |
onchanges |
yes | AnyChanged |
Run if any target reported changes. |
onfail |
yes | AnyFailed |
Run if any target failed. |
onfailchanges |
yes | AnyFailedChanged |
Run if any target failed and changed. |
use |
no | none | Share data only. |
require_in |
yes | AllSucceeded |
Reverse of require. |
watch_in |
yes | AnyChanged |
Reverse of watch. |
onchanges_in |
yes | AnyChanged |
Reverse of onchanges. |
onfail_in |
yes | AnyFailed |
Reverse of onfail. |
onfailchanges_in |
yes | AnyFailedChanged |
Reverse of onfailchanges. |
use_in |
no | none | Reverse use; data sharing only. |
watch_any |
yes | AnyChanged |
watch with any-target semantics. |
onchanges_any |
yes | AnyChanged |
onchanges with any-target semantics. |
onfail_any |
yes | AnyFailed |
onfail with any-target semantics. |
use_any |
no | none | use with any-target semantics. |
GateRule summarizes the gating semantics evaluated against recorded outcomes:
AllSucceeded— every target succeeded.AnySucceeded— at least one target succeeded.AnyFailed— at least one target failed.AnyFailedChanged— at least one target failed and changed.AnyChanged— at least one target changed.
A target with no recorded outcome never satisfies a gate.
How a user of the framework interacts with this code
1. Describe the desired state as State values
Give each state a stable id, a function, and the directives that relate it to others.
Only ids that appear in the same plan may be referenced.
let states: Vec<State> = vec![
State {
id: "nginx.package".into(),
function: StateFunction::Package(PackageState::Installed { name: "nginx".into() }),
dependency: vec![],
},
State {
id: "nginx.service".into(),
function: StateFunction::Service(ServiceState::Running { enable: Some(true) }),
dependency: vec![StateDependency {
directive: StateDirective::Require,
target: vec!["nginx.package".into()],
}],
},
];
2. Produce a plan
plan returns the states in scheduling order with their predecessors and gates attached.
It is the single entry point a caller needs; topological_sort is the lighter-weight
variant that returns just the ordered ids.
use keel::sls::state::generic_implementations::{plan, StateOutcome};
let planned = plan(&states)?; // Vec<PlannedState>, already in apply order
Both return Result<_, StatePlanError>. Handle the two errors:
StatePlanError::Cycle(states)— the directives form a cycle; fix the graph.StatePlanError::UnknownState(id)— a directive references an id not in the plan.
3. Apply in order, gating on outcomes
Walk the plan in order. Before applying each state, check its gates against the outcomes recorded so far. After applying, record the outcome so later gates can see it.
use std::collections::HashMap;
let mut outcomes: HashMap<String, StateOutcome> = HashMap::new();
for p in planned {
if !p.should_apply(&outcomes) {
// Skipped: a gate was not satisfied. Optionally record a no-op outcome.
continue;
}
// ... apply the state (enforce the function) and observe the result ...
let outcome = StateOutcome { success: true, changed: true };
outcomes.insert(p.id.clone(), outcome);
}
StateOutcome captures the two facts gates reason about — success and changed — and
provides convenience constructors: success_unchanged, success_changed,
failure_unchanged, failure_changed.
Rules of thumb
- Reference states only by ids present in the same plan.
- Prefer forward directives; use
*_inonly when it reads better on the prerequisite side. They are equivalent. - A state with multiple gates must satisfy all of them to run.
use/use_in/use_anynever change order or gating — they exist for data sharing, which the execution layer (not the planner) is expected to honor.- Keep the planner free of side effects: it only orders and gates; applying a state is always the caller's responsibility.
Reference
src/sls/state/mod.rs—State,StateFunction,StateDependency,StateDirective.src/sls/state/{file,service,package,command,user,group}.rs— state function types.src/sls/state/generic_implementations.rs—StateOutcome,GateRule,Gate,PlannedState,StatePlanError,topological_sort,plan, and the unit tests.