Linx.Netlink.Rtnl.Reconcile (Linx v0.1.0)

Copy Markdown View Source

Single-shot declarative reconciliation for rtnetlink — observe the kernel, diff against a desired state, and apply the minimal change, in one caller-driven pass scoped to the socket's network namespace.

This is the mechanism half of declarative networking: it holds no long-lived state and owns no process. The cadence, persistence, and supervision are the consumer's (see the reconcile design notes). It composes Linx.Netlink.Rtnl.Diff (the per-resource diffs) and the Route/Address actuation verbs into an ordered apply pass.

Scope

This pass reconciles addresses and routes on interfaces that already exist in the namespace. Link lifecycle (creating veth/ipvlan, MTU, admin state) is kind-specific and belongs to the composite/consumer; rules and neighbours reuse the same Diff engine and slot in the same way. Interfaces are observed (to resolve names to indices) but not created or deleted here.

Desired state

A map authored by interface name (indices are ephemeral, so you never write them down — they are resolved against a fresh link list each pass):

%{
  addresses: [
    {"eth0", "10.0.0.2", 24},
    {"eth0", "fc00::1", 64}
  ],
  routes: [
    {"10.50.0.0", 24, "10.0.0.1"},
    {"10.60.0.0", 24, "10.0.0.1", table: 100, metric: 50},
    {:default, "10.0.0.1"}
  ]
}

Address entries are {interface, ip, prefixlen}. Route entries are {dst, prefix, gateway} / {dst, prefix, gateway, opts} / {:default, gateway} / {:default, gateway, opts}, where opts is Route's :table/:metric (the :protocol is forced to this reconciler's ownership tag).

Ownership

Routes are owned by rtm_protocol: every route this reconciler installs is tagged with :protocol (default default_protocol/0), and the route diff considers only observed routes carrying that tag — connected routes and other writers are invisible to it, and never deleted. Addresses have no ownership field, so they are owned three-way via the last_applied set threaded between passes (foreign addresses that merely appeared are left alone). See Linx.Netlink.Rtnl.Diff.

Strategy and ordering

Apply order follows the kernel's layering: address creates first (so a route's gateway is reachable), then route creates/updates, then route deletes, then address deletes. The pass is fail-fast — it stops at the first error and reports the rest as pending; the next pass re-converges.

Example

{:ok, sock} = Rtnl.open()

desired = %{
  addresses: [{"eth0", "10.0.0.2", 24}],
  routes: [{:default, "10.0.0.1"}]
}

{:ok, r} = Reconcile.reconcile(sock, desired)
r.converged?
{:ok, r2} = Reconcile.reconcile(sock, desired, r.last_applied)  # idempotent

Summary

Types

Address entry: {interface_name, ip, prefixlen}.

Desired state, authored by interface name.

Reconciler-held ownership, threaded between passes.

Route entry — see the moduledoc.

Functions

The default route-ownership rtm_protocol (76, ASCII 'L').

Runs one reconcile pass against desired in the socket's namespace.

Types

address_spec()

@type address_spec() :: {String.t(), binary() | Linx.IP.t(), non_neg_integer()}

Address entry: {interface_name, ip, prefixlen}.

desired()

@type desired() :: %{
  optional(:addresses) => [address_spec()],
  optional(:routes) => [route_spec()]
}

Desired state, authored by interface name.

last_applied()

@type last_applied() :: %{optional(:addresses) => MapSet.t()}

Reconciler-held ownership, threaded between passes.

route_spec()

@type route_spec() ::
  {binary() | Linx.IP.t(), non_neg_integer(), binary() | Linx.IP.t()}
  | {binary() | Linx.IP.t(), non_neg_integer(), binary() | Linx.IP.t(),
     keyword()}
  | {:default, binary() | Linx.IP.t()}
  | {:default, binary() | Linx.IP.t(), keyword()}

Route entry — see the moduledoc.

Functions

default_protocol()

@spec default_protocol() :: pos_integer()

The default route-ownership rtm_protocol (76, ASCII 'L').

reconcile(socket, desired, last_applied \\ %{}, opts \\ [])

@spec reconcile(Linx.Netlink.Socket.t(), desired(), last_applied(), keyword()) ::
  {:ok, Linx.Netlink.Rtnl.Reconcile.Report.t()} | {:error, term()}

Runs one reconcile pass against desired in the socket's namespace.

Returns {:ok, %Report{}} — per-op kernel failures live in the report's :failed, since a partial apply is a normal transient state the next pass corrects. Returns {:error, {:normalize, reason}} if the desired state is invalid (an unknown interface, an unparseable address, a family mismatch) — nothing is applied in that case. Returns {:error, term} if observing the kernel fails.

Options

  • :protocol — the route-ownership tag (integer or a Route protocol atom). Default default_protocol/0.