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-held last_applied set records what we installed, so foreign state that merely appeared is left untouched. You thread last_applied from each pass into the next.

The reconcilable subsystems at a glance

SubsystemSingle-shot modulescopeOwnershipMonitor
netfilterLinx.Netfilter (push(mode: :reconcile))a tablesocket-owned + genID CASyes
rtnlLinx.Netlink.Rtnl.Reconcilean open socket / netnsroutes two-way, rest three-wayyes
sysctlLinx.Sysctl.Reconcilethe :in targetthree-wayno
cgroup limitsLinx.Cgroup.Reconcilea cgroup paththree-wayno

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 reportconverged?, 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, pending stays empty;
  • ordered ops (rtnl) are fail-fast — the pass stops at the first failure (failed holds it), and the ops it had not reached become pending.

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 implementing Linx.Reconcile.Source.
  • :scope (required) — the namespace handle, in that source's shape (sysctl: the :in target; 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 :infinity to rely solely on the Monitor (default 5_000).
  • :monitor — subscribe to the source's Monitor for low-latency wakeups (default true; a source that returns :unsupported falls back to timer-only transparently).
  • :debounce — ms to coalesce a burst of hints (or a put_desired) into one pass (default 100).
  • :owner — a pid to notify after each pass.
  • :name — a GenServer name 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:

Adapterscopesubscribe/2
Linx.Sysctl.Reconcile.Sourcethe :in target:unsupported (timer-only)
Linx.Netlink.Rtnl.Reconcile.Sourcea netnsstarts an rtnl Monitor
Linx.Cgroup.Reconcile.Sourcea 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
end

Then {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.