Conversion Techniques#
Detailed guidance on converting Chef cookbooks to Ansible playbooks, including recipes, resources, templates, and complex patterns.
Conversion Overview#
Conversion is where Chef infrastructure code transforms into Ansible playbooks. SousChef automates the heavy lifting while preserving logic and intent.
Iterative Conversion
Convert incrementally: one recipe at a time, validate each conversion, then move to the next. This approach catches issues early and builds confidence.
Conversion Strategy#
```mermaid graph TD A[Parse Chef Code] --> B[Map Resources] B --> C[Convert to Tasks] C --> D[Transform Templates] D --> E[Migrate Variables] E --> F[Generate Handlers] F --> G[Validate Output] G --> H{Tests Pass?} H -->|Yes| I[Commit & Next] H -->|No| J[Fix Issues] J --> G
style A fill:#e3f2fd
style C fill:#fff3e0
style G fill:#e8f5e9
style H fill:#f3e5f5
```
Recipe to Playbook Conversion#
Basic Recipe Conversion#
Chef Recipe:
# recipes/webserver.rb
package 'nginx' do
action :install
end
service 'nginx' do
action [:enable, :start]
end
template '/etc/nginx/nginx.conf' do
source 'nginx.conf.erb'
owner 'root'
group 'root'
mode '0644'
notifies :reload, 'service[nginx]', :delayed
end
Conversion Command:
Generated Ansible Playbook:
---
- name: Configure web server
hosts: webservers
become: true
tasks:
- name: Install nginx
ansible.builtin.package:
name: nginx
state: present
- name: Enable and start nginx
ansible.builtin.service:
name: nginx
enabled: true
state: started
- name: Deploy nginx configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: '0644'
notify: Reload nginx
handlers:
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
Multi-Recipe Conversion#
For cookbooks with multiple recipes:
# Convert all recipes in cookbook
for recipe in cookbooks/myapp/recipes/*.rb; do
recipe_name=$(basename "$recipe" .rb)
echo "Converting: $recipe_name"
souschef-cli convert-recipe "$recipe" > "playbooks/myapp_${recipe_name}.yml"
done
Organise as Role:
roles/myapp/
├── defaults/
│ └── main.yml # Default variables
├── tasks/
│ ├── main.yml # Main entry point
│ ├── install.yml # From install.rb
│ ├── configure.yml # From configure.rb
│ └── deploy.yml # From deploy.rb
├── templates/
│ └── config.j2
└── handlers/
└── main.yml
Resource Mapping#
Core Resource Types#
| Chef Resource | Ansible Module | Notes |
|---|---|---|
package |
ansible.builtin.package |
Cross-platform |
service |
ansible.builtin.service |
Systemd support |
template |
ansible.builtin.template |
ERB → Jinja2 |
file |
ansible.builtin.file |
Ownership, permissions |
directory |
ansible.builtin.file |
state: directory |
user |
ansible.builtin.user |
User management |
group |
ansible.builtin.group |
Group management |
execute |
ansible.builtin.command |
One-off commands |
bash |
ansible.builtin.shell |
Shell scripts |
git |
ansible.builtin.git |
Git repos |
cron |
ansible.builtin.cron |
Cron jobs |
Platform-Specific Resources#
Chef Platform-Specific Packages:
# Chef
case node['platform_family']
when 'debian'
apt_package 'apache2'
when 'rhel'
yum_package 'httpd'
end
Ansible with Facts:
- name: Install web server (Debian)
ansible.builtin.apt:
name: apache2
state: present
when: ansible_os_family == 'Debian'
- name: Install web server (RedHat)
ansible.builtin.yum:
name: httpd
state: present
when: ansible_os_family == 'RedHat'
Or use package module (cross-platform):
- name: Install web server
ansible.builtin.package:
name: "{{ 'apache2' if ansible_os_family == 'Debian' else 'httpd' }}"
state: present
Template Conversion#
ERB to Jinja2 Syntax#
| Chef ERB | Ansible Jinja2 | Example |
|---|---|---|
<%= @var %> |
{{ var }} |
Variable interpolation |
<% if @condition %> |
{% if condition %} |
Conditionals |
<% @items.each do \|item\| %> |
{% for item in items %} |
Loops |
<%# comment %> |
{# comment #} |
Comments |
<%= node['attr'] %> |
{{ ansible_local.attr }} |
Facts |
Template Conversion Process#
Chef Template (nginx.conf.erb):
user <%= @user %>;
worker_processes <%= @workers %>;
events {
worker_connections <%= @connections %>;
}
http {
<% if @enable_gzip %>
gzip on;
gzip_types text/plain text/css application/json;
<% end %>
<% @servers.each do |server| %>
upstream <%= server['name'] %> {
<% server['backends'].each do |backend| %>
server <%= backend %>:8080;
<% end %>
}
<% end %>
}
Conversion:
Generated Jinja2 (nginx.conf.j2):
user {{ nginx_user }};
worker_processes {{ nginx_workers }};
events {
worker_connections {{ nginx_connections }};
}
http {
{% if nginx_enable_gzip %}
gzip on;
gzip_types text/plain text/css application/json;
{% endif %}
{% for server in nginx_servers %}
upstream {{ server.name }} {
{% for backend in server.backends %}
server {{ backend }}:8080;
{% endfor %}
}
{% endfor %}
}
Variable File (group_vars/all.yml):
nginx_user: nginx
nginx_workers: 4
nginx_connections: 1024
nginx_enable_gzip: true
nginx_servers:
- name: app
backends:
- 10.0.1.10
- 10.0.1.11
- 10.0.1.12
Guard Conversion#
Simple Guards#
Chef:
Ansible:
- name: Check if app directory exists
ansible.builtin.stat:
path: /opt/app
register: app_dir
- name: Create app configuration
ansible.builtin.file:
path: /etc/app.conf
state: touch
when: app_dir.stat.exists
Complex Guards#
Chef Multiple Conditions:
execute 'deploy' do
command '/opt/deploy.sh'
only_if { ::File.exist?('/opt/app.tar.gz') }
not_if { ::File.exist?('/opt/.deployed') }
not_if { node['maintenance_mode'] }
end
Ansible:
- name: Check for deployment archive
ansible.builtin.stat:
path: /opt/app.tar.gz
register: app_archive
- name: Check for deployment marker
ansible.builtin.stat:
path: /opt/.deployed
register: deploy_marker
- name: Run deployment
ansible.builtin.command:
cmd: /opt/deploy.sh
when:
- app_archive.stat.exists
- not deploy_marker.stat.exists
- not maintenance_mode | default(false)
Array-Based Guards#
Chef:
Ansible:
- name: Check apt sources
ansible.builtin.stat:
path: /etc/apt/sources.list
register: apt_sources
- name: Check dpkg
ansible.builtin.command:
cmd: dpkg --version
register: dpkg_check
failed_when: false
changed_when: false
- name: Install nginx
ansible.builtin.package:
name: nginx
state: present
when:
- apt_sources.stat.exists
- dpkg_check.rc == 0
Notification & Handler Conversion#
Immediate vs Delayed Notifications#
Chef:
template '/etc/nginx/nginx.conf' do
notifies :reload, 'service[nginx]', :delayed
end
template '/etc/app/critical.conf' do
notifies :restart, 'service[app]', :immediately
end
service 'nginx' do
action :nothing
end
service 'app' do
action :nothing
end
Ansible:
# Tasks
- name: Deploy nginx config
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: Reload nginx
- name: Deploy critical config
ansible.builtin.template:
src: critical.conf.j2
dest: /etc/app/critical.conf
notify: Restart app
- name: Flush handlers immediately
meta: flush_handlers # For immediate effect
# Handlers (handlers/main.yml)
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
- name: Restart app
ansible.builtin.service:
name: app
state: restarted
Chained Notifications#
Chef:
template '/etc/app/config.yml' do
notifies :create, 'file[/var/log/app.log]', :immediately
notifies :restart, 'service[app]', :delayed
end
file '/var/log/app.log' do
action :nothing
notifies :run, 'execute[set-permissions]', :immediately
end
Ansible:
- name: Deploy config
ansible.builtin.template:
src: config.yml.j2
dest: /etc/app/config.yml
notify:
- Create log file
- Restart app
# Handlers execute in order defined
- name: Create log file
ansible.builtin.file:
path: /var/log/app.log
state: touch
notify: Set log permissions
- name: Set log permissions
ansible.builtin.command:
cmd: chmod 666 /var/log/app.log
- name: Restart app
ansible.builtin.service:
name: app
state: restarted
Custom Resource Conversion#
Simple Custom Resource#
Chef Custom Resource (resources/app_config.rb):
property :config_name, String, name_property: true
property :settings, Hash, required: true
property :owner, String, default: 'app'
action :create do
directory "/etc/#{new_resource.config_name}" do
owner new_resource.owner
mode '0755'
end
file "/etc/#{new_resource.config_name}/config.json" do
content Chef::JSONCompat.to_json_pretty(new_resource.settings)
owner new_resource.owner
mode '0600'
end
end
Ansible Role Equivalent:
roles/app_config/defaults/main.yml:
roles/app_config/tasks/main.yml:
- name: Create config directory
ansible.builtin.file:
path: "/etc/{{ config_name }}"
state: directory
owner: "{{ app_config_owner }}"
mode: "{{ app_config_mode_dir }}"
- name: Deploy configuration file
ansible.builtin.copy:
content: "{{ settings | to_nice_json }}"
dest: "/etc/{{ config_name }}/config.json"
owner: "{{ app_config_owner }}"
mode: "{{ app_config_mode_file }}"
Usage in Playbook:
- name: Configure application
ansible.builtin.include_role:
name: app_config
vars:
config_name: myapp
settings:
database_url: postgresql://localhost/myapp
log_level: info
port: 8080
Complex Custom Resource → Custom Module#
For very complex resources, create an Ansible module:
module_utils/app_deployment.py:
#!/usr/bin/python
from ansible.module_utils.basic import AnsibleModule
def deploy_app(module, app_name, version, config):
"""Deploy application with complex logic."""
changed = False
# Complex deployment logic here
# ...
return changed, "Deployment successful"
def main():
module = AnsibleModule(
argument_spec=dict(
app_name=dict(type='str', required=True),
version=dict(type='str', required=True),
config=dict(type='dict', required=True),
state=dict(type='str', default='present',
choices=['present', 'absent'])
)
)
changed, message = deploy_app(
module,
module.params['app_name'],
module.params['version'],
module.params['config']
)
module.exit_json(changed=changed, msg=message)
if __name__ == '__main__':
main()
Attribute Migration#
Attribute Precedence Mapping#
Chef Precedence Levels: 1. Automatic (highest) 2. Force Override 3. Override 4. Normal 5. Force Default 6. Default (lowest)
Ansible Variable Precedence (simplified): 1. Extra vars (highest) 2. Task vars 3. Block vars 4. Role vars 5. Play vars 6. Host vars 7. Group vars (priority order) 8. Role defaults (lowest)
Migration Strategy:
| Chef Level | Ansible Equivalent | Location |
|---|---|---|
| Default | Role defaults | roles/*/defaults/main.yml |
| Force Default | Group vars (all) | group_vars/all.yml |
| Normal | Host vars | host_vars/hostname.yml |
| Override | Group vars (specific) | group_vars/production.yml |
| Force Override | Play vars | In playbook |
| Automatic | Facts | ansible_facts.* |
Chef Attributes (attributes/default.rb):
Ansible Variables:
roles/app/defaults/main.yml:
group_vars/all.yml:
Override in Inventory:
Data Bag Migration#
Unencrypted Data Bags#
Chef Data Bag (data_bags/users/john.json):
{
"id": "john",
"full_name": "John Doe",
"email": "john@example.com",
"groups": ["developers", "docker"]
}
Ansible Variables (group_vars/all/users.yml):
Encrypted Data Bags to Ansible Vault#
Chef Encrypted Data Bag:
# Decrypt Chef data bag
knife data bag show secrets database --secret-file ~/.chef/encrypted_data_bag_secret -Fj > database_secrets.json
Convert to Ansible Vault:
# Create vault file
cat > group_vars/all/vault.yml <<EOF
vault_db_password: "{{ lookup('file', 'database_secrets.json') | from_json | json_query('password') }}"
vault_db_host: "{{ lookup('file', 'database_secrets.json') | from_json | json_query('host') }}"
EOF
# Encrypt with Ansible Vault
ansible-vault encrypt group_vars/all/vault.yml
Reference in Playbook:
- name: Configure database
ansible.builtin.template:
src: database.yml.j2
dest: /etc/app/database.yml
vars:
db_password: "{{ vault_db_password }}"
db_host: "{{ vault_db_host }}"
no_log: true
Environment Conversion#
Chef Environment (environments/production.rb):
name 'production'
description 'Production environment'
override_attributes(
'app' => {
'port' => 80,
'workers' => 8,
'log_level' => 'warn'
}
)
Ansible Inventory Structure:
# inventory/production/hosts.yml
all:
children:
production:
hosts:
app01:
ansible_host: 10.0.1.10
app02:
ansible_host: 10.0.1.11
# inventory/production/group_vars/production.yml
app_port: 80
app_workers: 8
app_log_level: warn
environment: production
Validation During Conversion#
Syntax Validation#
After each conversion:
# Validate YAML syntax
ansible-playbook playbooks/myapp.yml --syntax-check
# Validate with ansible-lint
ansible-lint playbooks/myapp.yml
# Check mode (dry run)
ansible-playbook playbooks/myapp.yml --check -i inventory/dev
Semantic Validation with SousChef#
# Validate conversion accuracy
souschef-cli validate playbook \
--original recipes/myapp.rb \
--converted playbooks/myapp.yml \
--format text
Validation Output:
[OK] Syntax: PASSED (YAML valid)
[OK] Semantic: PASSED (Logic preserved)
WARNING Best Practice: 2 warnings
- Consider using 'package' instead of 'apt'
- Add 'become: true' for privilege escalation
[OK] Security: PASSED
[OK] Performance: PASSED
Overall: PASSED with warnings
Conversion Checklist#
Per-Recipe Checklist#
- Parse recipe with
parse_recipe - Review resource types and actions
- Identify notification chains
- Check guard conditions
- Convert recipe to playbook
- Transform templates (ERB → Jinja2)
- Map variables (attributes → vars)
- Create handlers for notifications
- Validate syntax
- Test in development
- Verify idempotency
- Document any manual changes
Per-Cookbook Checklist#
- Convert all recipes
- Migrate custom resources to roles/modules
- Transform all templates
- Migrate attribute files
- Convert data bags to vars/vault
- Update environment-specific variables
- Generate InSpec tests
- Organise as role (if appropriate)
- Create README with usage examples
- Tag in version control
Best Practices#
Do's [YES]#
- Convert One Recipe at a Time: Test each before moving to next
- Preserve Intent: Focus on what the code does, not how
- Use Native Modules: Prefer Ansible modules over shell commands
- Validate Continuously: Check syntax and semantics after each conversion
- Test Idempotency: Run playbooks multiple times
- Document Changes: Note any manual modifications
- Organise into Roles: Structure for reusability
- Version Control: Commit after each successful conversion
Don'ts [NO]#
- Don't Skip Testing: Always test converted code
- Don't Ignore Warnings: Validation warnings indicate issues
- Don't Convert Blindly: Understand the Chef code first
- Don't Forget Handlers: Notifications must be preserved
- Don't Mix Concerns: Keep tasks focused and organised
- Don't Hardcode Values: Use variables for flexibility
- Don't Skip Documentation: Document complex conversions
- Don't Rush: Thorough conversion prevents rework
Troubleshooting#
Issue: Resource Not Converting Properly#
Problem: Chef resource doesn't have direct Ansible equivalent
Solution:
1. Check Ansible Galaxy for community collections
2. Use ansible.builtin.command as fallback
3. Create custom module for complex logic
4. Document the conversion approach
Issue: Guard Logic Too Complex#
Problem: Complex Ruby guards don't convert cleanly
Solution:
# Break into separate fact-gathering and conditional tasks
- name: Gather deployment facts
ansible.builtin.set_fact:
can_deploy: "{{ (app_archive.stat.exists and
not deploy_marker.stat.exists and
not maintenance_mode) | bool }}"
- name: Deploy if conditions met
ansible.builtin.command:
cmd: /opt/deploy.sh
when: can_deploy
Issue: Notification Timing#
Problem: Chef immediate vs delayed notifications
Solution:
# Use meta: flush_handlers for immediate effect
- name: Critical config change
ansible.builtin.template:
src: critical.j2
dest: /etc/app/critical.conf
notify: Restart app immediately
- name: Force handler execution
meta: flush_handlers
Next Steps#
After completing conversions:
- Review Deployment Strategies - Plan production deployment
- Validation Testing - Comprehensive testing
- Examples - Real-world conversion patterns
Or explore related topics:
- MCP Tools Reference - Conversion tool details
- CLI Usage - Command-line conversion
- Assessment Guide - Pre-conversion assessment
Ready to Convert?
Use SousChef's conversion tools to transform Chef cookbooks systematically. Start simple, validate continuously, and build confidence with each successful conversion.