01

Build an Agentless Desired-State Loop

Run a safe localhost system from declared intent to verified, disposable state.

System map · Day 01

Whole-system design

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

Intent and source

Ahead — Role source

Playbook control

Source-backed today

Binds the selected demo group to one reusable role through the exact source playbook.

Selection and control

Ahead — Variable resolution

Inventory selection

Source-backed today

Selects only localhost through the local connection and supplies the first application values.

Compute and execution

Ahead — Managed-node runtime · Nginx runtime

Ansible control node

Source-backed today

Interprets the local play and runs modules on the same machine without installing an Ansible agent.

Storage and state

Ahead — Change-triggered marker · Nginx site files

Generated local configuration

Source-backed today

Owns disposable rendered files under /tmp/ansible-poc and is deleted by the bounded cleanup target.

Evidence and feedback

Ahead — HTTP response proof

Assertions and recap

Source-backed today

Checks rendered values, rejects an invalid environment, and distinguishes expected evidence from availability proof.

Traversed today

selects hosts · Inventory selectionPlaybook controldrives execution · Playbook controlAnsible control noderenders state · Ansible control nodeGenerated local configurationverifies state · Generated local configurationAssertions and recap

Turn a shell command into controlled change

Ansible is useful when configuration must be reviewable and repeatable rather than remembered as a sequence of manual shell edits. Today you will run one complete loop on localhost: inspect syntax, predict change, apply it, verify the rendered state, force one bounded failure, recover, and remove only the lab output.

Ansible is agentless: the control node runs Ansible, while a managed node does not need a permanently installed Ansible service. Most remote Linux targets are reached through SSH; this first lab uses the local connection so control node and managed node are the same machine. The official installation guide defines those two roles and their runtime requirements.

Download the exact runnable POC archive, extract it, then enter its ansible-poc directory. The archive is source material for every excerpt in this course; commands below report expected evidence, not results observed on your machine.

The POC's Makefile gives each step one explicit operator action:

syntax:
	ansible-playbook playbooks/local.yml --syntax-check
	ansible-playbook playbooks/verify.yml --syntax-check

check:
	ansible-playbook playbooks/local.yml --check --diff

apply:
	ansible-playbook playbooks/local.yml --diff

verify:
	ansible-playbook playbooks/verify.yml

The archive pins ansible-core==2.19.2. Its control node supports Python 3.11 through 3.13 according to Ansible's release and maintenance matrix. Guard that prerequisite before creating an isolated environment:

python3 - <<'PY'
import sys

version = sys.version_info[:2]
if not (3, 11) <= version <= (3, 13):
    raise SystemExit(f"Python 3.11-3.13 required; found {version[0]}.{version[1]}")
print(f"Python {version[0]}.{version[1]} is supported")
PY
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
ansible --version

A role packages related defaults, tasks, templates, and handlers behind a reusable name. A task is one named operation in a play or role. A module is the Ansible-supplied code a task invokes to inspect or change one kind of state, such as ansible.builtin.file for a path. A Jinja template is plain text containing {{ variable }} placeholders that Ansible's template module renders with resolved values; Jinja is the template language, not another managed service.

The smallest model is intent -> selection -> execution -> state -> evidence. Infrastructure as Code (IaC) means storing infrastructure or configuration intent as versioned files that tools can interpret repeatedly. Here YAML and Jinja templates are authoritative intent; files under /tmp/ansible-poc are resulting machine state. Ansible is strongest at configuration and orchestration, not at proving every property of a production service.

Select exactly one disposable target

A command without a bounded target can change the wrong machine, so inventory selection is a safety boundary. An inventory names managed hosts, groups them, and supplies connection or environment data; the inventory guide explains how Ansible flattens group and host variables before a play runs.

This source inventory contains one host and explicitly chooses the local connection:

---
all:
  children:
    demo:
      hosts:
        localhost:
          ansible_connection: local
      vars:
        demo_app_name: learning-ansible
        demo_app_environment: development
        demo_app_port: 8080

Inspect selection before changing state:

ansible-inventory --graph
ansible-inventory --host localhost

Expected evidence is one demo group containing localhost, plus the three application values above. Abort if another host appears or ansible_connection is not local; the lab's cleanup contract applies only to /tmp/ansible-poc on your current machine.

Let the control node reconcile declared state

Knowing the target does not say what should happen there; without a playbook, there is no ordered link from selected host to reusable work. A playbook is YAML that selects hosts and composes tasks or roles into an ordered run.

The complete local playbook delegates its work to the demo_app role:

---
- name: Converge the safe local Ansible POC
  hosts: demo
  gather_facts: true

  roles:
    - role: demo_app

Run the stages in order:

make syntax
make check
make apply
make verify

--syntax-check parses without applying. --check asks supporting modules to predict changes without making them, and --diff shows supported before/after detail. Check mode is a simulation, not a proof: unsupported tasks may do nothing or report no prediction, as the check and diff mode documentation warns.

During apply, ansible-playbook interprets the play on your CPU and memory. Because connection is local, modules use the same machine's process, disk, and filesystem namespace. That preserves the control relationship but collapses control node, managed node, disk, network, power, and operator session into one failure domain. This run cannot prove remote connectivity, host isolation, high availability, or production readiness.

Inspect state instead of trusting exit zero

A successful command can still leave incorrect files, so runtime state must be inspected directly. A template is source text with variable placeholders; Ansible's template module renders it to a concrete file and compares the destination before deciding whether change is needed.

This exact template is the desired shape of config/app.conf:

# Managed by Ansible. Manual edits will be replaced.
name={{ demo_app_name }}
environment={{ demo_app_environment }}
port={{ demo_app_port }}
managed_host={{ inventory_hostname }}

Inspect only the role-owned output:

find /tmp/ansible-poc -maxdepth 2 -type f -print
cat /tmp/ansible-poc/config/app.conf

Expected configuration contains name=learning-ansible, environment=development, port=8080, and managed_host=localhost. Those lines prove that selected values reached one rendered file. They do not prove a network service consumed it.

Make incorrect input fail closed

Rendered text alone can look plausible after a partial or stale run, so a separate postcondition must read current state and reject mismatch. An assertion is an executable condition that stops the play when its truth test fails.

The source verification play reads the file without marking state changed, decodes it, then checks all three injected values:

---
- name: Verify the safe local Ansible POC
  hosts: demo
  gather_facts: false

  tasks:
    - name: Read rendered application configuration
      ansible.builtin.slurp:
        src: "{{ demo_app_root | default('/tmp/ansible-poc') }}/config/app.conf"
      register: rendered_config
      changed_when: false

    - name: Assert the injected values reached the rendered configuration
      ansible.builtin.assert:
        that:
          - "'name=' + demo_app_name in (rendered_config.content | b64decode)"
          - "'environment=' + demo_app_environment in (rendered_config.content | b64decode)"
          - "'port=' + (demo_app_port | string) in (rendered_config.content | b64decode)"
        success_msg: The role converged to the expected state.

Now force one safe rejection. qa is outside the role's allowed environment list:

ansible-playbook playbooks/local.yml --extra-vars 'demo_app_environment=qa'

Expected evidence is a failed Validate role inputs task with The injected application inputs are invalid. The precondition is still one local host; the permitted blast radius is no new lab state. Confirm the prior positive control remains readable with cat /tmp/ansible-poc/config/app.conf, then recover:

ansible-playbook playbooks/local.yml --extra-vars 'demo_app_environment=development'
ansible-playbook playbooks/verify.yml

Expected recovery evidence is the assertion success message and failed=0 in recap. Do not claim more than those bounded observations.

Predict the one value that changes

Without a narrow check, a learner can guess from several moving parts and a wrong answer reveals little. Hold host, role, name, environment, root, and template constant; only demo_app_port is unknown.

Run this after the clean default apply:

ansible-playbook playbooks/local.yml --extra-vars 'demo_app_port=9090'
cat /tmp/ansible-poc/config/app.conf

Question: which exact port line must the file contain?

Expected answer and evidence: port=9090. If you answer port=8080, you are treating inventory as stronger than an explicit extra variable. Extra variables override role defaults and inventory values for that run; the variable precedence documentation places them last in the precedence list. Replay only the two commands above, then compare the command input with the rendered line.

Remove the lab and preserve its receipt

Leaving disposable state behind can make a later run look successful because stale files already exist. Cleanup therefore targets only the directory owned by this POC, after its exact path has been inspected.

test -d /tmp/ansible-poc
make clean
test ! -e /tmp/ansible-poc

Record a localhost control-loop receipt with environment local, target localhost, expected path /tmp/ansible-poc, syntax/check/apply/verify outcomes, invalid-environment rejection, recovery outcome, timestamp, and archive SHA-256. This receipt is bounded evidence of generated configuration plus assertions on one machine. Day 02 consumes it to separate reusable role inputs from change-triggered side effects.

Keep these decision rules:

  • Inventory bounds target identity; never infer it from a playbook name.
  • Check mode predicts supported changes; verification inspects actual postconditions.
  • A recap with failed=0 is necessary but not sufficient for user-visible service health.
  • Local convergence proves one control loop while collapsing production failure domains.