Ansible Automation Platform · Tower/AWX · Controller Hands-On Lab

🤖 Ansible Automation Platform (Tower/AWX) Lab Guide

Hands-on Controller lab — Inventories · Credentials · Projects · Job Templates · Surveys · Workflows · RBAC · Notifications · API, built on a 3-node lab (Controller + 2 managed nodes).

Controller / Tower / AWXRHEL 10.2 + RHEL 8.10 mixed 14 Task Sets · L1→L3Task + Solution Format
🗺️

0. Your Lab Topology & Naming

Reference used throughout every task below — keep this open in a second tab

L1-00

Lab Inventory Reference

HostnameRoleOSNotes
app.local.labController node (Tower/AWX/AAP)RHEL 10.2Runs the web UI, API, execution engine
node01.local.labManaged nodeRHEL 10.2Same major OS as controller — good "happy path" node
node02.local.labManaged nodeRHEL 8.10Older Python/OpenSSL — deliberately used to practice mixed-OS automation and version drift

All tasks below assume: DNS or /etc/hosts resolves the three FQDNs, SSH key-based auth is possible from app.local.lab to both nodes, and you have a local automation user (e.g. ansible) with sudo rights on node01/node02.

Quick pre-flight check (run on app.local.lab)
for h in node01.local.lab node02.local.lab; do echo "== $h ==" ssh ansible@$h 'hostname; cat /etc/redhat-release; sudo -n true && echo "sudo OK"' done
Terminology: "Ansible Tower" is the legacy commercial name. Today it ships as the Automation Controller component inside Ansible Automation Platform (AAP), or as its open-source upstream AWX. The UI concepts (Inventories, Credentials, Projects, Templates, Workflows) are identical across all three — every task below works regardless of which one you installed.
🏗️

1. Organizations, Users & Base Setup

The container objects everything else lives inside

L1-01

Task 1 — Create an Organization and a non-admin User

Task

Create an Organization called LocalLab. Inside it, create a user opuser (non-superuser) who will later be granted limited permissions. Do this both via the UI and via the CLI/API so you understand both paths.

▶ Show Solution
UI path

Automation Execution → Organizations → Add → Name: LocalLab → Save. Then Access Management → Users → Add → Username: opuser, uncheck "System Administrator", set a password, Save. Then open Organizations → LocalLab → Access → Add → select opuser with role Member.

CLI path (awx-cli / controller-cli, run on app.local.lab)
awx organizations create --name "LocalLab" --description "Local 3-node lab" awx users create --username opuser --password 'ChangeMe123!' --is_superuser false awx organizations associate_user LocalLab opuser
REST API equivalent
curl -sk -u admin:PASSWORD -X POST https://app.local.lab/api/v2/organizations/ \ -H "Content-Type: application/json" \ -d '{"name":"LocalLab","description":"Local 3-node lab"}'
📋

2. Inventories, Groups & Host Variables

Static inventory, groups by OS, per-host variables, ad-hoc commands

L1-02

Task 2 — Build a Static Inventory with OS-based Groups

Task

Create Inventory local_lab. Add both nodes as Hosts. Create two Groups: rhel10 containing node01.local.lab, and rhel8 containing node02.local.lab. Set host variable ansible_host if your DNS is unreliable.

▶ Show Solution
Steps

Inventories → Add → Inventory → Name: local_lab, Organization: LocalLab → Save. Inside it: Groups → Add → rhel10 → Save; Groups → Add → rhel8 → Save. Hosts → Add → node01.local.lab (Variables field optional):

ansible_host: 192.168.1.101 ansible_user: ansible

Repeat for node02.local.lab with its own IP. Then open group rhel10 → Hosts → Associate → node01.local.lab. Same for rhel8 → node02.local.lab.

Verify with an ad-hoc job (Resources → Hosts → select host → Run Command, or via CLI)
ansible local_lab -i /etc/ansible/hosts -m ping # Or from inside Controller UI: Inventories > local_lab > Hosts > select all > Run Command > module: ping
Expected result: both hosts return SUCCESS | rc=0 | pong. If node02 (RHEL 8.10) fails with a Python interpreter error, that's your first real troubleshooting task — see Task 3.
L1-03

Task 3 — Fix Python Interpreter Mismatch (RHEL 8 vs RHEL 10)

Task

RHEL 8.10's default python3 may resolve differently than RHEL 10.2's. Force the correct interpreter on node02 using a group variable instead of editing playbooks.

▶ Show Solution

On node02.local.lab, confirm interpreter location:

which python3 python3 --version

In Controller: Inventories → local_lab → Groups → rhel8 → Variables:

ansible_python_interpreter: /usr/bin/python3
This is a very common real-world interview topic: "how do you handle mixed Python versions across a fleet?" — group/host vars for ansible_python_interpreter is the textbook answer.
L1-04

Task 4 — Smart Inventory Filtered by Facts

Task

Create a Smart Inventory rhel10_only that automatically includes any host where ansible_distribution_major_version equals 10, sourced from local_lab.

▶ Show Solution

Inventories → Add → Smart Inventory → Name: rhel10_only, Organization: LocalLab, Smart Host Filter:

ansible_facts__ansible_distribution_major_version="10"

Save, then open the Smart Inventory's Hosts tab — it should auto-populate with node01.local.lab only, after a fact-gathering job has run at least once against local_lab (facts must be cached first — enable "Use Fact Cache" on the Job Template used to gather facts).

🔑

3. Credentials

Machine, Become, Vault, and testing failure modes

L1-05

Task 5 — Machine Credential with SSH Key

Task

Generate an SSH key pair on app.local.lab, distribute the public key to both nodes, then create a Machine Credential in Controller and validate it with an ad-hoc ping.

▶ Show Solution
ssh-keygen -t ed25519 -f ~/.ssh/aap_lab -N "" ssh-copy-id -i ~/.ssh/aap_lab.pub ansible@node01.local.lab ssh-copy-id -i ~/.ssh/aap_lab.pub ansible@node02.local.lab

Credentials → Add → Name: lab-ssh-key, Credential Type: Machine, Username: ansible, paste the private key from ~/.ssh/aap_lab into "SSH Private Key". Save. Then run an ad-hoc ping job against local_lab using this credential.

L1-06

Task 6 — Become (sudo) Credential + Deliberate Failure Practice

Task

Add a Become password to the credential (or a separate credential) and run a privileged ad-hoc command. Then deliberately break it three ways and record the exact error each time: (a) wrong username, (b) wrong SSH key, (c) become password omitted while target requires one.

▶ Show Solution
Privileged ad-hoc test
# Module: command, Args: whoami, Enable "Become Privilege Escalation" on the Job Template/ad-hoc form # Expected stdout: root
Deliberate breakTypical error
Wrong usernameUNREACHABLE! ... Permission denied (publickey,password)
Wrong/mismatched SSH keyUNREACHABLE! ... Permission denied (publickey)
Become required, no password/NOPASSWDFAILED! ... Missing sudo password or sudo: a password is required
This "break it on purpose" habit is one of the fastest ways to build real troubleshooting muscle memory for an AAP admin interview.
L2-07

Task 7 — Vault Credential for Encrypted Variables

Task

Encrypt a secret string with ansible-vault, reference it from a playbook, and run it in Controller using a Vault credential attached to the Job Template.

▶ Show Solution
Create the vaulted var file
ansible-vault create group_vars/all/vault.yml # vault password: LabVault123! # content: # vault_db_password: SuperSecretPassw0rd
Reference it in a playbook
--- - name: Show vaulted var (masked by no_log in real use) hosts: all vars_files: - group_vars/all/vault.yml tasks: - name: Use the secret (never echo secrets in real jobs) ansible.builtin.debug: msg: "Secret length is {{ vault_db_password | length }} chars"

In Controller: Credentials → Add → Type: Vault, Vault Password: LabVault123! → Save. On the Job Template, add this Vault credential alongside the Machine credential. Run — Controller decrypts transparently at runtime.

📁

4. Projects (SCM Integration)

Git-backed playbook source, auto-sync, branches

L1-08

Task 8 — Git-backed Project with SCM Update on Launch

Task

Push a folder of playbooks (see Task 9) to a Git repo (GitHub, GitLab, or a local bare repo). Create a Project in Controller pointing at it, with "Update Revision on Launch" enabled.

▶ Show Solution
Option A — local bare repo (no internet needed)
mkdir -p ~/git/lab-playbooks.git && cd ~/git/lab-playbooks.git && git init --bare cd ~ && git clone ~/git/lab-playbooks.git lab-playbooks && cd lab-playbooks mkdir group_vars roles # add your yml files, then: git add . && git commit -m "initial lab playbooks" && git push origin main

Projects → Add → Name: Lab Playbooks, SCM Type: Git, SCM URL: /home/ansible/git/lab-playbooks.git (or your GitHub URL), check Update Revision on Launch. Save — watch the sync job succeed and confirm the playbooks list populates on the Job Template creation screen.

▶️

5. Job Templates & Core Playbooks

The playbooks referenced throughout this guide — copy these into your project

L1-09

Task 9 — Basic Job Template: Server Discovery

Task

Write discovery.yml that reports hostname, OS, kernel, CPU, memory and IP for every host. Create a Job Template Server Discovery using it and review the Job Output panes (Details, Output, Recap).

▶ Show Solution
--- - name: Linux Server Discovery hosts: all gather_facts: true tasks: - name: Show server summary ansible.builtin.debug: msg: hostname: "{{ ansible_hostname }}" os: "{{ ansible_distribution }} {{ ansible_distribution_version }}" kernel: "{{ ansible_kernel }}" cpu_count: "{{ ansible_processor_vcpus }}" memory_mb: "{{ ansible_memtotal_mb }}" ip: "{{ ansible_default_ipv4.address | default('n/a') }}"

Templates → Add → Job Template → Name: Server Discovery, Inventory: local_lab, Project: Lab Playbooks, Playbook: discovery.yml, Credentials: lab-ssh-key → Save → Launch. In the results, check the Recap tab for ok/changed/failed counts per host and the Output tab for the raw debug messages.

L1-10

Task 10 — Idempotent Package Install + Service Management

Task

Write a playbook that installs and starts httpd on all hosts. Run it twice and confirm the second run shows 0 "changed" tasks (idempotency). Then manually stop the service on a node and re-run to prove Controller restores desired state.

▶ Show Solution
--- - name: Deploy and manage httpd hosts: all become: true tasks: - name: Install httpd ansible.builtin.dnf: name: httpd state: present - name: Ensure httpd is enabled and running ansible.builtin.systemd: name: httpd enabled: true state: started
Prove idempotency
# Run 1: changed=2 (install + start) # Run 2: changed=0 ok=2 <-- idempotent ssh ansible@node01.local.lab 'sudo systemctl stop httpd' # Run 3 in Controller: changed=1 (systemd restarts it)
L2-11

Task 11 — Survey: Prompt for Package + Choice List

Task

Add a Survey to a "Package Manager" Job Template with: a required text field package_name, a multiple-choice field package_action (install/remove) with default install. Playbook should act on the survey answers via extra_vars.

▶ Show Solution
--- - name: Manage a package via Survey input hosts: all become: true vars: package_action: install # default if not overridden tasks: - name: "{{ package_action }} {{ package_name }}" ansible.builtin.dnf: name: "{{ package_name }}" state: "{{ 'present' if package_action == 'install' else 'absent' }}"

On the Job Template → Survey tab → Add:

FieldTypeRequiredDefault / Choices
Package NameTextYese.g. vim-enhanced
ActionMultiple Choice (single select)Yesinstall, remove — default install

Enable the Survey (toggle at top of the Survey tab) — it's inactive by default even after adding questions. Launch — Controller now shows a form before running.

L2-12

Task 12 — Jinja2 Template Deployment (per-host config)

Task

Deploy a per-host config file /opt/lab/app.conf using a Jinja2 template, driven by group variables (environment differs per group) and gathered facts.

▶ Show Solution
group_vars/rhel10.yml
environment: PROD_RHEL10
group_vars/rhel8.yml
environment: LEGACY_RHEL8
templates/app.conf.j2
SERVER_NAME={{ inventory_hostname }} IP_ADDRESS={{ ansible_default_ipv4.address }} OS={{ ansible_distribution }} {{ ansible_distribution_version }} ENVIRONMENT={{ environment }} MANAGED_BY=AAP-Controller
deploy_config.yml
--- - name: Deploy templated config hosts: all become: true tasks: - name: Ensure /opt/lab exists ansible.builtin.file: path: /opt/lab state: directory mode: '0755' - name: Deploy app.conf from template ansible.builtin.template: src: templates/app.conf.j2 dest: /opt/lab/app.conf mode: '0644'
Confirm each host gets a different ENVIRONMENT value — this proves group_vars precedence is working as expected.
L2-13

Task 13 — Conditionals for OS-specific Logic (RHEL8 vs RHEL10)

Task

Using your two real OS versions, write one playbook that installs httpd on RHEL 10 hosts and nginx on RHEL 8 hosts, using when: conditions against ansible_distribution_major_version.

▶ Show Solution
--- - name: OS-specific web server install hosts: all become: true tasks: - name: Install httpd on RHEL 10 ansible.builtin.dnf: name: httpd state: present when: ansible_distribution_major_version == "10" - name: Install nginx on RHEL 8 ansible.builtin.dnf: name: nginx state: present when: ansible_distribution_major_version == "8" - name: Flag low-memory hosts (example numeric condition) ansible.builtin.debug: msg: "WARNING: {{ inventory_hostname }} has less than 4GB RAM" when: ansible_memtotal_mb < 4096
L2-14

Task 14 — Loops, Handlers & Roles Refactor

Task

Create three local users via a loop, then convert the whole "web server" playbook into a proper role: roles/webserver/{tasks,handlers,templates,defaults,vars,meta}.

▶ Show Solution
Loop example
--- - name: Create lab users hosts: all become: true vars: lab_users: - appuser - developer - operator tasks: - name: Create users ansible.builtin.user: name: "{{ item }}" shell: /bin/bash create_home: true loop: "{{ lab_users }}"
Role scaffold
ansible-galaxy init roles/webserver tree roles/webserver # roles/webserver/{tasks,handlers,templates,files,defaults,vars,meta}/main.yml
roles/webserver/handlers/main.yml
--- - name: restart httpd ansible.builtin.systemd: name: httpd state: restarted
roles/webserver/tasks/main.yml (notify triggers the handler)
--- - name: Deploy app.conf ansible.builtin.template: src: app.conf.j2 dest: /opt/lab/app.conf notify: restart httpd
site.yml (calls the role)
--- - hosts: rhel10 become: true roles: - webserver
🎚️

6. Extra Vars, Tags & Check Mode

Prompt-on-launch behavior, dry runs, selective execution

L2-15

Task 15 — Prompt on Launch: Extra Variables

Task

Write motd.yml that writes a custom message to /etc/motd from an extra_vars var motd_message. Enable "Prompt on Launch" for Variables on the Job Template and supply a different value at each launch.

▶ Show Solution
--- - name: Set MOTD hosts: all become: true vars: motd_message: "Managed by AAP Controller" # default tasks: - name: Write /etc/motd ansible.builtin.copy: content: "{{ motd_message }}\n" dest: /etc/motd mode: '0644'

Job Template → toggle "Prompt on Launch" next to Variables → Save. At Launch, Controller shows an Extra Variables box — enter:

motd_message: "Patched on 2026-08-11 by opuser"
L2-16

Task 16 — Tags: Split Install / Configure / Validate

Task

Tag tasks as install, configure, validate. Create three Job Templates from the same playbook, each using --tags to run only one phase, plus a fourth "Full Deployment" template with no tag filter.

▶ Show Solution
--- - name: Tagged web deployment hosts: all become: true tasks: - name: Install httpd ansible.builtin.dnf: name: httpd state: present tags: install - name: Deploy config ansible.builtin.template: src: app.conf.j2 dest: /opt/lab/app.conf tags: configure - name: Validate service is listening ansible.builtin.wait_for: port: 80 timeout: 5 tags: validate

On each Job Template's "Job Tags" field enter install, configure, or validate respectively; leave blank on the "Full Deployment" template to run everything.

L2-17

Task 17 — Check Mode (Dry Run) and Diff Mode

Task

Launch any Job Template with "Show Changes" (diff mode) and check-mode both enabled, and compare output to a normal run.

▶ Show Solution

On the Launch dialog (or Job Template options), enable both Show Changes and Enable Check Mode. Launch — tasks show what would change (e.g. "package would be installed") without applying anything, and file-content diffs are shown inline for template/copy tasks. Use this before every production-style run.

Interview line to remember: check mode = simulate; diff mode = show before/after; they're independent toggles that combine well together.
🔀

7. Workflow Templates

Chaining jobs, branching on success/failure, approval gates

L2-18

Task 18 — Linear Workflow: Precheck → Patch → Postcheck

Task

Create three Job Templates: Precheck (facts + disk space check), Patch (dnf update), Postcheck (re-check facts). Chain them in a Workflow Template so each only runs "on success" of the previous.

▶ Show Solution
precheck.yml
--- - name: Precheck hosts: all tasks: - name: Check free disk space on / ansible.builtin.shell: df -h / | awk 'NR==2{print $4}' register: freespace changed_when: false - ansible.builtin.debug: var: freespace.stdout
patch.yml
--- - name: Patch system hosts: all become: true tasks: - name: dnf update ansible.builtin.dnf: name: "*" state: latest
postcheck.yml
--- - name: Postcheck hosts: all tasks: - name: Confirm reachable and report kernel ansible.builtin.debug: msg: "{{ inventory_hostname }} kernel is {{ ansible_kernel }}"

Templates → Add → Workflow Template → Name: Patch Workflow → Visualizer → drag Precheck as start node → add Patch node with link type On Success → add Postcheck node with link type On Success from Patch. Save, Launch, watch the graph light up green node by node.

L3-19

Task 19 — Failure Branch + Notification Job

Task

Add a 4th node Failure Alert that only runs "On Failure" of Precheck. Force Precheck to fail (target a nonexistent host or add a failing assert) and confirm the branch fires while Patch/Postcheck are skipped.

▶ Show Solution
--- - name: Precheck with forced failure gate hosts: all tasks: - name: Require at least 1GB free RAM (example threshold) ansible.builtin.assert: that: ansible_memtotal_mb > 1024 fail_msg: "Not enough memory to patch safely"

In the Workflow Visualizer, from the Precheck node draw a second link to a new node Failure Alert (any simple debug/notify playbook) with link type On Failure. Temporarily lower the assert threshold above your lab's actual RAM to trigger it, run, then confirm in the workflow graph: Precheck = red, Failure Alert = ran, Patch/Postcheck = grey "not executed".

L3-20

Task 20 — Approval Node Before Patching

Task

Insert an Approval node between Precheck and Patch with a timeout of 10 minutes. Confirm the workflow pauses and requires a human click to continue, and that it fails-safe on timeout.

▶ Show Solution

In the Workflow Visualizer, click the "+" on the link between Precheck and Patch → Add Approval Node → Name: Manager Approval, Timeout: 600 seconds. Save, Launch — the workflow pauses at "Pending Approval". Go to Jobs → find the Workflow → the Approval node shows Approve/Deny buttons. Test both: Approve continues to Patch; Deny (or letting it time out) marks the workflow failed and Patch/Postcheck never run.

👥

8. RBAC: Organizations, Teams, Roles

Least-privilege access — the #1 real-world admin skill

L2-21

Task 21 — Execute-Only User vs Admin User

Task

Give opuser (from Task 1) the Execute role only on the Server Discovery Job Template — not Admin. Log in as opuser and confirm they can launch it but cannot edit its playbook path, delete it, or see Credentials details.

▶ Show Solution

Templates → Server Discovery → Access → Add → select opuser, Role: Execute → Save. Log out, log in as opuser. Expected: the template appears under Templates, a Launch (rocket) icon is available, but the pencil/edit icon and Delete are absent or return a 403 if hit directly via API.

RoleViewLaunchEditDelete
Read
Execute
Admin
L2-22

Task 22 — Teams and Delegated Inventory Access

Task

Create a Team OpsTeam under LocalLab, add opuser to it, and grant the Team Use role on the local_lab Inventory and Credential (but not Admin), so team members can build their own Job Templates against your lab without touching Credential secrets.

▶ Show Solution

Access Management → Teams → Add → Name: OpsTeam, Organization: LocalLab. Open it → Users → Add → opuser. Then Inventories → local_lab → Access → Add → search Team → OpsTeam, Role: Use. Credentials → lab-ssh-key → Access → Add → OpsTeam, Role: Use. Note: "Use" lets them reference the credential in a Job Template they own without ever viewing the private key.

🔔

9. Scheduling & Notifications

Recurring jobs and alerting on success/failure

L1-23

Task 23 — Schedule a Recurring Job

Task

Schedule Server Discovery to run every 15 minutes. Review the Schedules tab and confirm job history builds up automatically without manual launches.

▶ Show Solution

Job Template → Schedules → Add → Name: Every 15 min, Start Date/Time: now, Repeat Frequency: Custom → RRULE:

FREQ=MINUTELY;INTERVAL=15

Save. After ~30 minutes, Jobs → filter by this template — you should see 2+ automatic runs with "Launched By: Scheduler".

L2-24

Task 24 — Notification on Job Failure

Task

Create a Webhook Notification Template pointing to a local test receiver (e.g. webhook.site, or a small local HTTP listener), attach it to a Job Template for the Failure event, then trigger a real failure (target a nonexistent host) and confirm delivery.

▶ Show Solution
Quick local receiver (optional, on app.local.lab)
python3 -m http.server 9000 --bind 0.0.0.0 # or use `nc -l 9000` to just watch raw POSTs land

Administration → Notifications → Add → Type: Webhook, Target URL: http://app.local.lab:9000/ → Save. On the Job Template → Notifications tab → toggle this template ON for Failure (and separately for Success if you want both). Temporarily change the inventory to include node99.local.lab (doesn't exist), launch — job fails with UNREACHABLE, and the webhook receiver logs an inbound POST.

📦

10. Execution Environments & Job Slicing

Container-based runtimes and horizontal scale-out

L3-25

Task 25 — Inspect and Assign an Execution Environment

Task

Identify which Execution Environment (EE) your Job Templates use by default, and explicitly pin the Server Discovery template to a specific EE image.

▶ Show Solution

Administration → Execution Environments — note the default (usually ee-supported-rhel10 or ee-minimal). Each EE is an OCI container bundling ansible-core, Python, and collections — this is what actually executes your playbook, not the Controller host's own Python. On the Job Template → Execution Environment dropdown → explicitly select one and Save; re-run and confirm the Job Details pane shows that EE's image name.

Interview framing: "the managed nodes only need Python + SSH; the EE is what runs on the control side and decouples the Controller OS from playbook dependencies."
L3-26

Task 26 — Job Slicing (simulated with your 2 nodes)

Task

Set Job Slicing to 2 on a Job Template running against local_lab (2 hosts) and observe that Controller splits the run into 2 separate jobs under one workflow-style job, one per slice.

▶ Show Solution

Job Template → Job Slicing field → set to 2 → Save → Launch. Jobs list shows a parent "Slice Job" plus two child jobs, each targeting one host. At real scale (say 100 hosts, slice count 10) this parallelizes work across multiple Controller execution nodes/instance groups — with only 2 hosts you're mainly proving the mechanism, not the performance gain.

🌐

11. REST API Automation

Driving Controller from curl / Python instead of the UI

L3-27

Task 27 — Launch a Job Template and Poll Status via API

Task

Authenticate to the API, list Job Templates, launch Server Discovery by ID, and poll until it completes — all with curl.

▶ Show Solution
BASE=https://app.local.lab AUTH="admin:PASSWORD" # List job templates curl -sk -u $AUTH $BASE/api/v2/job_templates/ | python3 -m json.tool | grep -E '"id"|"name"' # Launch template ID 9 (Server Discovery) JOB=$(curl -sk -u $AUTH -X POST $BASE/api/v2/job_templates/9/launch/ | python3 -c "import sys,json;print(json.load(sys.stdin)['job'])") echo "Launched job $JOB" # Poll status every 5s until [ "$STATUS" = "successful" ] || [ "$STATUS" = "failed" ]; do STATUS=$(curl -sk -u $AUTH $BASE/api/v2/jobs/$JOB/ | python3 -c "import sys,json;print(json.load(sys.stdin)['status'])") echo "status: $STATUS" sleep 5 done
This is the exact pattern used to trigger AAP jobs from Jenkins, GitLab CI, or a Slack bot — treat it as your CI/CD integration reference.
🏆

12. Capstone Project — Self-Service Patch Portal

Combine every feature above into one real workflow

L3-28

Task 28 — Build the Full Patch Workflow

Task

Build a single Workflow Template a non-admin can self-launch that: (1) shows a Survey for a maintenance window comment, (2) runs Precheck (facts + disk + service status), (3) waits for Approval, (4) runs Patch (dnf update), (5) reboots if the kernel changed, (6) runs Postcheck, (7) sends a success or failure Notification, all restricted via RBAC so opuser can launch but not edit.

▶ Show Solution
Workflow graph
Survey (on Workflow Template itself) ↓ Precheck ──(On Failure)──► Failure Alert (Notification) │(On Success) ▼ Manager Approval (600s timeout) │(Approved) ▼ Patch (dnf update -y) │(On Success) ▼ Conditional Reboot (register kernel before/after, reboot only if changed) │(On Success) ▼ Postcheck │(On Success) ▼ Success Notification
reboot_if_needed.yml (conditional reboot pattern)
--- - name: Reboot only if kernel changed hosts: all become: true tasks: - name: Check if a new kernel is pending ansible.builtin.command: needs-restarting -r register: needs_restart changed_when: false failed_when: false - name: Reboot when required ansible.builtin.reboot: reboot_timeout: 300 when: needs_restart.rc == 1

Add the Survey directly on the Workflow Template (not just individual Job Templates) so it prompts once at the top: field maintenance_comment, Text, optional. Then grant opuser/OpsTeam Execute role on the Workflow Template only — they can launch the entire pipeline with one click and never see the underlying playbooks or credentials.

This single build exercises: Survey, Workflow branching, Approval Node, conditional logic, Notifications, and RBAC — the exact feature set an AAP admin interview will probe.
🎯

13. Interview Q&A

Concept checks tied directly to the tasks above

Q&A

Core Concepts

Q1. What's the difference between Ansible Tower, AWX, and Ansible Automation Platform (AAP) Controller?
Tower was the original commercial product name from Red Hat. AWX is the open-source upstream project (same codebase, faster release cadence, no official support). AAP Controller is the current commercial component name — Tower was rebranded into it. Concepts (Inventories, Projects, Job/Workflow Templates, Credentials) are identical across all three.
Q2. Why use a Project pointing at Git instead of playbooks stored locally on the Controller?
Version control, audit trail, rollback, and multi-admin collaboration. "Update Revision on Launch" also guarantees every job run uses the exact latest committed playbook, avoiding drift between what's on disk and what's tracked in source control.
Q3. How does Controller keep secrets like SSH keys and vault passwords out of playbooks?
Via the Credentials object — secrets are injected at runtime into the execution environment and never written into the Project's Git repo or exposed to users with only Execute/Use roles. Vault credentials similarly decrypt ansible-vault files transparently at job launch.
Q4. What is the practical difference between the Execute and Admin roles on a Job Template?
Execute lets a user launch the template and view results, but not edit its playbook, inventory, credentials, or survey, and not delete it. Admin has full CRUD plus the ability to grant access to others. This underpins RBAC-based self-service.
Q5. Why add an Approval node instead of just trusting Job Templates to run correctly?
Approval nodes enforce a human gate before a risky step (e.g. production patching or reboot), satisfying change-management/ITIL-style controls, while still letting the rest of the pipeline (precheck, postcheck, reporting) run unattended.
Q6. How would you handle a fleet with mixed OS/Python versions, like RHEL 8 and RHEL 10 in this lab?
Set ansible_python_interpreter at the group/host variable level rather than hardcoding it in playbooks, and use when: conditionals keyed off gathered facts like ansible_distribution_major_version for OS-specific task logic.
Q7. What problem does Job Slicing solve?
It splits one Job Template run across many hosts into multiple parallel sub-jobs, distributable across Controller execution nodes/instance groups — reducing wall-clock time for large fleets versus a single sequential/forked run.
Q8. What actually executes the playbook — the Controller host's OS, or something else?
The Execution Environment — a container image bundling ansible-core, Python, and required collections. This decouples playbook dependencies from whatever OS the Controller itself runs on.
📋

14. Quick Reference

Object hierarchy and common CLI/API one-liners

REF

Command & Concept Cheatsheet

Object hierarchy (build in this order)
Organization └── Inventory (Hosts, Groups, Smart Inventories) └── Credentials (Machine, Become, Vault, Cloud, etc.) └── Project (SCM-backed playbook source) └── Job Template (Inventory + Project + Playbook + Credentials + Survey) └── Schedules, Notifications, Job Slicing, Execution Environment └── Workflow Template (chains Job Templates + Approval Nodes + its own Survey) └── Teams / Users (RBAC roles: Read, Use, Execute, Admin)
TaskCLI (awx-cli / controller-cli)
Loginawx login --conf.host https://app.local.lab
List inventoriesawx inventory list
Launch a templateawx job_templates launch "Server Discovery" --monitor
Check job statusawx jobs get <id>
Ad-hoc pingansible local_lab -m ping
Vault-encrypt a fileansible-vault encrypt group_vars/all/vault.yml
Syntax-check a playbookansible-playbook site.yml --syntax-check
Dry run from CLIansible-playbook site.yml --check --diff