Initial commit
This commit is contained in:
343
skills/ansible-best-practices/patterns/playbook-role-patterns.md
Normal file
343
skills/ansible-best-practices/patterns/playbook-role-patterns.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# Playbook and Role Design Patterns
|
||||
|
||||
Best practices for structuring playbooks and roles based on production patterns from community roles like
|
||||
`geerlingguy.docker` and this repository.
|
||||
|
||||
## Pattern 1: State-Based Playbooks (Not Separate Create/Delete)
|
||||
|
||||
### Anti-Pattern: Separate playbooks for each operation
|
||||
|
||||
```text
|
||||
❌ BAD:
|
||||
playbooks/
|
||||
├── create-user.yml
|
||||
└── delete-user.yml
|
||||
```
|
||||
|
||||
### Best Practice: Single playbook with state variable
|
||||
|
||||
```text
|
||||
✅ GOOD:
|
||||
playbooks/
|
||||
└── manage-user.yml # Handles both create and delete via state variable
|
||||
```
|
||||
|
||||
### Why This Pattern?
|
||||
|
||||
Following community role patterns (like `geerlingguy.docker`, `geerlingguy.postgresql`):
|
||||
|
||||
- **Single source of truth**: One playbook to maintain
|
||||
- **Consistent interface**: Same variables, just change `state`
|
||||
- **Less duplication**: Validation and logic shared
|
||||
- **Familiar pattern**: Matches how Ansible modules work
|
||||
|
||||
### Implementation Example
|
||||
|
||||
**Role with state support** (`roles/system_user/tasks/main.yml`):
|
||||
|
||||
```yaml
|
||||
---
|
||||
- name: Create/update system users
|
||||
ansible.builtin.include_tasks: create_users.yml
|
||||
loop: "{{ system_users }}"
|
||||
when:
|
||||
- user_item.state | default('present') == 'present'
|
||||
|
||||
- name: Remove system users
|
||||
ansible.builtin.include_tasks: remove_users.yml
|
||||
loop: "{{ system_users }}"
|
||||
when:
|
||||
- user_item.state | default('present') == 'absent'
|
||||
```
|
||||
|
||||
**Playbook using the role** (`playbooks/manage-admin-user.yml`):
|
||||
|
||||
```yaml
|
||||
---
|
||||
# Playbook: Manage Administrative User
|
||||
# Usage:
|
||||
# # Create:
|
||||
# uv run ansible-playbook playbooks/manage-admin-user.yml \
|
||||
# -e "admin_name=myuser" -e "admin_ssh_key='ssh-ed25519 ...'"
|
||||
#
|
||||
# # Remove:
|
||||
# uv run ansible-playbook playbooks/manage-admin-user.yml \
|
||||
# -e "admin_name=myuser" -e "admin_state=absent"
|
||||
|
||||
- name: Manage Administrative User
|
||||
hosts: "{{ target_cluster | default('all') }}"
|
||||
become: true
|
||||
|
||||
pre_tasks:
|
||||
- name: Set default state
|
||||
ansible.builtin.set_fact:
|
||||
admin_state_value: "{{ admin_state | default('present') }}"
|
||||
|
||||
- name: Validate variables
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- admin_name is defined
|
||||
- (admin_state_value == 'absent') or (admin_ssh_key is defined)
|
||||
fail_msg: "admin_name required. admin_ssh_key required when state=present"
|
||||
|
||||
roles:
|
||||
- role: system_user
|
||||
vars:
|
||||
system_users:
|
||||
- name: "{{ admin_name }}"
|
||||
state: "{{ admin_state_value }}"
|
||||
# Only include creation params when state=present
|
||||
ssh_keys: "{{ [] if admin_state_value == 'absent' else [admin_ssh_key] }}"
|
||||
sudo_nopasswd: "{{ false if admin_state_value == 'absent' else true }}"
|
||||
```
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
1. **Default to `present`**: Makes common case (creation) easiest
|
||||
|
||||
```yaml
|
||||
admin_state_value: "{{ admin_state | default('present') }}"
|
||||
```
|
||||
|
||||
2. **Conditional validation**: SSH key only required when creating
|
||||
|
||||
```yaml
|
||||
- (admin_state_value == 'absent') or (admin_ssh_key is defined)
|
||||
```
|
||||
|
||||
3. **Conditional parameters**: Skip unnecessary vars when removing
|
||||
|
||||
```yaml
|
||||
ssh_keys: "{{ [] if admin_state_value == 'absent' else [admin_ssh_key] }}"
|
||||
```
|
||||
|
||||
4. **State-specific messages**: Different post_tasks based on state
|
||||
|
||||
```yaml
|
||||
- name: Display success (created)
|
||||
when: admin_state_value == 'present'
|
||||
|
||||
- name: Display success (removed)
|
||||
when: admin_state_value == 'absent'
|
||||
```
|
||||
|
||||
## Pattern 2: Public API Variables (No Role Prefix)
|
||||
|
||||
**Role defaults** should use clean variable names (not prefixed):
|
||||
|
||||
```yaml
|
||||
# roles/system_user/defaults/main.yml
|
||||
---
|
||||
# noqa: var-naming[no-role-prefix] - This is the role's public API
|
||||
system_users: []
|
||||
```
|
||||
|
||||
**Why?**
|
||||
|
||||
- Clean interface for users of the role
|
||||
- Follows community role patterns (`docker_users`, not `geerlingguy_docker_users`)
|
||||
- Internal variables should be prefixed (e.g., `system_user_create_result`)
|
||||
|
||||
## Pattern 3: Smart Variable Defaults in Playbooks
|
||||
|
||||
Use `set_fact` to handle defaults gracefully:
|
||||
|
||||
```yaml
|
||||
pre_tasks:
|
||||
- name: Set default values for optional variables
|
||||
ansible.builtin.set_fact:
|
||||
admin_shell_value: "{{ admin_shell | default('/bin/bash') }}"
|
||||
admin_comment_value: "{{ admin_comment | default('System Administrator') }}"
|
||||
when: admin_state_value == 'present'
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Defaults set once, used everywhere
|
||||
- Clear separation of user input vs computed values
|
||||
- Conditional defaults (only when needed)
|
||||
|
||||
## Pattern 4: Comprehensive Pre-flight Validation
|
||||
|
||||
Validate early, fail fast:
|
||||
|
||||
```yaml
|
||||
pre_tasks:
|
||||
- name: Validate required variables
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- admin_name is defined
|
||||
- admin_name | length > 0
|
||||
# Conditional validation
|
||||
- (admin_state_value == 'absent') or (admin_ssh_key is defined)
|
||||
fail_msg: "Clear error message about what's missing"
|
||||
success_msg: "All required variables present"
|
||||
```
|
||||
|
||||
**Why validate in playbook, not role?**
|
||||
|
||||
- Playbooks know the specific use case
|
||||
- Roles should be flexible
|
||||
- Better error messages with context
|
||||
|
||||
## Pattern 5: Documentation in Playbook Headers
|
||||
|
||||
Self-documenting playbooks with usage examples:
|
||||
|
||||
```yaml
|
||||
---
|
||||
# Playbook: Manage Administrative User
|
||||
# Purpose: Create or remove admin users with SSH and sudo
|
||||
# Role: ansible/roles/system_user
|
||||
#
|
||||
# Usage:
|
||||
# # Create user:
|
||||
# uv run ansible-playbook playbooks/manage-admin-user.yml \
|
||||
# -e "admin_name=alice" \
|
||||
# -e "admin_ssh_key='ssh-ed25519 ...'"
|
||||
#
|
||||
# # Remove user:
|
||||
# uv run ansible-playbook playbooks/manage-admin-user.yml \
|
||||
# -e "admin_name=alice" \
|
||||
# -e "admin_state=absent"
|
||||
#
|
||||
# Variables:
|
||||
# admin_name (required): Username
|
||||
# admin_ssh_key (required for create): SSH public key
|
||||
# admin_state (optional): present or absent (default: present)
|
||||
# admin_shell (optional): User shell (default: /bin/bash)
|
||||
```
|
||||
|
||||
## Pattern 6: Informative Output Messages
|
||||
|
||||
Context-aware success messages:
|
||||
|
||||
```yaml
|
||||
post_tasks:
|
||||
- name: Display success message (user created)
|
||||
ansible.builtin.debug:
|
||||
msg: |
|
||||
========================================
|
||||
User Creation Complete
|
||||
========================================
|
||||
User '{{ admin_name }}' configured on {{ inventory_hostname }}
|
||||
|
||||
Test SSH: ssh {{ admin_name }}@{{ inventory_hostname }}
|
||||
Test sudo: ssh {{ admin_name }}@{{ inventory_hostname }} sudo id
|
||||
when: admin_state_value == 'present'
|
||||
|
||||
- name: Display success message (user removed)
|
||||
ansible.builtin.debug:
|
||||
msg: |
|
||||
========================================
|
||||
User Removal Complete
|
||||
========================================
|
||||
User '{{ admin_name }}' removed from {{ inventory_hostname }}
|
||||
|
||||
Verify: ssh root@{{ inventory_hostname }} "id {{ admin_name }}"
|
||||
when: admin_state_value == 'absent'
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Users know what to do next
|
||||
- Copy-paste ready commands
|
||||
- Different messages per operation
|
||||
|
||||
## Testing the Pattern
|
||||
|
||||
### Idempotency Test
|
||||
|
||||
Both operations should be idempotent:
|
||||
|
||||
```bash
|
||||
# Create - first run should change, second should not
|
||||
uv run ansible-playbook playbooks/manage-user.yml -e "admin_name=test" -e "admin_ssh_key='...'"
|
||||
# Result: changed=5
|
||||
|
||||
uv run ansible-playbook playbooks/manage-user.yml -e "admin_name=test" -e "admin_ssh_key='...'"
|
||||
# Result: changed=0 ✅
|
||||
|
||||
# Remove - first run should change, second should not
|
||||
uv run ansible-playbook playbooks/manage-user.yml -e "admin_name=test" -e "admin_state=absent"
|
||||
# Result: changed=2
|
||||
|
||||
uv run ansible-playbook playbooks/manage-user.yml -e "admin_name=test" -e "admin_state=absent"
|
||||
# Result: changed=0 ✅
|
||||
```
|
||||
|
||||
## Real-World Example
|
||||
|
||||
From this repository: `ansible/playbooks/create-admin-user.yml` + `ansible/roles/system_user/`
|
||||
|
||||
**Features:**
|
||||
|
||||
- ✅ Single playbook for create and remove
|
||||
- ✅ State defaults to `present`
|
||||
- ✅ Conditional validation (SSH key only when creating)
|
||||
- ✅ Conditional role variables
|
||||
- ✅ State-specific output messages
|
||||
- ✅ Fully idempotent (tested on production infrastructure)
|
||||
|
||||
**Usage:**
|
||||
|
||||
```bash
|
||||
# Create admin user with full sudo
|
||||
cd ansible
|
||||
uv run ansible-playbook -i inventory/proxmox.yml \
|
||||
playbooks/create-admin-user.yml \
|
||||
-e "admin_name=alice" \
|
||||
-e "admin_ssh_key='ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...'"
|
||||
|
||||
# Remove the user
|
||||
uv run ansible-playbook -i inventory/proxmox.yml \
|
||||
playbooks/create-admin-user.yml \
|
||||
-e "admin_name=alice" \
|
||||
-e "admin_state=absent"
|
||||
```
|
||||
|
||||
## Comparison: Before and After
|
||||
|
||||
### Before (Anti-pattern)
|
||||
|
||||
```text
|
||||
playbooks/
|
||||
├── create-admin-user.yml # 70 lines
|
||||
└── delete-admin-user.yml # 45 lines
|
||||
# = 115 lines total
|
||||
# = 2 files to maintain
|
||||
# = Different interfaces
|
||||
```
|
||||
|
||||
### After (Best practice)
|
||||
|
||||
```text
|
||||
playbooks/
|
||||
└── create-admin-user.yml # 95 lines
|
||||
# = 1 file to maintain
|
||||
# = Consistent interface
|
||||
# = Follows community patterns
|
||||
```
|
||||
|
||||
## Related Patterns
|
||||
|
||||
- **Variable precedence**: See [reference/variable-precedence.md](../reference/variable-precedence.md)
|
||||
- **Role structure**: See [reference/roles-vs-playbooks.md](../reference/roles-vs-playbooks.md)
|
||||
- **Idempotency**: See [reference/idempotency-patterns.md](../reference/idempotency-patterns.md)
|
||||
|
||||
## Summary
|
||||
|
||||
✅ **Do:**
|
||||
|
||||
- Single playbook with `state` variable
|
||||
- Default `state: present` for common case
|
||||
- Conditional validation and parameters
|
||||
- Public API variables without role prefix
|
||||
- Comprehensive documentation in headers
|
||||
|
||||
❌ **Don't:**
|
||||
|
||||
- Create separate create/delete playbooks
|
||||
- Require parameters for both create and delete
|
||||
- Use role prefixes on public API variables
|
||||
- Omit usage examples from playbooks
|
||||
Reference in New Issue
Block a user