Single-shot declarative reconciliation for sysctls — observe, diff, apply once, and return what happened.
This is the mechanism half of declarative config: caller-driven, holds no
long-lived state, owns no process. The long-lived control loop, .conf
parsing, and reload policy stay out — they belong to a consumer (or the
opt-in reconcile loop). Linx.Sysctl remains pure primitives; this module
composes them into the observe → diff → converge triad.
Sysctl is the simplest declarative subsystem — a flat %{key => value} map
with no ordering or identity subtlety — so it is also the proving ground for
the reconcile discipline that the harder subsystems (rtnl) reuse. The
function shapes here (observe/diff/reconcile) deliberately match the
contract a generic reconcile loop will drive.
Desired state
A plain map from dot-form key to the value you want, using the same value
types Linx.Sysctl.write/3 accepts:
%{
"net.ipv4.ip_forward" => 1,
"kernel.printk" => [4, 4, 1, 7],
"kernel.hostname" => "ct0"
}last_applied — three-way ownership
Convergence alone (write where observed differs from desired) is two-way and
needs no history. The third input, last_applied, is what lets reconcile do
the right thing when a key leaves the desired set — the sysctl analogue of
kubectl apply's last-applied-configuration. It is a map:
%{key => %{applied: value_we_wrote, original: value_before_we_touched_it}}It is reconciler-held state: returned in the report as
report.last_applied, threaded by the caller into the next pass, and never
persisted (it dies with the node — see the reconcile design notes). Start
from %{}.
When a key in last_applied is no longer in desired it is released:
- default — the kernel value is left untouched and the op is reported as
{:release, key}(sysctl has no "unset"; we simply stop managing it); - with
revert_on_release: true— we write the captured:originalback, reported as{:revert, key, original}. Off by default because reverting is a surprising side effect and the captured original is only meaningful while the node that captured it is alive.
Strategy
Sysctl writes are independent per key, so a pass is best-effort: every op
is attempted; failures collect in report.failed and never starve the
others. The next pass re-converges anything still wrong (level-triggered:
events are hints, resync is truth).
Example
desired = %{"net.ipv4.ip_forward" => 1, "net.ipv4.conf.all.rp_filter" => 1}
{:ok, r} = Linx.Sysctl.Reconcile.reconcile(desired)
r.converged? #=> true once the kernel matches
r.last_applied #=> feed into the next pass
# later tick, against the same target
{:ok, r2} = Linx.Sysctl.Reconcile.reconcile(desired, r.last_applied)
Summary
Types
Desired state: dot-form key to the value to converge on.
Reconciler-held ownership map, keyed by dot-form sysctl key.
A reconcile op. :set/:revert write; :release is a no-op marker.
Options for reconcile/3 and diff/4
Per-key ownership record. :applied is the value we last wrote; :original
is the value that was there before we first touched the key (or nil if it
was unreadable at capture time).
Functions
Computes the ops that would converge observed to desired, given the
last_applied ownership map. Pure — no I/O.
Reads the current value of each key into a %{key => value} map.
Runs one reconcile pass against the desired state.
Types
@type desired() :: %{optional(Linx.Sysctl.key()) => Linx.Sysctl.value()}
Desired state: dot-form key to the value to converge on.
@type last_applied() :: %{optional(Linx.Sysctl.key()) => ownership()}
Reconciler-held ownership map, keyed by dot-form sysctl key.
@type op() :: {:set, Linx.Sysctl.key(), Linx.Sysctl.value()} | {:revert, Linx.Sysctl.key(), Linx.Sysctl.value()} | {:release, Linx.Sysctl.key()}
A reconcile op. :set/:revert write; :release is a no-op marker.
@type opts() :: [in: Linx.Sysctl.in_target(), revert_on_release: boolean()]
Options for reconcile/3 and diff/4:
:in— target namespace, forwarded verbatim toLinx.Sysctl(:selfdefault,{:pid, n},{:path, p}).:revert_on_release— restore captured originals when a key leaves the desired set (defaultfalse).
@type ownership() :: %{ applied: Linx.Sysctl.value(), original: Linx.Sysctl.value() | nil }
Per-key ownership record. :applied is the value we last wrote; :original
is the value that was there before we first touched the key (or nil if it
was unreadable at capture time).
Functions
@spec diff( %{optional(Linx.Sysctl.key()) => binary()}, desired(), last_applied(), opts() ) :: [op()]
Computes the ops that would converge observed to desired, given the
last_applied ownership map. Pure — no I/O.
Produces:
{:set, key, value}— desired key whose observed value differs (or that is absent fromobserved);{:revert, key, original}— released key, whenrevert_on_release: trueand an original was captured;{:release, key}— released key otherwise.
Order is irrelevant for sysctl; ops are emitted sets-then-releases for a stable, readable result.
@spec observe([Linx.Sysctl.key()], opts()) :: %{ optional(Linx.Sysctl.key()) => binary() }
Reads the current value of each key into a %{key => value} map.
Keys that can't be read (e.g. :enoent, :eacces) are simply absent from
the result — the diff treats an absent desired key as needing a write and
lets the write surface the real error.
@spec reconcile(desired(), last_applied(), opts()) :: {:ok, Linx.Sysctl.Reconcile.Report.t()}
Runs one reconcile pass against the desired state.
Reads the current value of every relevant key (those in desired and those
still owned in last_applied), diffs, applies best-effort, and returns
{:ok, %Report{}}. The report's :last_applied is the updated ownership
map to thread into the next pass.
Never returns {:error, _} for kernel-level failures — per-op errors are
recorded in report.failed, because a partial apply is a normal transient
state the next pass corrects, not a fatal condition. (A malformed :in
option still raises via the underlying verbs.)