02

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.

System map · Day 02

Whole-system design

5 stable layers. Today's work is expanded and linked; the rest stays in context.

Intent and source

Covered — Playbook control

Role source

Source-backed today

Defines reusable defaults, desired-state tasks, templates, and a named change-triggered handler.

Selection and control

Covered — Inventory selection

Variable resolution

Source-backed today

Overrides the role contract with port 9090 and staging while leaving reusable role source unchanged.

Compute and execution

Ahead — Managed-node runtime · Nginx runtime

Ansible control node

Source-backed today

Resolves variables, renders templates, records changes, and flushes handlers before verification.

Storage and state

Covered — Generated local configurationAhead — Nginx site files

Change-triggered marker

Source-backed today

Records a simulated restart only after a template reports a real configuration change.

Evidence and feedback

Ahead — HTTP response proof

Assertions and recap

Source-backed today

Rejects an invalid port, proves recovery, and requires changed=0 and failed=0 on the second run.

Traversed today

injects values · Variable resolutionRole sourcesupplies role · Role sourceAnsible control nodenotifies change · Ansible control nodeChange-triggered markerverifies state · Generated local configurationAssertions and recap

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.