A tour of Linx's declarative-configuration surface: you describe the kernel state you want, and a reconciler diffs it against what the kernel actually has and converges the two — idempotent and self-healing, so manual drift, crashes, and reboots are corrected on the next pass.
This is the overview. Each subsystem keeps its own hands-on examples in its
EXAMPLES.md; the per-subsystem detail is not repeated here. This page covers
the shared model, the generic opt-in loop (Linx.Reconcile), the plug-in
contract (Linx.Reconcile.Source), and how the pieces fit together. The design
and rationale live in reconcile-overview.md and the
Linx.Reconcile moduledoc.
The shared model
Reconciliation is mechanism in Linx, policy in the consumer. Linx gives you the verbs; the desired-state source of truth, the cadence, and the supervision are yours.
Every reconcilable subsystem rhymes on the same four verbs (the template is
Linx.Netfilter, which had them first — see docs/netfilter/netfilter-examples.md):
- observe — read current kernel state into plain values;
- diff — compute the minimal set of create/update/delete ops;
- reconcile (
push) — apply that diff in one caller-driven pass; - subscribe — a Monitor that says "look now" when something changes.
Two principles run through all of it:
- Events are hints; resync is truth. A Monitor is a latency layer: any
event (or an
ENOBUFS:resync_needed) means "re-read and re-diff full state", never "apply this delta". Correctness rests on the periodic resync, not on catching every event. - Delete only what you own. Where the kernel tags ownership (routes carry
rtm_protocol), the diff is two-way — observed-filtered-to-our-tag vs desired. Where it does not (addresses, sysctls, cgroup knobs), the diff is three-way: a reconciler-heldlast_appliedset records what we installed, so foreign state that merely appeared is left untouched. You threadlast_appliedfrom each pass into the next.
The reconcilable subsystems at a glance
| Subsystem | Single-shot module | scope | Ownership | Monitor |
|---|---|---|---|---|
| netfilter | Linx.Netfilter (push(mode: :reconcile)) | a table | socket-owned + genID CAS | yes |
| rtnl | Linx.Netlink.Rtnl.Reconcile | an open socket / netns | routes two-way, rest three-way | yes |
| sysctl | Linx.Sysctl.Reconcile | the :in target | three-way | no |
| cgroup limits | Linx.Cgroup.Reconcile | a cgroup path | three-way | no |
Anything not in this table (capabilities, seccomp, uid maps, mount, tty) is not reconciled state — it is spec applied once at spawn (re-applied verbatim on restart by the supervisor), or a runtime channel — so it carries no observe/diff loop.
Single-shot reconcile, by hand
The smallest real loop needs nothing but the single-shot verb and a variable to
thread last_applied through. Here it is for sysctl:
alias Linx.Sysctl.Reconcile
desired = %{"net.ipv4.ip_forward" => 1, "net.ipv4.conf.all.rp_filter" => 1}
# First pass: writes whatever the kernel doesn't already match.
{:ok, r} = Reconcile.reconcile(desired)
r.converged? #=> true once the kernel matches
r.applied #=> the ops that ran this pass
r.last_applied #=> thread this into the next pass
# A second pass against the same desired state is a no-op.
{:ok, r2} = Reconcile.reconcile(desired, r.last_applied)
r2.applied #=> []Every pass returns a uniform report — converged?, applied, failed,
pending, last_applied — whichever subsystem produced it. The strategy
differs by subsystem and is visible in the report:
- independent ops (sysctl, cgroup) are best-effort — every op is
attempted, failures collect in
failed,pendingstays empty; - ordered ops (rtnl) are fail-fast — the pass stops at the first failure
(
failedholds it), and the ops it had not reached becomepending.
Either way a partial apply is a normal transient state the next pass corrects; there is no rollback.
The same shape, per subsystem (full examples on each page):
# rtnl — authored by interface name; routes owned by rtm_protocol.
{:ok, sock} = Linx.Netlink.Rtnl.open()
{:ok, r} =
Linx.Netlink.Rtnl.Reconcile.reconcile(sock, %{
addresses: [{"eth0", "10.0.0.2", 24}],
routes: [{:default, "10.0.0.1"}]
})
# cgroup limits — a flat map of interface file => value.
{:ok, r} =
Linx.Cgroup.Reconcile.reconcile("/sys/fs/cgroup/myorg/web-42", %{
"memory.max" => 256 * 1024 * 1024,
"pids.max" => 100,
"cpu.max" => {50_000, 100_000}
})See docs/sysctl/sysctl-examples.md, docs/netlink/netlink-examples.md, and
docs/cgroup/cgroup-examples.md for the subsystem-specific detail (value shapes,
revert_on_release, route options, namespaces).
Diffing without applying
The diff is pure and exposed on its own, for inspection or a custom apply strategy. For rtnl:
alias Linx.Netlink.Rtnl.Diff
# Two-way for routes (ownership is the rtm_protocol tag):
Diff.routes(desired_routes, observed_routes, _protocol = 76)
#=> [{:create, %Route{...}}, {:update, %Route{...}}, {:delete, %Route{...}}]
# Three-way for addresses (ownership is the last-applied key set):
Diff.addresses(desired_addrs, observed_addrs, owned_keys)A {:create, item}/{:update, item} carries the desired struct; a
{:delete, item} carries the observed one (it holds the kernel's handle).
Watching for change: the Monitor
Linx.Netlink.Rtnl.Monitor is the ip monitor equivalent — it forwards each
rtnetlink change to an owner as a wake-up hint:
{:ok, mon} = Linx.Netlink.Rtnl.Monitor.subscribe()
# the owner receives, for any change in the namespace:
# {:linx_rtnl, :event, %Linx.Netlink.Rtnl.Monitor.Event{...}}
# {:linx_rtnl, :resync_needed} (on ENOBUFS — the stream is lossy)
Linx.Netlink.Rtnl.Monitor.unsubscribe(mon)You rarely wire this up by hand — the loop below does it for you. See
docs/netlink/netlink-examples.md for the standalone Monitor.
Linx.Reconcile — the opt-in loop
Threading last_applied on a timer and re-syncing on a Monitor hint is a ~15-line
pattern you could write yourself. Linx.Reconcile is that loop, packaged — a
GenServer you add to your own supervision tree. It is deliberately thin and
genuinely opt-in: nothing runs unless you start it, there is no Application
boot side effect and no singleton, and it drives exactly one subsystem in
one namespace (the cross-subsystem composite stays in the consumer — see below).
It is especially useful for the simplest consumer — a plain host-config app with no supervision tree of its own to roll a loop from.
children = [
{Linx.Reconcile,
source: Linx.Sysctl.Reconcile.Source,
scope: :self,
desired: %{"net.ipv4.ip_forward" => 1},
name: :host_sysctls}
]
Supervisor.start_link(children, strategy: :one_for_one)Or for rtnl, continuously converging a container's namespace by pid, woken by the Monitor and re-syncing every 30 s as the safety net:
{Linx.Reconcile,
source: Linx.Netlink.Rtnl.Reconcile.Source,
scope: {:pid, container_pid},
desired: %{
addresses: [{"eth0", "10.0.0.2", 24}],
routes: [{:default, "10.0.0.1"}]
},
interval: :timer.seconds(30)}Options
:source(required) — a module implementingLinx.Reconcile.Source.:scope(required) — the namespace handle, in that source's shape (sysctl: the:intarget; rtnl: a netns; cgroup: a cgroup path).:desired(required) — the desired state, in that source's shape.:reconcile_opts— keyword forwarded verbatim to the source (e.g. rtnl's:protocol, sysctl's:revert_on_release).:last_applied— initial ownership state (default%{}).:interval— timer-resync period in ms, or:infinityto rely solely on the Monitor (default5_000).:monitor— subscribe to the source's Monitor for low-latency wakeups (defaulttrue; a source that returns:unsupportedfalls back to timer-only transparently).:debounce— ms to coalesce a burst of hints (or aput_desired) into one pass (default100).:owner— a pid to notify after each pass.:name— aGenServername to register under.
Driving it at runtime
{:ok, pid} =
Linx.Reconcile.start_link(
source: Linx.Cgroup.Reconcile.Source,
scope: "/sys/fs/cgroup/myorg/web-42",
desired: %{"pids.max" => 100}
)
# Force a pass now and inspect its report (handy in tests).
{:ok, report} = Linx.Reconcile.reconcile(pid)
# Swap the desired state; a debounced pass converges on it.
:ok = Linx.Reconcile.put_desired(pid, %{"pids.max" => 200})
# The most recent report (nil before the first pass completes).
Linx.Reconcile.last_report(pid)
Linx.Reconcile.stop(pid)With an :owner set, each pass notifies it:
{Linx.Reconcile,
source: Linx.Sysctl.Reconcile.Source, scope: :self,
desired: %{"net.ipv4.ip_forward" => 1}, owner: self()}
# the owner receives, after every pass:
# {:linx_reconcile, :report, %{converged?: true, ...}}
# {:linx_reconcile, :error, reason} (a pass that could not run at all)Failure model
A reconcile pass that returns {:error, _} does not crash the loop — it
keeps the previous last_applied and retries on the next tick. When
monitor: true, the loop links to the Monitor it starts: if the Monitor dies,
the loop dies, and a supervisor restart resubscribes and does a full resync from
scratch (which is correct — resync is truth). So put it under a supervisor.
Linx.Reconcile.Source — the plug-in contract
The loop is subsystem-agnostic because it drives a deliberately minimal behaviour, and each subsystem ships a thin adapter that delegates to its own rich single-shot surface:
| Adapter | scope | subscribe/2 |
|---|---|---|
Linx.Sysctl.Reconcile.Source | the :in target | :unsupported (timer-only) |
Linx.Netlink.Rtnl.Reconcile.Source | a netns | starts an rtnl Monitor |
Linx.Cgroup.Reconcile.Source | a cgroup path | :unsupported (timer-only) |
The contract is three callbacks (observe/2 is optional — the loop does not
call it):
@callback reconcile(scope, desired, last_applied, opts) ::
{:ok, report} | {:error, term}
@callback subscribe(scope, owner :: pid) ::
{:ok, pid} | {:error, term} | :unsupported
@callback observe(scope, opts) :: {:ok, observed} | {:error, term}The loop relies only on the report exposing :converged? and :last_applied
(every subsystem's report does). To drive a custom subsystem, implement the
behaviour by delegating to your own reconcile:
defmodule MyApp.Hosts.Source do
@behaviour Linx.Reconcile.Source
@impl true
def reconcile(scope, desired, last_applied, opts) do
# delegate to your own single-shot reconcile, returning a report map
# with at least :converged? and :last_applied
MyApp.Hosts.reconcile(scope, desired, last_applied, opts)
end
@impl true
def subscribe(_scope, _owner), do: :unsupported # timer-only is fine
endThen {Linx.Reconcile, source: MyApp.Hosts.Source, scope: ..., desired: ...}.
The cross-subsystem composite stays in the consumer
Linx.Reconcile drives one subsystem. An object that spans subsystems — "this
container should exist, in its own namespace, with this address and these
limits, and be restarted if it crashes" — cannot live in a primitives library
without Linx becoming the runtime it refuses to be. That composite is the
consumer's, built by composing Linx.Process, the single-shot reconciles, and
OTP supervision directly.
Tank is a worked example of exactly that: a separate app that consumes only Linx's public API to build a supervised container whose network is reconciled from the host at the spawn checkpoint.
See also
- reconcile-overview.md — the design: the mechanism/policy seam, ownership and lifetime, and how the loop fits over the single-shot subsystems.
docs/sysctl/sysctl-examples.md— "Declarative reconciliation" and "A long-lived loop (opt-in)".docs/netlink/netlink-examples.md— "Reconciliation", "Single-shot reconcile", "the Monitor", and "A long-lived loop (opt-in)".docs/cgroup/cgroup-examples.md— "Reconciling limits declaratively".docs/process/process-examples.md— "Supervising a workload" (how a crashed workload is restarted via OTP, not a reconcile loop).docs/netfilter/netfilter-examples.md— the reference triad every other subsystem rhymes with.