Configuration management as real code. No YAML.
Rustible is an independent replacement for Ansible. A playbook is an ordinary
Rust file: typed operations, typed outputs, checked by the compiler and
completed by your editor. Running one compiles it into a self-contained
binary for each target's architecture, ships it over SSH, runs it there, and
streams changed / ok / failed back, with dry run and diffs built in. The
target needs nothing installed: no Python, no agent, no runtime.
use rustible::prelude::*;
use rustible_std::{apt, file, systemd};
#[rustible::playbook(hosts = "web", escalate = true)]
fn main(ctx: &mut Ctx) -> Result<()> {
ctx.step("nginx installed", apt::Present::new(["nginx"]))?;
let conf = template!(
include_str!("../files/nginx.conf.j2"),
workers = ctx.facts().cpus,
server_names = &["example.com", "www.example.com"],
)?;
let cfg = ctx.step(
"nginx.conf is current",
file::Copy::from_str(&conf)
.to("/etc/nginx/nginx.conf")
.mode(0o644),
)?;
ctx.step(
"nginx is enabled and running",
systemd::Enabled::new("nginx").now(true),
)?;
if cfg.changed {
ctx.step("nginx reloaded", systemd::Reload::new("nginx"))?;
}
Ok(())
}Every operation returns a typed struct, and those values feed the steps after
it: cfg.changed is a bool, so the reload is an if, not a handler. A
misspelled field or a wrong type fails the build, and so does a template
variable that nginx.conf.j2 uses but the playbook does not pass.
Rustible keeps the parts of Ansible that work — desired state, idempotence, readable runs — and changes what does not.
| Ansible | Rustible |
|---|---|
| YAML tasks, Jinja templates | Rust functions, and Jinja2 templates whose names the compiler checks |
when: strings |
if |
handlers and notify |
if step.changed { ... } |
loops with item |
just use for :-) |
register + set_fact |
just use the value the step returns |
| Python on every target | one self-contained binary, nothing preinstalled |
Rustible runs from a controller and manages targets.
| x86_64 | aarch64 | |
|---|---|---|
| Controller — Linux | yes | yes |
| Controller — macOS | yes (Intel) | yes (Apple silicon) |
| Target — Linux, any libc | yes | yes |
| Target — macOS | yes (basic support) | yes (basic support) |
| Target — BSD | not yet (planned) | not yet (planned) |
| Target — Windows | nope | no way |
Targets need nothing installed. A playbook arrives on the target machine as one self-contained binary, that's it.
cargo install rustible-cliOn the machine you run rustible from (the controller) you need:
- rustup (https://rustup.rs/)
- a C compiler (
cc,gccorclang) curl
Rustible also uses zig (for playbook cross-compilation), but it is installed automatically if not already present.
Rustible is new, so LLMs have no prior knowledge of it. Paste this into your coding agent's session for a quick bootstrap:
Rustible is a Rust-based replacement for Ansible. Read
https://github.com/flipbit03/rustible/blob/main/docs/USING_RUSTIBLE.md
to understand how to write playbooks and operate it, then help me with my
infrastructure.
mkdir infra && cd infra && git init
rustible init # Cargo.toml, build.rs, src/, hosts.kdl, rustible.toml, README.md
rustible playbook create playbooks/hello.rs # a scaffolded playbook targeting this machineDescribe your machines in hosts.kdl (KDL format).
rustible init starts you with your local machine only:
host "local" connection="local"and here's a more fleshed out example of a hosts.kdl file:
defaults ssh_user="deploy" escalate="sudo"
group "web" {
vars { nginx_workers 4 }
host "web1" addr="10.0.1.11"
host "web2" addr="10.0.1.12" {
// host variable override
vars { nginx_workers 8 }
}
}Seven parameters, variables at three levels, groups of groups, and how it is validated: docs/HOSTS_KDL_REFERENCE.md.
Fill in the playbook (playbooks/hello.rs):
use rustible::prelude::*;
#[rustible::playbook(hosts = "local")]
fn main(ctx: &mut Ctx) -> Result<()> {
let f = ctx.facts();
ctx.log(format!("{} is {:?} {} on {:?}", f.hostname, f.distro, f.distro_version, f.arch));
Ok(())
}Then:
rustible playbook list # what this workspace holds
rustible inventory check # inventory and every playbook's vars
rustible playbook run playbooks/hello.rs # build, ship, run, render
rustible playbook run playbooks/hello.rs --check # change nothing, show what would change
rustible playbook run playbooks/hello.rs -vv # every command the run executedA run ends with a table, one row per host:
host ok changed would change skipped failed recovered warnings
local 1 1 0 0 0 0 0
web1 2 0 0 0 0 0 0
Vars come from the inventory, typed per playbook:
#[rustible::vars]
struct Vars {
package: String,
#[default = false]
update_cache: bool,
}
#[rustible::playbook(hosts = "web", vars = Vars, escalate = true)]
fn main(ctx: &mut Ctx, vars: Vars) -> Result<()> { /* ... */ }Every host's vars are validated against that struct before anything is
built or shipped, so a missing var fails in a second. --var package=htop
overrides the inventory.
- One verb.
ctx.step(name, op)to run operations on a target machine. - Ops are desired state, named for it.
apt::Present,apt::Absent,systemd::Enabled,user::Present. Things that are genuinely actions get verbs and always report changed:systemd::Restart,shell::Command. - Every op defines both halves.
checkdecides what would change and returns it as a typed intent;applyexecutes that intent, and the diff is rendered from it. Dry run and diff are not a per-module afterthought. - Outputs chain. A step returns a typed value describing what it found or
made, and later steps use it. In check mode a step that would change has
no output yet. Reading it ends the innermost
ctx.block(or that host's dry run) with a warning naming the step, rather than guessing. Nothing fails. - Blocks group steps.
ctx.block(name, |ctx| ..)names a section and returns its closure's value. - Prerequisites are refused. An op that manages a user does not create the group it references; it fails and names the op you wanted. For an account the run is creating, a dry run defers that refusal to the real run, since an earlier step may create the group — the same line Ansible draws.
- A dry run contacts nothing outside the target. No request of any
method is sent under
--check; such a step reportswould change.
A Rustible Operation (Op) is one desired state: apt::Present, systemd::Enabled,
user::Absent. A Rustible collection is a library of them — an ordinary Rust crate that depends on rustible-sdk. You add one with
cargo add. Contrasting with Ansible, there is no "galaxy" - it's just crates.
Two collections ship from this repository.
rustible-std — the operations a fleet needs, always available:
| module | ops |
|---|---|
apt |
Present, Absent, Latest — Debian and Ubuntu |
brew |
Present, Absent — Homebrew, on a mac or Linuxbrew; never as root (the login user, or the owner via ctx.as_user) |
file |
Copy, Directory, Symlink, Absent, Attrs, Line, Block |
user |
Present, Absent, Membership, Existing |
group |
Present, Absent |
ssh::authorized_keys |
Present (with exclusive), Absent |
systemd |
Enabled, Disabled, Running, Stopped, Restart, Reload, DaemonReload |
hostname |
Is |
sysctl |
Present |
http |
Download, Request |
archive |
Extracted |
shell |
Command |
Every operation declares where it runs and refuses other platforms by name.
On macOS file, shell, http, archive and brew run, and
ssh::authorized_keys runs when given the account rather than a name to look
up; the rest refuse.
rustible-github — a small collection showing what a third-party one
looks like. It adds rustible_github::UserKeys, which fetches a GitHub user's public
keys, and github_ssh_keys_to_user, a helper that runs it and
ssh::authorized_keys::Present as two visible steps in one block:
let r = github_ssh_keys_to_user(ctx, "flipbit03", "admin")?;Both are built on rustible-sdk, which is what you use to write your own
operations and collections.
- Find the workspace (
rustible.toml), read the inventory. - Ask the playbook about itself with a host-native build (
--describe): which hosts, which vars, whether it escalates, and anyssh_user. Cached by source hash. - Validate every target host's vars. Abort before touching anything if one fails.
- Connect to all hosts in parallel, learn each one's architecture.
- One cargo build for every architecture in play,
--profile dist(stripped, LTO; static musl on Linux, Mach-O againstlibSystemonly on macOS). - Upload to
~/.cache/rustible/bin/<name>-<sha256>unless it is already there; a playbook escalating to anescalate_userother than root or the login gets its own copy, streamed to that account. - Run it, escalating if the playbook says so, and stream framed events back over the SSH channel: steps, diffs, commands, logs, the summary.
The playbook binary never listens on a socket or connects back to the
controller: everything it reports rides the SSH session. An operation such as
http::Download connects out from the target, and only in a real run.
| crate | what |
|---|---|
rustible-cli |
the rustible command |
rustible |
the facade a playbook imports |
rustible-sdk |
everything you need to write your own collection: Op, Ctx, System, facts, protocol, testing |
rustible-std |
the standard operations |
rustible-macros |
#[playbook], #[vars], #[integration_test] |
rustible-build |
playbook discovery for the generated build.rs |
rustible-github |
a collection, and the worked example of one |
| document | what it is |
|---|---|
CLAUDE.md |
how to work in this repository: the rules, writing an operation, the testing tiers and the make targets |
docs/01_VISION.md |
the contract: architecture, the playbook model, check mode, the error model |
docs/USING_RUSTIBLE.md |
operating Rustible: workspace, playbooks, operations, the CLI |
docs/HOSTS_KDL_REFERENCE.md |
describing your machines: parameters, variables, groups |
docs/DEVELOPING.md |
per-platform setup for the tests that need a machine |
docs/plan/DECISIONS.md |
every decision made while building |
docs/plan/PROGRESS.md |
what is built |
MIT or Apache-2.0, at your option.