Desired-state infrastructure convergence engine
Project description
verg
Desired-state infrastructure convergence engine. A fast, stateless alternative to Ansible, built in Rust.
Pre-1.0: verg is under active development. Expect breaking changes between minor versions.
Features
- Fast - pushes a single static binary to each target over SSH; the agent executes locally, so there are no per-task SSH round-trips and no Python dependency on the target.
- Stateless - every run reads actual system state and converges to desired state. No state files are kept on the control host between runs.
- Dynamic inventory - generate hosts from a command (cloud, CMDB, scripts) and merge them with static hosts.
- Simple - declare state in TOML files with Jinja2 templating for dynamic values. Strict config validation catches typos before any SSH connection is opened.
- Agent-friendly -
--jsonoutput,verg schemafor machine-readable resource schemas, and structured exit codes make verg easy to drive from scripts and AI agents. - Secure by default - agent binary is checksum-verified before and after transfer; strict SSH host key checking; config validated locally before touching any target.
- Continuous enforcement - agents pull and re-converge on a schedule with no central server. See docs/continuous-enforcement.md.
- Encrypted secrets - secrets are encrypted at rest with age, decrypted on the control host at build time, and injected under the
secret.*template namespace. See docs/secrets.md. - Extensible - define your own resource types as TOML data in
verg/resources/; no recompile required. - Native providers - custom resource types as embedded-source scripts (any language) over a JSON protocol, self-contained in the bundle.
Install
# Cargo (crates.io)
cargo install verg
# PyPI
pip install verg
# Homebrew (tap)
brew install rvben/tap/verg
Quick Start
# Scaffold a new project in ./verg/
verg init
# Edit your inventory and desired state
$EDITOR verg/hosts.toml
$EDITOR verg/state/base.toml
# Preview what would change (dry-run, no modifications)
verg diff --targets all
# Apply to all hosts
verg apply --targets all
How It Works
verg (control CLI, runs on your machine) reads TOML state files and your host inventory, builds a host-specific bundle for each target, then pushes a compiled verg-agent binary over SSH. The agent reads the bundle from stdin, checks actual system state, converges each resource to desired state in dependency order, and returns a JSON summary to stdout. The control CLI aggregates results and presents them.
The agent is a static binary with no runtime dependencies. It is checksum-verified before deployment and runs entirely on the target - no per-resource SSH calls from the control host are needed once the agent is running.
Concepts
Project layout
A verg project lives in a directory named verg/ by default (override with --path or the VERG_PATH environment variable). Inside it:
verg/
hosts.toml # host inventory
groups/ # one .toml per group, each with a [vars] table
state/ # desired-state declarations; loaded in lexicographic order
files/ # static files referenced by file resources
templates/ # Jinja2 template files
.verg/logs/ # structured apply logs (written automatically)
verg init scaffolds this layout with a starter hosts.toml and state/base.toml.
Inventory and selectors
Each host in hosts.toml has these fields:
| Field | Required | Default | Description |
|---|---|---|---|
address |
yes | - | IP address or hostname |
user |
no | "root" |
SSH user |
port |
no | 22 |
SSH port |
groups |
no | [] |
Group memberships |
[hosts.NAME.vars] |
no | - | Host-specific variables |
The --targets flag accepts a selector expression:
| Syntax | Meaning |
|---|---|
all |
Every host |
web |
Hosts named web or in group web |
a,b |
Union of selectors a and b |
a:b |
Intersection (hosts in both a and b) |
!x |
Exclude x |
prod:!db |
In group prod but not group db |
An unknown selector name is an error (exit code 6). The one exception is an
exclusion (!x): excluding a group or host that matches nothing excludes
nothing rather than erroring, so prod:!down still works when the down group
is empty (and a misspelled exclusion is silently a no-op). Parentheses are not
supported.
Hosts can also be generated dynamically by a command via an [inventory]
section in hosts.toml. See docs/inventory.md for the
static fields, the dynamic-inventory command, its JSON output format, and how
static and dynamic hosts merge.
State files
State files live in state/ and are loaded in lexicographic order. Each file is a TOML document with this shape:
targets = ["web"] # optional: limit to hosts in group/name "web"
# omit to apply to all hosts
[resource.<type>.<name>] # one table per resource
property = "value"
A state file with no targets key applies to every host. A state file with targets = ["web"] applies only when the target host belongs to group web or has the name web. Only targets and resource are valid top-level keys; unrecognized keys are rejected by default.
See docs/resources.md for the full resource reference.
Variables and facts
Variables are interpolated using Jinja2 syntax ({{ var }}). Template sources:
| Source | Precedence |
|---|---|
Host vars ([hosts.NAME.vars]) |
Highest |
Group vars (groups/<g>.toml [vars]) |
Below host vars |
| Facts gathered at runtime | Available as fact.arch, fact.os, etc. |
| Group membership | Available as group.<name> |
Inventory context (hosts.*, groups.*) |
Lowest |
Available facts: fact.arch, fact.hostname, fact.os, fact.os_release, fact.os_version.
String values starting with $env. (e.g. password = "$env.MY_SECRET") are resolved from the environment at build time.
Inline content in a file resource is always interpolated. To interpolate a file loaded via source, set template = true.
cmd resources can register their stdout for use in downstream resources as {{ register.NAME }}. See docs/resources.md for details.
Ordering and handlers
Resources declare dependencies with after = ["type.name", ...]. verg sorts resources into execution layers using Kahn's topological sort; within a layer, resources run in FQN order. A resource whose dependency fails is skipped with "dependency failed".
Resources with handler = true are skipped unless another resource lists their FQN in notify. Handlers run after all normal resources; shorthand notify actions (e.g. reload:nginx, daemon-reload) run after handlers. Each shorthand runs at most once per host per run.
when expressions conditionally skip resources based on facts and group membership (e.g. when = "fact.os == 'ubuntu'").
See docs/resources.md for the full common attributes reference.
Resource Types
| Type | Purpose |
|---|---|
pkg |
Install or remove system packages (apt-get, dnf, pacman - auto-detected) |
file |
Manage a file's content, permissions, and ownership |
directory |
Ensure a directory exists with the right permissions and ownership |
service |
Manage a systemd service's running state and boot enablement |
cmd |
Run a shell command with an idempotency guard |
user |
Create or remove a system user; update shell, home, and groups on existing users |
cron |
Manage a cron job file in /etc/cron.d/ |
sysctl |
Set a Linux kernel parameter |
apt_repo |
Add or remove an APT repository with its GPG key |
docker_compose |
Manage a Docker Compose stack |
download |
Download a file from a URL, optionally extracting an archive |
hostname |
Set the static system hostname (requires systemd) |
timezone |
Set the system timezone by IANA name (requires systemd) |
mount |
Manage an /etc/fstab entry and mount state |
git |
Clone a git repository and keep it checked out at a ref |
You can also define your own resource types as data in verg/resources/. See docs/resources.md - Custom resource types for the definition format, param schema, and a worked example.
For resource types that need a full scripting language or a richer protocol, native providers let you write the logic in any language and communicate over JSON-on-stdio. The script is embedded in the bundle at build time, so pull-mode agents are self-contained. See docs/providers.md for the declaration format, wire protocol, and a complete worked example.
Complete Example
A minimal nginx setup using pkg, file, and service resources with dependency ordering.
# verg/hosts.toml
[hosts.web1]
address = "192.0.2.10"
user = "root"
groups = ["web"]
[hosts.web1.vars]
http_port = "80"
server_name = "example.com"
# verg/state/nginx.toml
targets = ["web"]
[resource.pkg.nginx]
name = "nginx"
state = "present"
[resource.file.nginx-conf]
path = "/etc/nginx/sites-available/default"
content = """
server {
listen {{ http_port }};
server_name {{ server_name }};
root /var/www/html;
}
"""
mode = "0644"
after = ["pkg.nginx"]
notify = ["reload:nginx"]
[resource.service.nginx]
name = "nginx"
state = "running"
enabled = true
after = ["file.nginx-conf"]
The FQN for each resource is type.name (e.g. pkg.nginx, file.nginx-conf, service.nginx). Every after reference in this example resolves to a resource defined in the same file.
Commands
| Command | Key args | Description |
|---|---|---|
verg apply |
--targets <TARGETS> (required) |
Converge targets to desired state |
verg diff |
--targets <TARGETS> (default: all), --limit, --offset, --fields |
Show what would change without applying |
verg check |
--targets <TARGETS> (default: all) |
Verify targets match desired state (exits 0 when drift found, 1 when all match) |
verg schema |
- | Print resource type schemas as JSON |
verg init |
--force |
Scaffold a new project directory |
verg completions |
<bash|fish|zsh|powershell|elvish> |
Generate shell completions |
verg publish |
--targets <TARGETS>, --dest <DIR> |
Build per-host bundles offline for pull-mode agents |
apply has no default target. --targets is required to prevent accidental mass applies. Running apply in a non-interactive pipeline without --yes exits with code 5 (confirmation required).
Global Flags
| Flag | Default | Description |
|---|---|---|
-o, --output <auto|text|json> |
auto |
Output format; auto selects JSON when stdout is not a TTY |
--json |
- | Force JSON output (hidden alias for -o json) |
-q, --quiet |
- | Suppress per-resource lines; print only the final summary |
-y, --yes |
- | Proceed when stdin is not a TTY (required for CI/pipelines) |
--path <PATH> |
./verg |
Path to the verg project directory (VERG_PATH) |
--parallel <N> |
10 |
Maximum parallel SSH connections |
--ssh-config <PATH> |
- | Path to SSH config file (VERG_SSH_CONFIG) |
--agent-dir <PATH> |
- | Directory containing verg-agent binaries per architecture (VERG_AGENT_DIR) |
--host-key-checking <yes|accept-new|no> |
yes |
SSH host key checking policy |
--ssh-known-hosts <PATH> |
- | Path to a known_hosts file |
--skip-agent-checksum |
- | Skip agent binary checksum verification (air-gapped or local builds) |
--lax-config |
- | Downgrade config validation errors (unknown keys/types) to warnings |
--timeout <SECONDS> |
600 |
Per-host timeout in seconds |
Exit Codes
| Code | Name | Meaning |
|---|---|---|
0 |
SUCCESS | Resources changed or drift detected |
1 |
NOTHING_CHANGED | All resources already in desired state |
2 |
PARTIAL_FAILURE | Some resources failed; others succeeded |
3 |
TOTAL_FAILURE | All resources on all targets failed |
4 |
CONNECTION_ERROR | All failures were SSH connection errors |
5 |
INVALID_CONFIG | Config is invalid, or apply requires --yes (ConfirmationRequired) |
6 |
TARGET_NOT_FOUND | No hosts matched the given target selector |
7 |
INTERNAL_ERROR | Unexpected internal error (I/O or other) |
8 |
CONFLICT | State conflict that cannot be automatically resolved |
130 |
INTERRUPTED | Process received SIGINT (Ctrl-C) |
check exits 0 when drift is found and 1 when everything already matches. diff exits 0 on any successful run (even with no changes); only connection or partial failures yield 4 or 2.
Security
verg pushes a compiled agent binary to target hosts and runs it as the configured SSH user (root by default). The agent binary is SHA-256 verified locally before transfer and remotely after transfer using sha256sum -c. SSH host key checking is strict by default (StrictHostKeyChecking=yes). All state files are validated locally before any SSH connection is opened. Resources marked sensitive = true have their payload redacted from output and the apply changelog.
See SECURITY.md for the full threat model, supply chain details, secret handling, and vulnerability reporting.
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file verg-0.8.1.tar.gz.
File metadata
- Download URL: verg-0.8.1.tar.gz
- Upload date:
- Size: 209.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f72dfce2faadb081b5b04a467f14bf7222f17127a3fcb84687894392fc2b3cbd
|
|
| MD5 |
20e013ebbf6c16bdfd02e5973c17f995
|
|
| BLAKE2b-256 |
5c4157b69711a38f1515e8b6de67e20826691f3e2ff62bc0181b3e6cda5f636a
|
File details
Details for the file verg-0.8.1-py3-none-manylinux_2_28_x86_64.whl.
File metadata
- Download URL: verg-0.8.1-py3-none-manylinux_2_28_x86_64.whl
- Upload date:
- Size: 2.9 MB
- Tags: Python 3, manylinux: glibc 2.28+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
893b904940f9ecab2a604fcfd3987cea502f11cfbeae1769c9608e91e6139fc9
|
|
| MD5 |
13100736193ce05777ead536977dd89d
|
|
| BLAKE2b-256 |
1eb54a4983f0a0046e3fd771ed1f49a566b44ae54382aa46785ffd44ca3270a8
|
File details
Details for the file verg-0.8.1-py3-none-manylinux_2_28_aarch64.whl.
File metadata
- Download URL: verg-0.8.1-py3-none-manylinux_2_28_aarch64.whl
- Upload date:
- Size: 2.6 MB
- Tags: Python 3, manylinux: glibc 2.28+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6a1bbdbe04c69c845ca621de42aebd06bfe6d0f1c0923f1f723e8ce3ee671765
|
|
| MD5 |
0816c6d5727d2b0c01eb08ce05f9c031
|
|
| BLAKE2b-256 |
a697dc70b55928b78a916c6f074d17746ec8189e3364b572a8ff08da60aea3bc
|
File details
Details for the file verg-0.8.1-py3-none-macosx_11_0_arm64.whl.
File metadata
- Download URL: verg-0.8.1-py3-none-macosx_11_0_arm64.whl
- Upload date:
- Size: 2.6 MB
- Tags: Python 3, macOS 11.0+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2b42f89d90e1d2a07245da139fc105ae4b6ff6f9834b4dc5555d11370ce88771
|
|
| MD5 |
45fc5cea9f191e8701ab9b5677b99d6a
|
|
| BLAKE2b-256 |
f2265695e2b351ea90c308e9213c1df49b648f2ab9aad49d87ac447965c9be6c
|
File details
Details for the file verg-0.8.1-py3-none-macosx_10_12_x86_64.whl.
File metadata
- Download URL: verg-0.8.1-py3-none-macosx_10_12_x86_64.whl
- Upload date:
- Size: 2.7 MB
- Tags: Python 3, macOS 10.12+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a3f7041c5023fe2b1a77eda05e2d8702e91ddb7be13173c73710a2a57f4944da
|
|
| MD5 |
3f9d5ec4e085a5bca29b73bbe43561ac
|
|
| BLAKE2b-256 |
952f122d72c8b43eefabae804a5dd56d2b57924baa1339e0e02dac8438763d3a
|