added some documentatio

This commit is contained in:
Linus Vogel 2026-09-05 12:31:02 +02:00
parent cf563d437c
commit 19d5911f1e

255
states.md Normal file
View File

@ -0,0 +1,255 @@
# 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`, and `StateDirective`.
- **Six state function types**, each in its own module:
- `file.rs``FileState`
- `service.rs``ServiceState`
- `package.rs``PackageState`
- `command.rs``CommandState`
- `user.rs``UserState`
- `group.rs``GroupState`
- **A generic planning engine** in
`src/sls/state/generic_implementations.rs` that 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 via `cargo 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.
```rust
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:
```rust
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):
```rust
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:
1. **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.
2. **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`, ...): `A` depends on `B`. `B` runs first and gates `A`.
- **Reverse** (`require_in`, `watch_in`, ...): `A` is a prerequisite of `B`. `A` runs
first and gates `B`.
`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`,
- `from` as an immediate predecessor of `to`.
`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 `A` about `B``A` gets a gate whose targets are `B`'s ids.
- **Reverse** directive on `A` about `B``B` gets a gate whose targets are `[A]`.
- **`use`** directives 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.
```rust
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.
```rust
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.
```rust
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 `*_in` only 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_any` never 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.