krikri
krikri - Ansible-Compatible Automation Tool
A single-binary automation tool that runs real Ansible playbooks - written in Crystal
📋 What this is
krikri parses and runs standard Ansible playbook YAML directly - the same syntax you already write, unmodified. There's no Python, no ansible-core, no pip, and no collections directory anywhere in the picture, on either the controller or the target - it's one compiled binary (krikri-playbook) plus a directory of small compiled module binaries.
It is not a new automation DSL you have to learn, and not a "mostly compatible" reimplementation verified by eyeballing docs - every plugin's behavior is checked against real ansible-playbook output on real hosts (see Differences and What's missing below), and a Docker-based compatibility harness (compat/) runs the same playbooks through both engines side by side and diffs the resulting state.
🔀 How this differs from real (Python) Ansible
If you already know Ansible, here's what actually changes when you swap ansible-playbook for ./bin/krikri-playbook:
Architecture: compiled binaries, not a Python interpreter per task
Real Ansible ships a Python module's source, templates it, and starts a fresh Python interpreter for it on the target host for every single task, every run - even when nothing changes. krikri compiles each module (apt, copy, service, ...) into its own small native binary once; running a task means uploading that binary (cached after the first run) and executing it directly - no interpreter startup, no module templating step, no AnsiballZ wrapper. Consecutive tasks bound for the same host are also batched into a single SSH round trip by default (--no-batching to disable) instead of one round trip per task.
This is the single biggest practical difference, and it shows up directly in wall-clock time - see Performance below.
What's the same
Playbook syntax, inventory format, roles/ layout, Jinja2 templating (filters, tests, block tags), become:, Vault, --check/--diff, --tags/--limit, handlers, loops, block:/rescue:/always: - all parsed and executed the same way, from the same YAML files you already have. See Features below for the full list of what's implemented.
What's structurally different (by design, not a gap)
- No arbitrary Python execution. A role's own private
library/*.pymodule (outside theansible.builtin/community.*set this project ships as native binaries) can't run - there's no Python interpreter to run it in. The task is skipped with a warning rather than crashing the play, but anything downstream depending on its result sees an undefined value. - Cloud provider modules and inventory plugins are out of scope for now -
amazon.aws/community.aws(ec2_instance,s3_object, IAM, security groups, ...),azure_rm_*, and dynamic cloud inventory plugins (aws_ec2.ymletc.) aren't implemented. These are a fundamentally different kind of module (HTTP calls to a cloud API from the controller, not shell commands run on a managed target) and would need a real API client built from scratch - a much bigger undertaking than adding another module that shells out to a CLI tool. Not planned unless a specific real-world need justifies the investment. docker_*'sapi_version:pin isn't supported - the underlying Docker client talks unversioned API endpoints throughout, which negotiate fine against current Docker/Podman, but pinning a specific API version would mean touching every endpoint individually.meta:supportsclear_facts/flush_handlers/end_host/end_play/clear_host_errors/noop/refresh_inventory-reset_connection/end_batch/end_rolestill act on execution-flow machinery this engine models differently (persistent-connection control,serial:batching, and role-scoped early-return respectively), and are rejected at parse time rather than silently accepted and ignored.
❓ What's missing
Short version: as of this version, there is one known real correctness gap left open (a Crystal/OpenSSL TLS-compatibility limitation talking to a server running very old TLS - see KNOWN_MISSING.md) - the primary way gaps get found here is running real production Ansible roles (from Galaxy) against both engines on real hosts and diffing the result, not a pre-planned feature checklist, and every OTHER gap found that way has been fixed. The structural differences above are the only deliberate exclusions.
That status changes as new roles get tested, so it's tracked in one place rather than duplicated here:
- KNOWN_MISSING.md - the current, short, up-to-date list of any open real gaps plus the full explicit scope-cut list (cloud modules, role-private modules, a handful of untestable/ narrow module gaps, etc., each with the reasoning behind it).
- ROLES_TESTED.md - the current status of every real Ansible Galaxy role that's been benchmarked against a live host, one line each, so you can check whether something resembling your own playbooks has already been exercised.
Both files describe current state only - the fix history for everything already resolved lives in git log, not duplicated in either file (searchable, e.g. git log --all --grep=auth_socket).
⚡ Performance
Native compiled modules, one persistent SSH connection per host, and batched round trips make the biggest difference on idempotent re-runs - the common case for a config-management tool running on a schedule, where most tasks find nothing to change but Python still pays a fresh interpreter-and-module cost per task regardless.
Measured against real ansible-playbook (ansible-core 2.19.4): 10 real Galaxy roles drawn at random from the project's verified-clean list, each on its own fresh Atlantic.net G3.2GB Ubuntu-22.04 host pair (one host per engine, never reused, destroyed immediately after), cold (first touch) and warm (idempotent re-run) on both engines, --forks 1 on both sides. krikri-playbook was built --release and stripped, and ran with --persistent-daemon --no-batching (one long-lived ssh ... -- <plugin binary> --daemon connection per host instead of a fresh ssh+bash+exec per task; batching was disabled during measurement because, at the time, it routed around that path). That caveat is now historical - as of 0.9.635 batched groups go over the daemon too, so the two features compose and a re-run of this table would no longer need --no-batching. The numbers below predate that and are not re-measured here. PLAY RECAP parity with real Ansible (ok=/changed=/failed=/skipped=) was checked per role, cold AND warm, and matched exactly on all 10:
| Role (author) | Python cold | Crystal cold | Cold speedup | Python warm | Crystal warm | Warm speedup |
|---|---|---|---|---|---|---|
robertdebock.remi |
4.39s | 3.55s | 1.2x | 2.82s | 0.72s | 3.9x |
geerlingguy.helm |
27.28s | 9.74s | 2.8x | 5.81s | 1.66s | 3.5x |
geerlingguy.clamav |
74.15s | 52.19s | 1.4x | 29.53s | 2.49s | 11.9x |
geerlingguy.node_exporter |
54.64s | 8.16s | 6.7x | 19.56s | 2.32s | 8.4x |
robertdebock.types |
5.34s | 3.34s | 1.6x | 3.15s | 0.65s | 4.8x |
robertdebock.docker_ce |
81.13s | 50.45s | 1.6x | 15.56s | 2.54s | 6.1x |
robertdebock.digitalocean_agent |
37.22s | 23.29s | 1.6x | 17.60s | 2.37s | 7.4x |
geerlingguy.adminer |
15.79s | 9.50s | 1.7x | 9.00s | 2.01s | 4.5x |
robertdebock.upgrade |
9.24s | 4.84s | 1.9x | 6.90s | 1.59s | 4.3x |
robertdebock.fail2ban |
39.62s | 21.63s | 1.8x | 22.33s | 2.65s | 8.4x |
Cold runs are dominated by real apt/download time (both engines wait on the same mirrors), so the trustworthy signal is the warm column: crystal warm runs are 3.5x-11.9x faster (mean ~6.3x), cold runs 1.2x-6.7x faster (mean ~2.2x). Real Ansible pays a fresh Python- interpreter-and-module cost per task on every run regardless of whether anything changes; krikri-playbook's compiled-binary-plus-persistent- connection model is why its warm numbers drop so far below its own cold.
✨ Features
Core Engine
- ✅ YAML playbook parser (Ansible syntax): plays, tasks, handlers,
vars: - ✅ Inventory: static INI + YAML, and dynamic (script-based) inventory
- ✅ SSH connection pooling, plus local (
ansible_connection: local) execution - ✅ Variable substitution
{{ vars }}with a Jinja2 filter pipeline (chained filters, e.g.{{ x | sort | join(',') }}) - ✅ Conditionals
when:(==,!=,<,>,and,or,not,in, dotted attribute access), including magic variables such asinventory_hostnamein bare (non-{{ }}) conditions - ✅ Facts gathering (90+
ansible_*variables), with--gathering implicit|explicit|smartandmeta: clear_facts - ✅ Roles (
roles/<name>/{tasks,handlers,vars,files,templates,defaults}) - ✅
import_playbook/import_tasks/include_tasks/include_role - ✅
block:/rescue:/always:error handling - ✅ Loops:
loop/with_items,with_dict,with_nested,with_sequence,with_indexed_items,with_fileglob - ✅
until:/retries:/delay:retry loops - ✅
async:/poll:fire-and-forget/background task execution - ✅ Handlers (
notify:/listen:) - ✅
become:/become_user:privilege escalation - ✅ Ansible Vault: AES256 encrypt/decrypt,
--ask-vault-pass,--vault-password-file - ✅
--check(dry-run) and--diff(show file changes) modes - ✅
--tagsand--limithost/task filtering - ✅ Task batching - consecutive independent tasks bound for the same remote host run in a single SSH round trip instead of one round trip per task, on by default (
--no-batchingto disable)
Plugins (87 total)
Files & templates: copy, template, file, lineinfile, blockinfile, replace, ini_file, stat, find, archive, unarchive, fetch, get_url, slurp
Execution: command, shell, async_status, debug, assert, fail, set_fact, pause, wait_for, wait_for_connection, uri, ping, capabilities
Packages: apt, apt_key, apt_repository, deb822_repository, dnf, yum, yum_repository, rpm_key, package, package_facts, pip, gem, npm, alternatives, make
Users, groups & access: user, group, authorized_key, cron, getent, openssh_keypair, htpasswd
Services & system: service, systemd, service_facts, sysctl, mount, modprobe, firewalld, ufw, facts, setup, filesystem, timezone, hostname
Security: selinux, seboolean, pamd, pam_limits, openssl_dhparam
Source control: git
Docker: docker_container, docker_image, docker_network (including networks:/connected: attachment and TLS-secured remote daemon support)
Databases: mysql_db, mysql_user, mysql_info, mysql_query, postgresql_db, postgresql_user, postgresql_privs (every type: real Ansible supports, including function/procedure signatures and default_privs)
🚀 Quick Start
Prerequisites
- Crystal (tested with 1.20.x - see
shard.ymlfor the declared minimum) (install guide) - The
sshCLI onPATHfor remote targets (SSH connections use nativessh/ControlMasterunder the hood, not a bundled library)
Build & Run
# Install dependencies
shards install
# Build (all plugins + the CLI)
./build.sh
# Run a playbook
./bin/krikri-playbook playbook.yml
# With options
./bin/krikri-playbook --check --diff -i inventory.ini playbook.yml
📁 Project Structure
krikri-playbook/
├── krikri-playbook.cr # CLI entry point
├── src/krikri/ # Engine: parser, task executor, SSH,
│ # inventory, roles, loops, vault, facts
├── plugins/ # One binary per Ansible module (87 total)
├── spec/ # crystal spec unit + integration tests
├── compat/ # Docker-based real-ansible-playbook
│ # compatibility harness
├── testing/ # Manual smoke-test fixture playbooks
├── build.sh # Build script (all plugins + CLI)
└── shard.yml # Dependencies
💡 Usage Example
- name: Deploy app
hosts: webservers
become: true
tasks:
- name: install nginx
package:
name: nginx
state: present
- name: start nginx
service:
name: nginx
state: started
notify: reload nginx
handlers:
- name: reload nginx
service:
name: nginx
state: reloaded
Supports standard Ansible playbook syntax. See the Ansible documentation for playbook reference.
🎯 Command Reference
# Basic usage
./bin/krikri-playbook playbook.yml
# With inventory
./bin/krikri-playbook -i inventory.ini playbook.yml
# Dry-run (check mode)
./bin/krikri-playbook --check playbook.yml
# Show changes
./bin/krikri-playbook --diff playbook.yml
# Verbose output
./bin/krikri-playbook -v playbook.yml
# Limit to a host group/pattern, run only tagged tasks
./bin/krikri-playbook -l webservers -t deploy playbook.yml
# Vault-encrypted playbook/vars
./bin/krikri-playbook --ask-vault-pass playbook.yml
./bin/krikri-playbook --vault-password-file pass.txt playbook.yml
# Disable task batching (on by default - see Performance above)
./bin/krikri-playbook --no-batching -i inventory.ini playbook.yml
# Run each task against up to 10 hosts concurrently (default: 5, matching
# ansible-playbook; --forks 1 restores one-host-at-a-time)
./bin/krikri-playbook --forks 10 -i inventory.ini playbook.yml
# Fact gathering policy (default: implicit, matching ansible-playbook):
# implicit - every play re-gathers
# explicit - only plays that set gather_facts: true
# smart - each host gathered at most once per run
# Under smart, add `meta: clear_facts` to a play (e.g. after a reboot or a
# package install) to force the next play to gather again.
./bin/krikri-playbook --gathering smart -i inventory.ini playbook.yml
# Multiple options
./bin/krikri-playbook --check --diff -i production.ini playbook.yml
Ad-hoc commands (krikri)
A separate binary, matching real Ansible's own ansible/ansible-playbook split - runs exactly one module against a pattern of inventory hosts, reusing the same connection/become/check-mode/forks engine as the playbook runner:
./bin/krikri all -m ping
./bin/krikri webservers -a 'uptime'
./bin/krikri all -m command -a 'systemctl status nginx'
./bin/krikri all -m copy -a 'src=foo.conf dest=/etc/foo.conf' -b
./bin/krikri db -i inventory.ini -m service -a 'name=postgresql state=restarted' -b
Supports -i, -m, -a, -u, -b/--become, --become-user, -C/--check, -f/--forks, -l/--limit, -v. Output matches real ansible's own minimal callback (host | SUCCESS => {...} / host | CHANGED | rc=0 >>), not ansible-playbook's ok: [host] TASK-recap style.
✅ Testing
# Unit + integration specs (crystal spec's own test runner)
crystal spec
# Ansible compatibility harness - runs the same playbooks through real
# ansible-playbook and krikri-playbook side by side and diffs the result
crystal run compat/run.cr
See compat/README.md for what the compatibility harness covers and how it works.
🕐 Recent changes
A short rolling summary of the last few versions - see git log for the complete history (150+ rounds of real-host benchmarking) and KNOWN_MISSING.md/ROLES_TESTED.md for current-state detail.
0.9.646- round 200 (60 more untested roles, fresh host pair per role). Three real engine divergences found and fixed:assert:was missingstrict: trueon its ownthat:evaluation (the identical check forwhen:already had it), so a non-bool result (a real int) was silently accepted as truthy instead of failing the task (mrlesmithjr.postgresql); the vendored Crinja fork'sValue#comparehad no case forCrinja::Tuple, so sorting a dict's.items()(a list of 2-tuples) raised "cannot compare Crinja::Tuple with Crinja::Tuple" even thoughCrinja::Tuplealready implements element-wise<=>(Oefenweb.bash,Oefenweb.nagios_server); avars:-level ternary selecting between two role-default LISTS as a loop source resolved to Crinja's Python-repr display text instead of a real array (Oefenweb.percona_client). A fourth (andrewrothstein.traefik- a bare{% if %}-block-tag variable reached as a sub-expression inside a larger literal+template string) was found and investigated, but the obvious fix broke three existing strict-undefined specs and was reverted - documented inKNOWN_MISSING.mdfor a more careful pass. Full per-role verdicts and timings inROLES_TESTED.md's round-200 rows.0.9.643- round 199 (60 untested roles, fresh Atlantic.net host pair per role - a revised CLAUDE.md workflow, replacing the previous "reuse a pair for a handful of roles" guidance). Two real engine divergences found and fixed:rerender_if_templatedonly re-rendered avars:value that was a{{ }}span spanning its ENTIRE string - a mixed literal+template value ("{{ x }}/y.conf") fell through unrendered into a chained.lstrip()/method call and collapsed to an empty string (Oefenweb.nginx); awhen:list's already-parenthesized multi-line OR-clause item was double-wrapped by the AND-join and only single-unwrapped, so a block ran when it should have skipped (bodsch.dnsmasq). A third (lookup(...).method()chained with no|mis-locating the lookup call's own closing paren, and avars:filter chain resolving to a real Bool losing its type) is documented inKNOWN_MISSING.md, not yet fixed. Also confirmed thebodsch.*author's ownbodsch.core/bodsch.systemdcollections' custom modules/filters hit the existing no-arbitrary-Python scope cut on 8 of 15 roles tried from that author - not new bugs, but a pattern worth knowing before picking more of that author's roles expecting a different outcome. Full per-role verdicts and timings inROLES_TESTED.md's round-199 rows.0.9.641- removeskrikri-playbook-fastand the whole parity-breaking tier (0.9.639's minimal fact gathering and0.9.640's package coalescing). Benchmarked across ten real roles it measured 1.00x cold, 1.03x warm - inside run-to-run variance, with the sign flipping per role. Package coalescing never fired on a single real role; fact subsetting engaged on 7 of 10 but saves ~50ms against multi-second runs. Against that it produced a silent wrong answer:dev-sec.os-hardeningcame outok=24where the parity binary gaveok=25, because the only hardware-fact reference in the role lives inside a.j2template the planner never opened. Trading parity - the entire point of this engine - for nothing measurable is a bad deal, so the tier is gone and there is one binary again. The per-module timings that settled it are ingit log.0.9.638- implements thevarsmagic variable forwhen:/assert:(a dict of every variable in scope). The Crinja path already had one; the hand-rolled conditional evaluator did not, soX in varsfailed with "'vars' is undefined" - which blocked the wholeprometheus.prometheuscollection, whose preflight uses exactly that. Round 198's 10-role python-vs-crystal sweep otherwise matched 18/20 cold+warm comparisons: mean 1.41x cold, 10.36x warm.0.9.637- skips the plugin-verification round trip when a previous run already verified the same binaries on that host (controller-side record, TTL-bounded, keyed on the current local md5). Bootstrap is under 5% of a long run but 59-74% of a sub-second one, so this targets the small roles batching cannot help: measured 1.41x warm onrobertdebock.cron. Self-heals if the binaries turn out to be missing - a host rebuilt behind the same address re-uploads rather than failing;testing/ipreuse/reproduces that case deterministically.--no-plugin-state-cacheopts out.
🤝 Contributing
Contributions welcome! Please:
- Review the existing code structure and KNOWN_MISSING.md
- Verify any Ansible-compatibility claims against real
ansible-playbookoutput, not just documentation - Test your changes thoroughly (
crystal spec, andcompat/run.crfor plugin behavior changes) - Submit a pull request with a clear description
📄 License
MIT License - see LICENSE file for details.
🙏 Acknowledgments
We love Ansible, and this project wouldn't have been possible without it.
krikri-playbook exists because ansible-core is genuinely great software. The playbook/role/inventory model, the module ecosystem, and the Jinja2-based templating that this project spends so much effort matching are Ansible's design - the product of more than a decade of real-world use and the work of the Ansible community and Red Hat behind it. This project is a tribute to that design as much as it is a reimplementation of it: we admire it enough to have rebuilt it, line by line, in another language.
Where krikri-playbook is faster, that's simply a different execution model (compiled binaries vs. a Python interpreter per task) - not a knock on Ansible, which made the choices it made for good reasons of its own. ansible-core is, and remains, the reference implementation this project is measured against and tries to be worthy of.
This project was built with the help of AI coding assistants (Claude Code) and other AI models: MiniMax, DeepSeek Flash, GLM 5.3 Express.
Thanks also to:
- Crystal, the language this is built with
- crinja for Jinja2 templating, among other Crystal shards - see
shard.yml
Ansible and the Ansible logo are trademarks of Red Hat, Inc., registered in the United States and other countries. This project is not affiliated with, sponsored by, or endorsed by Red Hat, Inc. or the Ansible project.
krikri - Ansible-compatible automation in Crystal 🚀
krikri
- 0
- 0
- 0
- 0
- 8
- 13 minutes ago
- January 31, 2026
MIT License
Mon, 31 Aug 2026 00:49:36 GMT