Introduction
Courtyard Sessions has a working website on two backends. The new program is ready, but updating both nodes together could leave visitors with no available site. Use Ansible to inspect the running state and release the new package one backend at a time.
You need basic terminal navigation and prior practice with a small Ansible inventory, variables and file/copy tasks. The gateway, SSH access and packages are prepared. This lab explains execution location, delegation, batches, release links and complete HTTP content checks before using them. The duration is an author estimate including reading and ordinary corrections.
See Where Each Task Runs
In this step, you will build a read-only inspection playbook that shows the running release on each backend and the actual active routing pool.
Courtyard Sessions already has a working visitor website. Open the Website Tab. Read Good things, around the corner., then click The program. The page scrolls to two sessions, beginning with Coffee in the courtyard. Click Download the program (CSV) and open the downloaded file: its header is time,session,place, followed by the two displayed sessions. A CSV is a plain-text table with comma-separated fields. This real visitor download must remain usable during your update.
The gateway distributes short HTTP requests across two backends. Removing a backend from its active pool stops new gateway requests being sent there; adding it returns that backend to rotation. This small prepared gateway does not drain long-lived connections or model an entire cloud release platform. Both backends are SSH-managed containers sharing this training VM's kernel. The controller is the machine running Ansible and the gateway.
Switch to the Controller Tab. It starts in /home/labex/project. pwd prints your current directory; cat prints a file:
pwd
cat inventory.yml
cat group_vars/all.yml
The inventory is YAML-compatible structured data. Its website group contains web1 and web2, each with its actual ansible_host address and prepared SSH connection details. The control group contains controller with a local connection. Use inventory.yml, which this project's ansible.cfg selects; the image's initial inventory.ini is not the course inventory.
The group variables supply backend_port (8090), the controller-only pool_api, the allowed release_root, the local package_root and release identifiers. baseline_release is courtyard-preview; approved_release is courtyard-weekend. These are folder names, not Ansible or software versions. An HTTP port identifies a listening service. /identity returns JSON, structured text identifying a backend's actual running release. /pool returns the controller's current list of active nodes.
If a command says “No such file or directory”, check its spelling with ls, then return with cd /home/labex/project if needed. You can repeat a read command safely. Keep all supplied package files, connection data, original release files and private organizer notes unchanged.
By default, Ansible executes a task over SSH on each selected backend. On a backend, 127.0.0.1 means that backend itself. delegate_to: controller instead executes that one task on the controller, whose own 127.0.0.1 hosts the pool API. Delegation does not change inventory_hostname: it still names the backend currently being managed. That is how one controller request can refer to the right backend.
Create inspect.yml. cat > filename writes a file, replacing an existing file of that name. <<'YAML' starts a here-document: paste through the closing YAML on its own line. The quoted delimiter keeps the pasted text literal; Ansible processes the expressions when it runs the playbook. Indentation uses spaces. {{ variable }} inserts an Ansible variable. gather_facts: false skips collecting unrelated machine facts.
The uri module sends a real HTTP request; its default method is GET, which reads a resource. register saves its actual result under a name. For JSON responses, .json holds parsed fields. debug prints those saved observations; it does not create the release or pool state.
cat > inspect.yml <<'YAML'
- name: Inspect the running website without changing it
hosts: website
gather_facts: false
tasks:
- name: Read this backend on its own loopback address
ansible.builtin.uri:
url: "http://127.0.0.1:{{ backend_port }}/identity"
register: backend
- name: Read the pool on the controller
ansible.builtin.uri:
url: "{{ pool_api }}/pool"
delegate_to: controller
register: pool
- name: Show the actual release and active pool
ansible.builtin.debug:
msg:
node: "{{ inventory_hostname }}"
release: "{{ backend.json.release }}"
active: "{{ pool.json.active }}"
YAML
Run the playbook. ansible-playbook reads tasks and applies them to the inventory:
ansible-playbook inspect.yml
Each backend should show release: courtyard-preview, and active should contain both web1 and web2. Output formatting and addresses can vary. Notice that the first uri runs on the backend, while the pool read displays a delegated execution such as web1 -> controller. Both tasks are read-only.
If YAML indentation is wrong, correct the file by copying the complete block again, then retry. This playbook should never copy files or change the pool. You now have a useful observation tool to run again after the release.
Release One Backend at a Time
In this step, you will deliver the approved website package over SSH, validate its actual visitor content and return each backend to service before starting the next.
Stay in the Controller Tab at /home/labex/project. List the packages, then read the approved program. ls lists directory entries:
ls packages
cat packages/courtyard-weekend/schedule.csv
The approved program contains three sessions: Bread and stories, Acoustic afternoon and Print a postcard. A homepage alone is insufficient: visitors need the exact accompanying download. The courtyard-incomplete package is reserved for the next lab; select courtyard-weekend here, as the existing release_id variable already does.
Your playbook will process a batch of one host using serial: 1. Within that batch it removes the current node from the pool, copies the package, switches the active release link, checks both business resources and rejoins the node. Only then does the next batch begin. order: sorted makes the demonstration start with web1. any_errors_fatal: true explicitly propagates an unhandled failure to stop the play. With the one-host batches used here, that host failing already fails the batch. The setting does not undo completed tasks. The next lab adds recovery.
The pool API accepts an HTTP POST to /pool with a JSON body: node names the managed node and enabled is the boolean false to remove it or true to add it. body_format: json serializes this body correctly. These pool tasks run on the controller. File tasks run on the selected SSH backend.
file with state: directory creates a directory; mode '0755' allows its owner to write and everyone to read/traverse it. copy sends the selected controller package to that backend. The trailing / on src copies its contents, with mode '0644' for readable files. file with state: link creates or updates a symbolic link: current points to one release directory. The prepared HTTP server serves real files through that link on each request. Switching the link changes the active site without restarting the server, while the original release directory remains intact.
For HTTP probes executed on the controller, hostvars[inventory_hostname].ansible_host explicitly obtains the managed backend's address. Under delegation, an unqualified connection variable may refer to the delegated host instead. return_content: true saves the actual response body as .content.
assert requires its that expression to be true. lookup('file', ...) reads the expected package file on the controller; ~ joins path strings. rstrip=False preserves final newlines, so the comparison checks the complete body rather than a trimmed fragment. Both the full candidate homepage and the full approved CSV must match before rejoining. A 404 means the server answered but that resource is missing; a connection error means the service could not be reached. Neither is a healthy release.
Write the full rollout. The here-document uses the same file-writing method as the inspection tool:
cat > rollout.yml <<'YAML'
- name: Release one website backend at a time
hosts: website
order: sorted
gather_facts: false
serial: 1
any_errors_fatal: true
tasks:
- name: Drain this backend from the pool
ansible.builtin.uri:
url: "{{ pool_api }}/pool"
method: POST
body_format: json
body:
node: "{{ inventory_hostname }}"
enabled: false
delegate_to: controller
- name: Create this release directory
ansible.builtin.file:
path: "{{ release_root }}/releases/{{ release_id }}"
state: directory
mode: '0755'
- name: Copy the selected package over SSH
ansible.builtin.copy:
src: "{{ package_root }}/{{ release_id }}/"
dest: "{{ release_root }}/releases/{{ release_id }}/"
mode: '0644'
- name: Activate the selected release
ansible.builtin.file:
src: "{{ release_root }}/releases/{{ release_id }}"
dest: "{{ release_root }}/current"
state: link
- name: Read the real candidate homepage
ansible.builtin.uri:
url: "http://{{ hostvars[inventory_hostname].ansible_host }}:{{ backend_port }}/"
return_content: true
delegate_to: controller
register: homepage
- name: Require the complete candidate homepage
ansible.builtin.assert:
that: homepage.content == lookup('file', package_root ~ '/' ~ release_id ~ '/index.html', rstrip=False)
- name: Read the real candidate download
ansible.builtin.uri:
url: "http://{{ hostvars[inventory_hostname].ansible_host }}:{{ backend_port }}/schedule.csv"
return_content: true
delegate_to: controller
register: download
- name: Require the complete approved program
ansible.builtin.assert:
that: download.content == lookup('file', package_root ~ '/' ~ approved_release ~ '/schedule.csv', rstrip=False)
- name: Rejoin only after both business checks
ansible.builtin.uri:
url: "{{ pool_api }}/pool"
method: POST
body_format: json
body:
node: "{{ inventory_hostname }}"
enabled: true
delegate_to: controller
YAML
First check the YAML and task structure without performing the release. --syntax-check validates syntax; it cannot prove the business outcome:
ansible-playbook rollout.yml --syntax-check
If the check reports a spelling or indentation error, copy the complete file again and retry. Then run the actual release:
ansible-playbook rollout.yml
The tasks for web1 should finish before the tasks for web2. Both hosts should end with failed=0 and unreachable=0. Operational drain/rejoin requests may report ok; their HTTP success and real effect matter more than the recap's changed count. If an ordinary edit error stops the run, correct the complete file and rerun it with the same approved package. Repeating this workflow still performs drain/check/rejoin operations; it is not a no-operation deployment.
Inspect the running state with your original tool:
ansible-playbook inspect.yml
Both nodes should now report courtyard-weekend, with both active in the pool. Switch to the Website Tab and reload the page. The heading should be A little room for a good afternoon. Click The program, check all three sessions, download the CSV again and open the new download to confirm all three rows. A browser may add a number to the filename when an earlier download exists. Plan a visit should still lead to the garden-gate accessibility information.
The updated program contains all three approved sessions; its real CSV download is beside the list.

This proves a working two-node update in the prepared short-request environment. It does not establish a production zero-downtime guarantee for arbitrary traffic or applications.
Summary
You built a read-only inspection tool and used a sequential Ansible rollout to deliver a real website and its complete program download. Delegated pool changes and real content checks kept release decisions tied to each managed backend.



