Introduction
Riverlight Weekend's homepage is ready, but visitors cannot reach the program or venue pages. Both source services already work. Use Ansible to connect the entry to the selected inventory members, reload only when configuration changes and protect working routes from an inappropriate replacement.
You need basic terminal navigation and prior practice reading a small Ansible inventory, variables and playbook. This lab explains HTTP service roles, group membership, cross-host variables, JSON, read-only probes and reload handlers before using them. All software and event services are prepared. The time is an author estimate, including reading and ordinary corrections.
Connect the Program and Visit Pages
In this step, you will connect an event website to its prepared program and venue sources, using inventory membership instead of fixed addresses in a template.
Riverlight Weekend has a working homepage and visitor guide. Its program and visit links currently lead to an unavailable-page message. The entry is a small, prepared HTTP reverse proxy: it forwards a request to another service and returns that service's actual response. It does not copy program data into the homepage. The entry runs on the controller; the two source nodes are containers sharing this training VM's kernel. They are not three separate VMs. Software and services are already prepared; you will manage only the entry's connections.
Open the Event Site Tab. The homepage should say A little closer to the river. Click Explore the program: This page is taking a break. is an expected HTTP 502 response because that route is not connected. Use the page's Back to the weekend link, then Visitor guide in the navigation. That page already works and must keep working.
Switch to the Controller Tab. It starts in /home/labex/project. pwd prints the current directory:
pwd
If you ever lose the working directory, return with cd /home/labex/project. A misspelled filename causes “No such file or directory”; read the spelling, use ls templates to list that directory if needed, and retry. There is no need to reset the VM.
cat prints a file. Read the approved service selection, the inventory and the per-host data:
cat approved-services.txt
cat inventory.ini
cat host_vars/web1.yml
cat host_vars/web2.yml
The inventory's [program] and [venue] groups each select one source. web1 provides the current program; web2 provides the venue. Host files supply their actual service_address and service ports. Addresses may differ between VMs. Some nodes also have an archive service; being reachable does not make it the approved current program.
The [gateway] group contains entry. ansible_connection=local means Ansible manages this entry on the controller, without an SSH hop. /usr/bin/python3 is the prepared local Python interpreter. An HTTP port identifies a listening service at an address; one node can run several services on different ports.
Read each selected source directly before changing the entry. The prepared curl configuration files hold the actual URLs. --config reads curl options from a file; fail makes HTTP errors fail, silent hides its progress meter, and show-error retains useful errors. These files contain no password or token.
cat requests/web1.conf
curl --config requests/web1.conf
cat requests/web2.conf
curl --config requests/web2.conf
The JSON responses identify program and venue, the site riverlight, and their editions. JSON is structured text with named fields. A successful source request proves that source works directly; it does not yet prove the website routes work.
Create the route template next. Ansible's groups maps group names to their members; [0] selects the first member from the one-member group. hostvars retrieves that member's variables. The playbook below names those members program_host and venue_host. In Jinja expressions, ~ joins values as text, including numeric ports. | to_json produces a correctly quoted JSON string rather than unsafe hand-built quotes.
The shell command below uses > to replace this one file. <<'EOF' feeds the following lines literally into cat; the single quotes protect Jinja braces from shell interpretation. Paste through the closing EOF on its own line. If the shell waits at a continuation prompt because you missed that delimiter, press Ctrl+C and paste the complete command again.
cat > templates/routes.json.j2 <<'EOF'
{
"program": {{ ('http://' ~ hostvars[program_host].service_address ~ ':' ~ hostvars[program_host].program_port) | to_json }},
"venue": {{ ('http://' ~ hostvars[venue_host].service_address ~ ':' ~ hostvars[venue_host].venue_port) | to_json }}
}
EOF
Now write the entry playbook. gather_facts: false avoids collecting unused machine facts. vars defines expressions evaluated from the current inventory. template renders the file, sets mode 0644 (owner may write; everyone may read) and notifies a handler only when the route file changes. A handler is a task run after a notification, normally at the end of the play.
The entry keeps routes in memory, so replacing a file alone is insufficient. Its prepared reload helper sends a real SIGHUP reload signal only to this entry's existing process and waits for acknowledgement. The handler's command uses argv, a list of separate arguments. playbook_dir is the directory containing this playbook; it keeps the managed entry path tied to this project. You do not need sudo or a service restart.
cat > connect.yml <<'EOF'
- name: Connect the event services
hosts: gateway
gather_facts: false
vars:
program_host: "{{ groups['program'][0] }}"
venue_host: "{{ groups['venue'][0] }}"
tasks:
- name: Write the inventory-derived routes
ansible.builtin.template:
src: templates/routes.json.j2
dest: "{{ playbook_dir }}/entry/routes.json"
mode: '0644'
notify: Reload entry
handlers:
- name: Reload entry
ansible.builtin.command:
argv:
- python3
- /opt/labex/service-links/reload.py
- "{{ playbook_dir }}/entry"
EOF
First check the YAML structure, then preview the predicted file change. --syntax-check does not apply tasks. --check predicts supported changes; --diff shows the old and proposed file content. This preview does not signal the gateway or prove the final website works.
ansible-playbook connect.yml --syntax-check
ansible-playbook connect.yml --check --diff
The diff should replace the disconnected 127.0.0.1:9 routes with the current source addresses and ports. Apply the playbook normally:
ansible-playbook connect.yml
You should see the template change and RUNNING HANDLER [Reload entry]. Return to Event Site, use Program in the navigation and read the three current sessions: Paper and ink, Acoustic afternoon and Shared supper. Click Download the schedule to download schedule.csv; it should contain the same times, sessions and places shown in the table. Follow Visit to Riverside Yard, then Visitor guide. The home and guide must still work.
The connected program should show these three current sessions, with Download the schedule directly below the table:

Switch back to Controller. You can also save and inspect the actual download inside the VM. In curl, -f fails on HTTP errors, -sS hides the progress meter but shows errors, and -o names the output file:
curl -fsS http://127.0.0.1:8081/program/schedule.csv -o schedule.csv
cat schedule.csv
Expected content:
time,session,place
10:30,Paper and ink,Making table
13:00,Acoustic afternoon,River stage
16:00,Shared supper,Long table
127.0.0.1 means this controller, where the entry listens on port 8081. The website's public browser address forwards to the same service.
Run the same playbook again:
ansible-playbook connect.yml
The recap should report changed=0 and failed=0, and no reload handler should run. This is convergence: the current declaration already matches the file. The real pages and download, rather than only the recap, establish the usable result.
Protect Working Routes Before Replacing Them
In this step, you will add real HTTP identity checks before the route write, then reject a reachable archive service while preserving the working website.
Continue in Controller, in /home/labex/project. A request returning HTTP 200 means the server answered successfully; it does not establish the service type, event or edition. The current program and venue identify themselves through /identity. The prepared group_vars/all.yml holds the approved site and editions:
cat group_vars/all.yml
Add a preflight, which is a check before a change. assert stops the play when a condition is false. The first assertion requires exactly one member per service group. Each uri task makes a real HTTP GET request from the controller. return_content: true retains the body; a JSON response is also available through .json. register saves a task's result under a variable name for later tasks. The final assertions compare actual identities with the approved values.
The HTTP tasks use check_mode: false deliberately: these read-only requests must still run during a preview. A default uri task may otherwise be skipped in check mode. This setting belongs only on the read-only probes; it is not permission to apply a template or reload during a preview. A three-second request timeout prevents a missing source from holding up the exercise.
Replace the playbook with this complete guarded version. Keep the same template, and keep all identity assertions ahead of the template task:
cat > connect.yml <<'EOF'
- name: Connect the event services
hosts: gateway
gather_facts: false
vars:
program_host: "{{ groups['program'][0] }}"
venue_host: "{{ groups['venue'][0] }}"
tasks:
- name: Require one member for each service
ansible.builtin.assert:
that:
- groups['program'] | length == 1
- groups['venue'] | length == 1
- name: Read the selected program identity
ansible.builtin.uri:
url: "http://{{ hostvars[program_host].service_address }}:{{ hostvars[program_host].program_port }}/identity"
return_content: true
timeout: 3
check_mode: false
register: program_probe
- name: Read the selected venue identity
ansible.builtin.uri:
url: "http://{{ hostvars[venue_host].service_address }}:{{ hostvars[venue_host].venue_port }}/identity"
return_content: true
timeout: 3
check_mode: false
register: venue_probe
- name: Check the actual service identities
ansible.builtin.assert:
that:
- program_probe.json.service == 'program'
- program_probe.json.site == event_site
- program_probe.json.edition == program_edition
- venue_probe.json.service == 'venue'
- venue_probe.json.site == event_site
- venue_probe.json.edition == venue_edition
- name: Write the inventory-derived routes
ansible.builtin.template:
src: templates/routes.json.j2
dest: "{{ playbook_dir }}/entry/routes.json"
mode: '0644'
notify: Reload entry
handlers:
- name: Reload entry
ansible.builtin.command:
argv:
- python3
- /opt/labex/service-links/reload.py
- "{{ playbook_dir }}/entry"
EOF
ansible-playbook connect.yml --syntax-check
Now save a fingerprint of the working route file. sha256sum calculates a content hash; > stores that output in a separate file so it can be checked later:
sha256sum entry/routes.json > routes-before.sha256
The prepared alternate inventory selects an archive as the program. Read it, then intentionally try it with -i, which selects an inventory file for this one command. The normal inventory file is not changed:
cat inventory-wrong-service.ini
ansible-playbook -i inventory-wrong-service.ini connect.yml
This command is expected to fail with a nonzero exit and failed=1. The archive is reachable and returns JSON, but its edition is not approved. The identity assertion should fail before Write the inventory-derived routes; the recap should show changed=0, and no reload handler should run. This is different from a connection refusal, where uri cannot obtain a response at all. Both conditions must stop the write.
Check the saved fingerprint. -c asks sha256sum to compare the current file with the recorded hash:
sha256sum -c routes-before.sha256
Expect entry/routes.json: OK. In Event Site, open Program and download the current schedule again. A rejected proposal must leave the current program usable, not merely leave a file present.
Return to Controller and use the normal inventory:
ansible-playbook connect.yml --check --diff
ansible-playbook connect.yml
Both selected identities should pass. The existing correct routes need no change, so the recap reports changed=0 and no handler runs. If a YAML indentation error appears, compare the complete block above, use spaces rather than tabs and rewrite connect.yml; a syntax error is not a successful preflight rejection.
Your scope is connect.yml, templates/routes.json.j2, and entry routing through that playbook. Keep the supplied inventories, host data, approved editions, curl request files, backend content, organizer notes, homepage and visitor guide. Use the supplied /opt/labex/service-links/reload.py with this project entry directory for reloads. Do not hardcode current node names or addresses into the reusable template or probes, copy source pages into the entry, hide failed probes, restart source nodes or edit the prepared gateway implementation. The configured member and port must remain the source of truth when the inventory changes.
Summary
You connected real program and venue services through inventory-derived routes, inspected the website and its exact CSV download, and used a handler to reload only changed configuration. You then rejected a reachable but unapproved source before the route write, preserving the current website. The next independent lab changes service membership while keeping the old source and its data.



