Use Ansible playbooks in CFEngine policy with promise-type-ansible module

Posted by Craig Comstock
June 3, 2024

Whether you are migrating from Ansible to CFEngine to gain some of the benefits of scale or autonomy or just need some functionality in an Ansible module, the ansible promise type can be a great tool to utilize.

It also provides a compelling alternative to ansible-pull and works around some of the caveats included with that strategy. CFEngine has battle-tested features needed for the pull architecture:

  • cf-execd handles scheduling periodic runs as ansible-pull suggests using cron
  • cf-agent handles locking to avoid concurrent runs of the same playbooks

A tiny Ansible project example

Taking some first-step tips from 5 ways to harden a new system with Ansible let’s make a sample playbook project which patches Linux systems.

Ansible has some good documentation about best practices and roles that make it easy to develop a somewhat realistic mini ansible playbook project.

For reference, I have shared a cfbs project in the promise-type-ansible branch of our repository of example projects at play-cfbs.

The crux of our tiny Ansible project is to patch our hosts. This will be implemented as a patch role in roles/patch/tasks/main.yml:

- name: Perform full patching
  package:
    name: '*'
    state: latest

This role is included in our main site.yml file as follows:

---
- hosts: managed
  tasks:
    - ansible.builtin.debug:
        msg: first play

  roles:
    - patch

In this tiny project the managed group includes all hosts.

CFEngine policy to ensure the playbooks are run

The next step is to make an inventory file for Ansible which will enable running the playbook on each managed host but using a local connection so that no SSH credentials need be configured.

For a typical set of playbooks you would likely have many groups in your inventory. We can map classes in CFEngine to groups in this localhost inventory with some CFEngine policy:

  classes:
    "managed"
      meta => { "ansible_group" };
    "webserver"
      meta => { "ansible_group" };

  vars:
    "localhost_inventory"
      string => "$(sys.statedir)/localhost_inventory.ini";
    "ansible_groups"
      slist => classesmatching(".*", "ansible_group");

  files:
    "$(localhost_inventory)"
      create => "true",
      content => string_mustach("[all:vars]
ansible_connection=local
{{#-top-}}[{{.}}]
localhost
{{/-top-}}",
        "ansible_groups"),
      classes => if_ok("localhost_inventory_created");

This creates a file we will use later in our policy:

[all:vars]
ansible_connection=local
[managed]
localhost
[webserver]
localhost

We use the built-in group all to set ansible_connection=local so we can run the playbooks manually using the generated inventory. The ansible promise type defaults to this setting.

Of course, we need Ansible to be installed on this host, so we use a package promise and set a class to wait until it is installed before trying to run the playbook.

  packages:
    "ansible"
      classes => if_ok("ansible_package_installed");

Finally our policy handles copying the ansible_project directory to a nice place in /var/cfengine/state and runs site.yml the top-level site playbook.

  vars:
    "remote_ansible_project"
      string => string_replace("$(this.promise_dirname)", "inputs", "masterfiles");
    "ansible_project"
      string => "$(sys.statedir)/ansible_project";

  files:
    "$(ansible_project)"
      copy_from => remote_dcp("$(remote_ansible_project)", @(def.policy_servers)),
      depth_search => recurse(inf);

  ansible:
    ansible_package_installed.localhost_inventory_created::
      "site.yml"
        playbook => "$(ansible_project)/site.yml",
        inventory => "$(localhost_inventory)";

The complete policy

ansible_project/main.cf
bundle agent ansible_main
{
  vars:
    "ansible_project"
      string => "$(sys.statedir)/ansible_project";
    "localhost_inventory"
      string => "$(sys.statedir)/localhost_inventory.ini";
    "remote_ansible_project"
      string => string_replace("$(this.promise_dirname)", "inputs", "masterfiles");

  classes:
    # initial thought about how to translate CFEngine classification into Ansible groups classification for dynamic inventory
    "managed"
      meta => { "ansible_group" };
    "webserver"
      meta => { "ansible_group" };

  vars:
    "ansible_groups" slist => classesmatching(".*", "ansible_group");

  reports:
    "ansible_groups: ${ansible_groups}";

  files:
    "$(localhost_inventory)"
      create => "true",
      content => string_mustache("[all:vars]
ansible_connection=local
{{#-top-}}[{{.}}]
localhost
{{/-top-}}",
"ansible_groups"
),
      classes => if_ok("localhost_inventory_created");

    "$(ansible_project)"
      copy_from => remote_dcp($(remote_ansible_project), @(def.policy_servers)),
      depth_search => recurse(inf);

  packages:
    "ansible"
      classes => if_ok("ansible_package_installed");

  ansible:
    ansible_package_installed.localhost_inventory_created::
      "site.yml"
        playbook => "$(ansible_project)/site.yml",
        inventory => "$(localhost_inventory)";
}

Debugging CFEngine and Ansible

Debugging problems is a little tricky. You will have to use both your CFEngine and Ansible debugging skills. To get started you can run the policy with debug logging enabled and focus on the bundle in question with

cf-agent -Kdb ansible_main | tee ~/log

And to debug the Ansible side run the playbook something like this:

ansible-playbook -i /var/cfengine/state/localhost_inventory.ini /var/cfengine/state/ansible_project/site.yml

In the cf-agent debug output will look something like the following:

   debug: DeRefCopyPromise(): promiser:'site.yml'
   debug: DeRefCopyPromise():     copying constraint: 'playbook'
   debug: DeRefCopyPromise():     copying constraint: 'inventory'
 verbose: Starting custom promise module '/var/cfengine/modules/promises/ansible_promise.py' with command '/usr/bin/python3 /var/cfengine/modules/promises/ansible_promise.py'
   debug: Received header from promise module: 'ansible_promise_module 0.2.2 v1 json_based'
   debug: Received line from module: '{"operation": "validate_promise", "promiser": "site.yml", "attributes": {"inventory": "/var/cfengine/state/localhost_inventory.ini", "playbook": "/var/cfengine/state/ansible_project/site.yml"}, "result": "valid"}'
   debug: Received line from module: 'log_verbose=Task 'Gathering Facts' started on 'localhost''
 verbose: Task 'Gathering Facts' started on 'localhost'
   debug: Received line from module: 'log_verbose=Task 'Gathering Facts' didn't change'
 verbose: Task 'Gathering Facts' didn't change
   debug: Received line from module: 'log_verbose=Task 'Perform full patching' started on 'localhost''
 verbose: Task 'Perform full patching' started on 'localhost'
   debug: Received line from module: 'log_error=Task 'Perform full patching' failed'
   error: Task 'Perform full patching' failed
   debug: Received line from module: 'log_verbose=Summary of the tasks for 'localhost' is: ok=1 failures=1'
 verbose: Summary of the tasks for 'localhost' is: ok=1 failures=1
   debug: Received line from module: '{"operation": "evaluate_promise", "promiser": "site.yml", "attributes": {"inventory": "/var/cfengine/state/localhost_inventory.ini", "playbook": "/var/cfengine/state/ansible_project/site.yml"}, "result": "not_kept", "result_classes": ["site.yml_failed"]}'
   debug: Setting class: default:site_yml_failed
 verbose: Additional promise info: version 'CFEngine Promises.cf 3.21.4' source path '/var/cfengine/inputs/services/cfbs/ansible_project/main.cf' at line 43
 verbose: Promise with promiser 'site.yml' was not kept by promise module '/var/cfengine/modules/promises/ansible_promise.py'
 verbose: A: Promise NOT KEPT!
 verbose: P: END ansible promise (site.yml)

Let us know how it goes if you try it out!

Questions?

If you have questions or need help, reach out on the mailing list or GitHub discussions. If you have a support contract, feel free to open a ticket in our support system.