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
Bash Script to Ansible Conversion#
This section covers the enterprise scenario where provisioning Bash scripts — including scripts that break out of Salt, Puppet, or Chef — need to be converted to Ansible playbooks or roles for AAP.
When Bash Script Migration Applies#
- A CM tool (Salt, Puppet, or Chef) was replaced temporarily with a Bash script
- A team writes new provisioning logic directly in Bash and wants to land in Ansible/AAP
- An existing Bash script (
bootstrap.sh,provision.sh,deploy.sh) needs to become idempotent
Pattern Detection and Confidence Scores#
SousChef analyses 13 categories of provisioning operation and assigns a confidence score to each detected pattern:
| Confidence | Meaning | Ansible conversion |
|---|---|---|
| ≥ 80 % | High — clear module mapping | Structured Ansible module task |
| < 80 % | Low — ambiguous | ansible.builtin.shell fallback |
Bash Resource Mapping#
| Bash pattern | Ansible module | Notes |
|---|---|---|
apt-get install |
ansible.builtin.apt |
state: present added automatically |
yum install / dnf install |
ansible.builtin.yum / dnf |
|
pip install |
ansible.builtin.pip |
|
systemctl enable/start |
ansible.builtin.service |
state: started, enabled: true |
echo … > /file / heredoc |
ansible.builtin.copy |
content stub with TODO |
curl -o / wget |
ansible.builtin.get_url |
dest uses {{ download_dest_dir }} variable |
useradd / adduser |
ansible.builtin.user |
state: present |
groupadd |
ansible.builtin.group |
state: present |
chmod / chown |
ansible.builtin.file |
mode, owner, recurse supported |
git clone |
ansible.builtin.git |
repo, dest extracted |
tar -x / unzip |
ansible.builtin.unarchive |
remote_src: true |
sed -i |
ansible.builtin.shell |
Comment: prefer lineinfile/replace |
crontab |
ansible.builtin.shell |
Comment: prefer ansible.builtin.cron |
ufw allow |
community.general.ufw |
Requires community.general collection |
firewall-cmd |
ansible.posix.firewalld |
Requires ansible.posix collection |
iptables -A/-I |
ansible.builtin.iptables |
|
hostnamectl set-hostname |
ansible.builtin.hostname |
Converting a Bash Script#
Step 1 — Analyse
Review the output for: - Sensitive data warnings (act on these before committing) - CM escape calls that need manual review - Low-confidence shell fallbacks
Step 2 — Convert to playbook
souschef bash convert scripts/provision.sh --output playbook.yml
# Full JSON (quality score + AAP hints)
souschef bash convert scripts/provision.sh --format json > full_result.json
Step 3 — Generate role (recommended for production)
Idempotency Considerations#
Bash scripts are typically not idempotent. The conversion applies these safeguards:
- Package installs use
state: present(already idempotent in Ansible) - Service operations use
state: started/stopped(idempotent) - File copies produce
ansible.builtin.copy(idempotent by checksum) - Shell fallback tasks use
changed_when: "false"to avoid spurious change reports - Non-idempotent patterns (unconditional
echo > file, raw downloads) are flagged inidempotency_report
Handling CM Escape Calls#
When salt-call, puppet apply, or chef-client are detected inside a Bash script:
- The call is preserved as an
ansible.builtin.shellfallback with a warning comment - The quality score is penalised and an improvement note is generated
- The AAP hints note recommends reviewing the call before deploying
To resolve a CM escape call, identify what the original CM state does and rewrite it as native Ansible tasks in the appropriate task file.
Securing Secrets#
Bash scripts frequently contain hardcoded credentials. SousChef:
- Detects
password=,API_KEY=, and private key material - Redacts the value in all output (only the line number and type are shown)
- Generates a stubbed
defaults/main.ymlentry: - Reports the finding in
aap_hints.notes
After conversion, use ansible-vault create group_vars/all/vault.yml and move the secret there, then reference it as {{ vault_db_password }}.
AAP Job Template Configuration#
The aap_hints block in the conversion response provides everything needed to configure an AAP job template:
{
"suggested_ee": "registry.redhat.io/ansible-automation-platform/ee-supported-rhel8:latest",
"suggested_credentials": ["Machine"],
"become_enabled": true,
"timeout": 3600,
"survey_variables": [...],
"notes": [...]
}
Use the survey_variables list to create an AAP survey so operators can override defaults at launch time.
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.
PowerShell Script to Ansible Conversion#
For Windows PowerShell provisioning scripts, see the dedicated PowerShell Migration Guide, which covers:
- Parsing 28+ PowerShell provisioning patterns
- Mapping to
ansible.windows.*,community.windows.*, andchocolatey.chocolatey.*modules - Generating enterprise artefacts (WinRM inventory, group_vars, Ansible role, AWX job template)
- Fidelity scoring and actionable recommendations