Skip to content

Migrate from Ansible

Most Ansible playbooks for a single server map onto sadame resource by resource: packages, files, templates, services, users and guarded commands work the same way, with Ruby instead of YAML and ERB instead of Jinja2. This guide translates the ten modules that make up most playbooks, then the inventory, using examples/migrate-from-ansible: an Ansible playbook and its translation, which the test suite applies on Debian and Ubuntu.

What is the same, and what is not

Ansible sadame
playbook (YAML) recipe (Ruby)
hosts: web, --limit -g web, --limit
vars:, group_vars, host_vars, -e vars.rb, group and host vars in hosts.rb, --var
Jinja2 templates ERB templates with vars and locals
notify: and handlers notifies: and subscribes:
when: Ruby if
ansible_facts: ansible_processor_vcpus, ansible_memtotal_mb, ansible_default_ipv4.address facts[:cpus], facts[:memory_mb], facts[:ipv4]; read when used, no gather_facts
loop: Ruby each
--check --diff sadame plan
--syntax-check sadame validate, which also renders templates for each host
tags:, --tags, --skip-tags tags: and include_recipe ..., tags:, --tags, --skip-tags
forks --forks
serial: --serial
no built-in drift command sadame drift
become: true sudo: true in hosts.rb, or --sudo
Python on every target nothing on the targets; sadame copies itself
roles, collections, Galaxy include_recipe and plain Ruby; no package manager for recipes

Tasks run in order in both. A sadame resource reads the current state before acting, as an idempotent Ansible module does; there is no changed_when, because a resource changes exactly when its state differs.

The ten common modules

Each block shows the Ansible task and its translation from the example.

package / apt / dnf

    - name: Install packages
      ansible.builtin.apt:
        name: [nginx, openssl, cron, curl]
        state: present
        update_cache: true
# apt with update_cache: sadame runs apt-get update before the first
# install of a run.
%w[nginx openssl cron curl].each { |name| package name }

package picks apt, dnf, apk or pacman from the distribution, so one recipe covers several families.

copy

    - name: Login banner
      ansible.builtin.copy:
        dest: /etc/issue.net
        content: "Authorized access only.\n"
        mode: "0644"

    - name: Backup script
      ansible.builtin.copy:
        src: files/backup.sh
        dest: /usr/local/bin/backup
        mode: "0755"
file "/etc/issue.net", content: "Authorized access only.\n", mode: "0644"
file "/usr/local/bin/backup", source: "backup.sh", mode: "0755"

template

    - name: Site configuration
      ansible.builtin.template:
        src: templates/app.conf.j2
        dest: /etc/nginx/conf.d/app.conf
        mode: "0644"
      notify: Reload nginx
# Handlers are notifications; delayed, like Ansible's, by default.
file "/etc/nginx/conf.d/app.conf",
     template: "app.conf.erb",
     mode: "0644",
     notifies: [:reload, "service[nginx]"]

{{ server_name }} becomes <%= vars[:server_name] %>; {% for %} and {% if %} become <% ... %> with Ruby. See Recipe basics.

file

    - name: Application directory
      ansible.builtin.file:
        path: /srv/app
        state: directory
        owner: www-data
        mode: "0755"

    - name: Old configuration is gone
      ansible.builtin.file:
        path: /etc/nginx/conf.d/old.conf
        state: absent
directory "/srv/app", owner: "www-data", mode: "0755"
file "/etc/nginx/conf.d/old.conf", state: :absent

service / systemd

    - name: nginx runs and starts at boot
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true
service "nginx", enable: true, state: :running

Handlers become the service's notification actions: the handler

  handlers:
    - name: Reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded

is notifies: [:reload, "service[nginx]"] on the file that notified it.

user

    - name: Deploy user
      ansible.builtin.user:
        name: deploy
        shell: /bin/bash
        groups: www-data
        append: true
# groups: always appends, like append: true.
user "deploy", shell: "/bin/bash", groups: ["www-data"]

command / shell with creates

    - name: Self-signed certificate
      ansible.builtin.command:
        cmd: >-
          openssl req -x509 -newkey rsa:2048 -nodes -days 365 -subj /CN=app.example.com
          -keyout /etc/ssl/private/app.key -out /etc/ssl/certs/app.crt
        creates: /etc/ssl/private/app.key
exec "openssl req -x509 -newkey rsa:2048 -nodes -days 365 -subj /CN=app.example.com " \
     "-keyout /etc/ssl/private/app.key -out /etc/ssl/certs/app.crt",
     creates: "/etc/ssl/private/app.key"

creates: is the same; removes: has no equivalent, use only_if: "test -e PATH". A command without a guard runs on every apply in both tools, and sadame's drift always reports it.

lineinfile

    - name: Default editor
      ansible.builtin.lineinfile:
        path: /etc/environment
        regexp: "^EDITOR="
        line: EDITOR=vim
line "EDITOR=vim", path: "/etc/environment", match: /^EDITOR=/

line is sadame's lineinfile: match plays the part of regexp, and state: :absent removes the line. One difference: when several lines match, line replaces all of them, where lineinfile replaces the last one. Prefer managing the whole file with file when sadame can own it, so the recipe states what the file contains; see line.

cron

    - name: Nightly backup
      ansible.builtin.cron:
        name: backup
        minute: "30"
        hour: "2"
        user: root
        job: /usr/local/bin/backup
file "/etc/cron.d/backup", content: "30 2 * * * root /usr/local/bin/backup\n", mode: "0644"

Ansible's cron edits the user's crontab by default; the translation uses a file in /etc/cron.d, which names the user on each line. See examples/09-cron-and-logrotate.

get_url

    - name: A page from another server
      ansible.builtin.get_url:
        url: https://www.example.com/
        dest: /srv/app/example.html
        mode: "0644"
exec "curl -fsSL -o /srv/app/example.html https://www.example.com/",
     creates: "/srv/app/example.html"

The inventory

An INI inventory:

examples/migrate-from-ansible/ansible/inventory.ini
[web]
web1.example.com
web2.example.com workers=8

[db]
db1.example.com ansible_user=admin ansible_port=2222 ansible_become=true

[web:vars]
workers=4

[all:vars]
env=production

or its YAML form:

examples/migrate-from-ansible/ansible/inventory.yml
all:
  vars:
    env: production
  children:
    web:
      vars:
        workers: 4
      hosts:
        web1.example.com:
        web2.example.com:
          workers: 8
    db:
      hosts:
        db1.example.com:
          ansible_user: admin
          ansible_port: 2222
          ansible_become: true

becomes:

examples/migrate-from-ansible/hosts.rb
# ansible/inventory.ini and ansible/inventory.yml, translated. Ansible's
# all group is implicit; here it is declared, including the other groups.
group :web do
  host "web1.example.com"
  host "web2.example.com", vars: { workers: 8 }
  vars workers: 4
end

group :db do
  host "db1.example.com", user: "admin", port: 2222, sudo: true
end

group :all, include: [:web, :db] do
  vars env: "production"
end
Ansible hosts.rb
[web] with host lines group :web do host "..." end
ansible_host address:
ansible_user, ansible_port user:, port:
ansible_become=true sudo: true
ansible_ssh_common_args ssh_opts:
[web:vars] vars inside the group
[all:vars] a group :all that includes the others; Ansible's all is implicit, sadame's is not
[app:children] group :app, include: [...]
dynamic inventory scripts and plugins Ruby in hosts.rb; see Inventory

Precedence is similar: a host's vars override its groups', and a group's vars override the groups it includes. Ansible has many more layers (role defaults, facts, set_fact); sadame has four, listed on the Inventory page.

Gaps

Things Ansible does that sadame does not:

  • sixteen facts (CPUs, memory, disk, addresses, virtualization) rather than Ansible's hundreds: no package, service or mount facts, no facts.d;
  • ten resource types (package, file, line, directory, link, service, exec, user, group, git) where Ansible has thousands of modules: no apt_repository, mount, sysctl, firewalld and so on; use file, line and guarded exec (see examples/04-docker-host for a package repository);
  • no max_fail_percentage: --serial stops after the first batch with a failure;
  • no Windows, no network devices, no agent or pull mode;
  • no vault; read secrets from the environment in vars.rb;
  • no roles from Galaxy: recipes are files you include_recipe.

Common mistakes

  • Translating shell tasks one to one. Most shell tasks in a playbook exist because a module was missing; check whether a file or package resource says the same thing as state.
  • Forgetting the guard. An exec without creates, not_if or only_if runs every time and makes drift fail.
  • Expecting all to exist. Declare it in hosts.rb if you use it.

See also