keel/states.md

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.rsState, StateFunction, StateDependency, and StateDirective.
  • Six state function types, each in its own module:
    • file.rsFileState
    • service.rsServiceState
    • package.rsPackageState
    • command.rsCommandState
    • user.rsUserState
    • group.rsGroupState
  • 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.

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:

  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 BA gets a gate whose targets are B's ids.
  • Reverse directive on A about BB 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.

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 *_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.rsState, StateFunction, StateDependency, StateDirective.
  • src/sls/state/{file,service,package,command,user,group}.rs — state function types.
  • src/sls/state/generic_implementations.rsStateOutcome, GateRule, Gate, PlannedState, StatePlanError, topological_sort, plan, and the unit tests.