diff --git a/states.md b/states.md new file mode 100644 index 0000000..8e70eb2 --- /dev/null +++ b/states.md @@ -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, // 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, +} +``` + +## 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 = 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, 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 = 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.