Reuse Roles, Inject Variables, and Trigger Handlers
Turn the localhost control-loop receipt into a reusable contract with explicit inputs and change-driven side effects.
Whole-system design
5 stable layers. Today's work is expanded and linked; the rest stays in context.
Intent and source
Role source
Defines reusable defaults, desired-state tasks, templates, and a named change-triggered handler.
Selection and control
Variable resolution
Overrides the role contract with port 9090 and staging while leaving reusable role source unchanged.
Compute and execution
Ansible control node
Resolves variables, renders templates, records changes, and flushes handlers before verification.
Storage and state
Change-triggered marker
Records a simulated restart only after a template reports a real configuration change.
Evidence and feedback
Assertions and recap
Rejects an invalid port, proves recovery, and requires changed=0 and failed=0 on the second run.
Traversed today
Move from one successful run to a reusable role
Day 01 produced the localhost control-loop receipt: one bounded target, rendered configuration, an invalid-environment rejection, recovery, and explicit cleanup. One successful play is not yet reusable; today you will treat demo_app as a role contract, inject port 9090, observe a handler only after change, reject one invalid port, recover, and require a second-run recap of changed=0 and failed=0.
A role is reusable only when callers can understand its inputs without reading every task. The official roles guide describes the standard directory structure that lets Ansible load defaults, tasks, handlers, and templates by convention.
The POC declares its public inputs in roles/demo_app/defaults/main.yml:
---
# Public role inputs. Inventory or --extra-vars can inject different values.
demo_app_name: demo-app
demo_app_environment: local
demo_app_port: 8080
demo_app_root: /tmp/ansible-poc
demo_app_allowed_environments:
- local
- development
- staging
- production
Defaults are fallbacks, not immutable constants. The role owns how it validates and applies inputs; inventory or another caller owns environment-specific values. This separation is the useful beginning of the dependency injection analogy.
Inject port 9090 without editing role source
Hard-coding each environment inside a role produces copies that drift, so values must enter through a stable input seam. Dependency injection (DI) is an application-design technique where a component receives dependencies or configuration from a caller instead of constructing or hard-coding them internally.
Ansible is not literally a DI framework or DI container. The useful analogy is narrower: role defaults declare configurable inputs, then inventory, group_vars, host variables, Vault, role parameters, or --extra-vars provide concrete values. Ansible resolves those sources by defined variable precedence rules.
The POC README gives this exact override without changing the role:
ansible-playbook playbooks/local.yml \
--extra-vars 'demo_app_port=9090 demo_app_environment=staging'
The source template consumes the resolved values through stable names:
# Managed by Ansible. Manual edits will be replaced.
name={{ demo_app_name }}
environment={{ demo_app_environment }}
port={{ demo_app_port }}
managed_host={{ inventory_hostname }}
Preview the injected state without applying it yet. This keeps the first real 8080 to 9090 transition available for the handler experiment:
ansible-playbook playbooks/local.yml --check --diff \
--extra-vars 'demo_app_port=9090 demo_app_environment=staging'
Expected preview evidence is a diff containing environment=staging and port=9090, with no applied file mutation. Check mode predicts supported changes; the next section proves the actual transition and handler effect.
Let changed state schedule the side effect
Reloading a service after every run creates needless disruption, but forgetting a reload leaves a process on stale configuration. Inversion of Control (IoC) means framework control determines when application-supplied behavior runs; in this analogy, the role declares desired state and notification, while Ansible decides whether the task changed and when the notified handler runs.
Do not stretch the analogy. Ansible mixes declarative convergence with ordered orchestration: playbook task order is explicit, meta: flush_handlers is explicit, and shell commands can be imperative.
This exact task excerpt renders configuration, notifies by handler name, and flushes handlers before verification:
- name: Render application configuration
ansible.builtin.template:
src: app.conf.j2
dest: "{{ demo_app_root }}/config/app.conf"
mode: "0644"
notify: Record simulated application restart
tags: [config]
- name: Run handlers before verification
ansible.builtin.meta: flush_handlers
when: not ansible_check_mode
The template module compares desired content with the destination. A difference reports changed and queues the named handler; matching content reports no change and does not queue it. Handlers normally run after their play section, while flush_handlers runs queued handlers earlier; the latest handler documentation defines this notification behavior.
The localhost role uses a file as an observable stand-in for a service restart:
---
- name: Record simulated application restart
ansible.builtin.copy:
content: |
A configuration change notified this handler.
app={{ demo_app_name }}
environment={{ demo_app_environment }}
dest: "{{ demo_app_root }}/restart.marker"
mode: "0644"
First establish actual 8080/development state. Delete the marker created by that baseline before the first 9090/staging apply, prove it is absent, then inspect the marker produced by the transition:
ansible-playbook playbooks/local.yml \
--extra-vars 'demo_app_port=8080 demo_app_environment=development'
grep -F 'port=8080' /tmp/ansible-poc/config/app.conf
rm -f /tmp/ansible-poc/restart.marker
test ! -e /tmp/ansible-poc/restart.marker
ansible-playbook playbooks/local.yml \
--extra-vars 'demo_app_port=9090 demo_app_environment=staging'
test -f /tmp/ansible-poc/restart.marker
cat /tmp/ansible-poc/restart.marker
ansible-playbook playbooks/verify.yml \
--extra-vars 'demo_app_port=9090 demo_app_environment=staging'
Expected marker text names learning-ansible and staging; verification reports its success message and failed=0. This proves Ansible observed the actual 8080/development to 9090/staging template change and ran the simulated handler. It does not prove a real process restarted successfully; Day 03 replaces the marker with Nginx configuration validation and service evidence.
Reject an invalid port, then recover deliberately
Flexible inputs expand the failure surface because a caller can inject a value the role cannot safely use. Validation must fail before rendering state, then recovery must re-run the same contract with one valid correction.
The source task constrains the port and other public inputs:
- name: Validate role inputs
ansible.builtin.assert:
that:
- demo_app_name | length > 0
- demo_app_environment in demo_app_allowed_environments
- demo_app_port | int > 0
- demo_app_port | int < 65536
fail_msg: The injected application inputs are invalid.
tags: [always]
Use port 70000 as the single bounded failure:
ansible-playbook playbooks/local.yml \
--extra-vars 'demo_app_port=70000 demo_app_environment=staging'
Expected evidence is failure at Validate role inputs, the exact failure message, and a non-zero command exit. The existing app.conf is the unaffected positive control: its prior port=9090 line must remain unchanged because validation precedes rendering. Recover with the accepted port and carry the same values into verification:
ansible-playbook playbooks/local.yml \
--extra-vars 'demo_app_port=9090 demo_app_environment=staging'
ansible-playbook playbooks/verify.yml \
--extra-vars 'demo_app_port=9090 demo_app_environment=staging'
Expected recovery evidence is failed=0 plus the assertion success message. A failure followed by an unrelated successful command is not recovery; the corrected run must target the same host, role, and desired state.
Demand zero drift on the second run
A role that reports change forever cannot distinguish convergence from repeated mutation. Idempotency means applying the same declared state again leaves already-correct state unchanged.
The source Makefile encodes a deterministic recap check:
idempotency:
@output="$$(ansible-playbook playbooks/local.yml)"; \
printf '%s\n' "$$output"; \
printf '%s\n' "$$output" | grep -Eq 'changed=0.*failed=0' || \
(printf '%s\n' 'Idempotency check failed' >&2; exit 1)
For the injected state, run the same explicit command twice and read the second recap:
ansible-playbook playbooks/local.yml \
--extra-vars 'demo_app_port=9090 demo_app_environment=staging'
ansible-playbook playbooks/local.yml \
--extra-vars 'demo_app_port=9090 demo_app_environment=staging'
Expected second-run evidence is changed=0 and failed=0. If change remains non-zero, inspect which task changed; do not silence it with changed_when: false unless the task truly performs no mutation.
Decide whether the handler should run
Several changing values would make handler reasoning ambiguous, so hold target, name, environment, root, and template constant. Only one unknown remains: whether a second identical port=9090 run notifies Record simulated application restart.
Question: should the handler run on that second identical application?
Expected answer: no. Expected evidence is changed=0, failed=0, and no handler execution line in the second run. If you answer yes because the handler appears in source, you are confusing registration with notification: a handler runs only when a notifying task reports change. Replay two identical commands and compare the first and second recaps.
Clean role-owned state and carry the contract forward
Idempotency can be falsely inferred from leftovers, so finish by deleting only the role-owned local root. Verify the path before cleanup and do not widen it.
test -d /tmp/ansible-poc
make clean
test ! -e /tmp/ansible-poc
Record a reusable-role and idempotency receipt: localhost target; injected demo_app_port=9090; staging environment; rendered file evidence; handler ran after first change; port 70000 rejection; same-target recovery; second-run changed=0 and failed=0; timestamp; archive SHA-256. Day 03 consumes this receipt while changing only the transport, privilege boundary, package/service effects, and rollout evidence.
The reusable rules are concise:
- Put safe, overridable inputs in role defaults; put environment choices outside role source.
- Treat DI and IoC as useful analogies, not literal equivalence to an application container or framework.
- Validate before mutation, then prove recovery against the same target and intent.
- A handler is change-triggered control; idempotency is visible in the second recap.