Converting Playbook Into Role
This is the moment promised at the start of this section: the “Full Synthesis” deployment playbook from the close of the common modules section, refactored into a proper, reusable role — piece by piece, with the reasoning behind each decision made explicit.
Step 1: Scaffold The Role
ansible-galaxy role init roles/myappStep 2: Identify What Should Be Configurable — defaults/main.yml
Everything hardcoded in the original playbook that a reasonable user might want to change for their own deployment:
app_dir: /etc/myapp
app_owner: alice
app_group: alice
app_dir_mode: '0755'
max_connections: 100
health_check_port: 8080
health_check_retries: 10
health_check_delay: 3
service_name: myappEvery one of these was a hardcoded literal in the original playbook — the directory path, the tuning value, the health check port. Now they’re genuinely configurable, at the lowest precedence, exactly as the defaults chapter established.
Step 3: Identify What’s Internal — vars/main.yml
One value in the original playbook is derived from another, and shouldn’t be set independently without breaking that relationship:
conf_d_dir: "{{ app_dir }}/conf.d"conf_d_dir only makes sense as a subdirectory of app_dir — letting someone override it independently, disconnected from wherever app_dir actually points, would break the role’s own internal assumptions. This is exactly the kind of value the high-precedence vars/main.yml is for.
Step 4: Move The Tasks — tasks/main.yml
Every task from the original playbook, now referencing role variables instead of hardcoded values:
- name: Ensure app directory exists
ansible.builtin.file:
path: "{{ app_dir }}"
state: directory
owner: "{{ app_owner }}"
group: "{{ app_group }}"
mode: "{{ app_dir_mode }}"
- name: Install Apache (Debian)
ansible.builtin.apt:
name: apache2
state: present
when: ansible_facts['os_family'] == 'Debian'
- name: Install Apache (RedHat)
ansible.builtin.dnf:
name: httpd
state: present
when: ansible_facts['os_family'] == 'RedHat'
- name: Deployment attempt
block:
- name: "Copy discovered config: {{ item | basename }}"
ansible.builtin.copy:
src: "{{ item }}"
dest: "{{ conf_d_dir }}/{{ item | basename }}"
loop: "{{ query('fileglob', role_path + '/files/configs/*.conf') }}"
notify: Restart app
- name: Deploy main config from template
ansible.builtin.template:
src: app.conf.j2
dest: "{{ app_dir }}/app.conf"
notify: Restart app
- name: Ensure a specific tuning setting is correct
ansible.builtin.lineinfile:
path: "{{ app_dir }}/app.conf"
regexp: '^max_connections'
line: "max_connections = {{ max_connections }}"
notify: Restart app
rescue:
- name: Alert on any deployment failure
ansible.builtin.debug:
msg: "Deployment failed on {{ inventory_hostname }}"
changed_when: true
notify: Send alert
- name: Wait for the app to respond after any restart
ansible.builtin.command: "curl -s -o /dev/null -w '%{http_code}' http://localhost:{{ health_check_port }}/health"
register: health_check
until: health_check.stdout == "200"
retries: "{{ health_check_retries }}"
delay: "{{ health_check_delay }}"Step 5: Move The Handlers — handlers/main.yml
- name: Restart app
ansible.builtin.systemd:
name: "{{ service_name }}"
state: restarted
- name: Send alert
ansible.builtin.debug:
msg: "ALERT: deployment failed on {{ inventory_hostname }}"Step 6: Move The Template
app.conf.j2 moves into roles/myapp/templates/, and template’s src: app.conf.j2 in the tasks above needs no path at all — the automatic role-relative resolution from earlier in this section handles it.
A New Consideration: fileglob Inside A Role
Look closely at the copy task above — query('fileglob', role_path + '/files/configs/*.conf') is doing something worth understanding, not just copying blindly. copy and template’s src: parameters get automatic role-relative resolution for free, but a lookup plugin like fileglob doesn’t get that same treatment — it’s a general-purpose function, not tied to the role’s own file-resolution mechanism. role_path, a magic variable holding the current role’s own directory, makes the reference explicit: {{ role_path }}/files/configs/*.conf reaches into this specific role’s files/ directory deliberately, rather than relying on whatever the ambient relative-path resolution would otherwise assume.
Step 7: The Playbook, Reduced To Almost Nothing
- name: Full Application Deployment
hosts: servers
roles:
- myappEverything the original play did is still happening — it’s just packaged, reusable, and configurable now instead of hardcoded in one long play.
The Full Before And After
Deploying a second, genuinely different application with the exact same role, using mechanism two from the variables-passing chapter:
- name: Deploy Two Apps With The Same Role
hosts: servers
roles:
- role: myapp
app_dir: /etc/webapp
service_name: webapp
health_check_port: 8080
- role: myapp
app_dir: /etc/apiapp
service_name: apiapp
health_check_port: 9090This is the entire point of this section, now fully real: the same install-configure-restart-verify workflow, applied twice, to two genuinely different applications, with zero duplicated logic.
Best Practices
- Refactor a working playbook into a role once you find yourself wanting to apply the same pattern more than once — this exact motivation, from the start of this section, is the signal to act on.
- Sort every hardcoded value into
defaults/orvars/deliberately, based on whether it’s genuinely something a user should configure, or something the role’s own logic depends on staying consistent. - Use
role_pathexplicitly for lookup plugins that need to reach into the role’s own files, since lookups don’t get the same automatic resolutioncopy/template’ssrc:enjoys.