Skip to content

Repository files navigation

devendra

A very small subset of Ansible as an embeddable daemon. Devendra installs config files (podman quadlets, drop-ins, plain config), runs systemctl daemon-reload, and starts/stops/enables/disables/restarts systemd services — idempotently. Desired state is authored in a tiny web UI on a controller and distributed to agents over iroh 1.0 using iroh-docs document sync.

┌────────────────────────┐ iroh-docs sync ┌────────────────────────┐
│ controller │ (manifest + file bodies →) │ agent(s) │
│ │ ─────────────────────────────► │ │
│ web UI (axum+jinja) │ │ reconciler │
│ flat-file store │ (← status reports) │ · install files │
│ authors iroh doc │ ◄───────────────────────────── │ · daemon-reload │
│ │ │ · manage services │
└────────────────────────┘ └────────────────────────┘

The agent pulls: it joins the document from a ticket, receives its host's manifest and file bodies, converges the machine, and writes a status report back into the same document.

Workspace layout

cratewhat it is
crates/protoShared wire types (Manifest, Status) and the iroh-docs key conventions. No iroh/tokio deps — pure protocol.
crates/nodeShared iroh node bootstrap (endpoint + blobs + gossip + docs + router) used by both daemons, so they can't drift on transport.
crates/agentThe embeddable daemon. A pure, unit-tested reconciler (reconcile), the iroh sync loop (sync), and the adopt onboarding scan. Library-first (lib.rs + thin bin/).
crates/controllerWeb UI (axum + minijinja + htmx, structured after ../dms) over a flat-file desired-state store, authoring manifests into the iroh document.

How state flows

  1. Author — edit config/hosts/<id>.toml (on disk or in the web UI). Each file describes the files to install and services to manage.
  2. Publish — the controller compiles each host into a Manifest, stores file bodies as document entries (host/<id>/files/...) and the manifest at host/<id>/manifest. The generation counter advances only when the desired state actually changed, so a no-op republish never triggers restarts.
  3. Sync — agents receive the changes over iroh-docs (content auto-downloads via the default download policy).
  4. Reconcile — the agent applies files (writing only on content drift), daemon-reloads iff a unit/generator file changed, then reconciles services. restarted/reloaded fire only when a config file changed this pass (handler semantics), keeping repeat applies idempotent.
  5. Report — the agent writes a Status back to host/<id>/status; the controller renders it.

Quickstart

Requires a recent Rust (edition 2024) and just.

# 1. Build + test
just build
just test# 2. Start the controller (publishes ./config, serves the UI on :3900)
just serve
# open http://127.0.0.1:3900 — view hosts, edit config, see the ticket# 3. In another terminal, grab the agent join ticket
just ticket
# 4. Run an agent for a host (reconciles THIS machine — see warning below)
just agent web-01 '<ticket-from-step-3>'

⚠️ The agent modifies the local machine

The agent installs files to real paths (e.g. /etc/containers/systemd/...) and drives systemd, so it typically needs root for system-scoped units. To manage a user's own units instead, pass --user-scope (uses systemctl --user). Point --data-dir somewhere writable for its iroh store.

Deploying an agent as a Debian package

The agent ships as a .deb that installs the binary, a systemd unit, and an env file — and enables the service on install. The unit runs /usr/bin/devendra-agent run as root (system scope), provisions /var/lib/devendra/agent via StateDirectory=, and stops the agent with SIGINT so its iroh node flushes cleanly. The sample unit + env template live under packaging/systemd/; the deb metadata is in crates/agent/Cargo.toml ([package.metadata.deb]).

Build the package (needs cargo install cargo-deb; the arm64 target also needs cargo install cross + podman, same as build-agent-arm64):

just deb # native amd64 -> target/debian/devendra-agent_<ver>_amd64.deb
just deb-arm64 # arm64 -> target/debian/devendra-agent_<ver>_arm64.deb

Copy the .deb to the target host and install it (as root). apt install pulls the init-system-helpers/libc6 dependencies and runs the maintainer scripts:

scp target/debian/devendra-agent_0.1.0-1_arm64.deb root@web-01:/tmp/
ssh root@web-01 'apt install /tmp/devendra-agent_0.1.0-1_arm64.deb'

The package enables the service but does not start it — the unit needs a join ticket first. Set it, then start:

sudoedit /etc/devendra/agent.env # set DEVENDRA_TICKET (from `just ticket`)
systemctl start devendra-agent

/etc/devendra/agent.env is shipped as a dpkg conffile, so your edited ticket survives package upgrades. DEVENDRA_HOST_ID is optional: unset, the agent uses the system hostname, so a host named web-01 picks up config/hosts/web-01.toml automatically. Set it in agent.env only when the hostname differs from the manifest name (e.g. an FQDN).

Adopting an existing host

To onboard a machine that was already configured by hand, run the agent's adopt command instead of run. It scans a directory (default /etc/containers/systemd) and publishes what it finds as a proposal — it never writes desired state directly.

just adopt web-03 '<ticket>'# scans /etc/containers/systemd# or: devendra-agent adopt --host-id web-03 --ticket '…' --dir /some/dir

The proposal appears in the controller UI (a "pending adoption" badge, and a "Pending adoptions" list for hosts that aren't managed yet). Open /hosts/<id>/adopt to preview each file and accept the ones you want. Accepting copies the bodies into config/files/<id>/…, adds [[files]] entries to the host's TOML (creating the host if new), clears the proposal, and publishes. From then on the host is normal managed state — the agent's run mode will keep it converged. (Adoption proposes files only; add the matching [[services]] yourself after review.)

Config file reference (config/hosts/<id>.toml)

# Optional: pull in shared fragments before this file's own entries. Fragments# have the same shape (files/services) and may include others. This file's# entries override included ones with the same file path / service name, so a# host can reuse a fragment and tweak just what differs.include = ["include/nginx.toml"]
[[directories]] # ensure a directory existspath = "/mnt/hass"# absolute pathmode = "0750"# octal string (optional, default 0755)# owner = "hass" # username or uid (optional)# group = "hass" # group name or gid (optional)# state = "present" # or "absent" to remove it
[[files]]
path = "/etc/containers/systemd/web.container"# absolute destinationmode = "0644"# octal string (optional)# owner = "root" # username or uid (optional)# group = "root" # group name or gid (optional)content = "..."# inline body …# source = "files/web.container" # … OR path relative to config dir# state = "present" # or "absent" to remove
[[services]]
name = "web.service"enabled = true# start on boot (optional)state = "started"# started | stopped | restarted | reloaded (optional)

Fragments live under config/include/; include paths are relative to the config dir. The web UI has a fragments section (nav bar) to view, edit, and create them — saving a fragment republishes every host that includes it, and a fragment's page lists which hosts use it. See config/hosts/web-02.toml for a host that reuses config/include/nginx.toml and overrides the published port.

Dependency stack

Pinned to the coherent iroh 1.0 release stack (as of 2026-07): iroh 1.0, iroh-blobs 0.103, iroh-docs 0.101, iroh-gossip 0.101. Note that n0 still flags iroh-blobs as not-yet-"production quality"; revisit before relying on it for large-scale content transfer.

Testing

The reconciler is the risky part, so it's abstracted behind FileSystem / SystemdController / ContentSource traits and unit-tested against in-memory fakes — including the core idempotency property (apply twice → second pass is a clean no-op). Run cargo test --workspace.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages