Skybaer Labs logo
SKYBÆR LABS
Showcase Project

Ansible / RHCE Lab

A home lab built to re-certify as a Red Hat Certified Engineer, and to prove out real automation patterns, not just study for an exam.

Survey-Driven Playbooks Idempotent Design Cross-Platform Automation (Linux + Windows) AWX Job Templates AWX RBAC Source-Controlled Inventory Inventory Group Design GitOps Workflow Forced Password Rotation

Lab Architecture

How the pieces fit together

The whole lab runs inside VMware Workstation Pro on a single physical Windows host. Every hostname below is a VM, including a nested virtualization layer for OpenShift itself.

Scroll sideways to see the full diagram →
PHYSICAL HOST: WINDOWS + VMWARE WORKSTATION PRO control.lab Ansible control node GitHub: ansible-lab (external repo) rhel10-crc (RHEL 10 VM) CRC: single-node OpenShift (nested KVM VM) AWX (awx-demo) git push oc CLI: remote mgmt (bastion; no direct login) pulls Project + Inventory node1.lab web group node2.lab web group node3.lab db group node4.lab db group winlab2025* Windows Server foreman.lab configuring manual: ansible-playbook (SSH / WinRM) automated: AWX jobs (SSH / WinRM)
Two independent paths reach the same five managed hosts: control.lab runs playbooks by hand for development and testing, while AWX (running inside a nested OpenShift cluster) pulls the same playbooks from GitHub and runs them as scheduled/on-demand jobs. Foreman is provisioned in the lab but not yet wired into the automation.

* winlab2025, as referenced throughout this page, has since been renamed to DC01 and given a new job as the lab’s Active Directory domain controller. See the Windows Integration page for that story; everything below reflects how the host was configured and used at the time this automation was built.

Two paths, one fleet

control.lab and AWX reach the same managed hosts independently: one for hands-on development and testing, the other for scheduled and on-demand automation.

Nested virtualization, on purpose

The OpenShift cluster isn’t a separate box in the lab. It’s a CodeReady Containers VM running inside a RHEL 10 VM, which itself runs inside VMware Workstation Pro on the physical host. AWX runs inside that cluster.

GitOps, not sneakernet

control.lab pushes playbook changes to GitHub; AWX’s Project syncs from that same repo, so the automation path never depends on a human copying files onto the control node.

Foreman, next up

foreman.lab is live in the lab and mid-configuration, not yet wired into the automation shown here.

AWX: Job Templates, Surveys & RBAC

One job template, walked end to end

Lab Shutdown is a simple playbook on its own, but the AWX layer around it is where the real practice happens: a Survey that turns a fixed playbook into a flexible one, and Role-Based Access Control that scopes who can run it without handing out admin rights.

Tied to one project, one inventory

Scroll sideways to see the full screenshot →
AWX Job Template details for Lab Shutdown, showing its Project, Inventory, Playbook, and Execution Environment

Lab Shutdown runs playbooks/shutdown_lab.yml from the Ansible-Lab project against the RHCE-Lab inventory. Every job template in the lab shares that same Project and Inventory, rather than one-off copies wired up per template.

A survey instead of a hardcoded target

Scroll sideways to see the full screenshot →
AWX Survey question configuration for the Lab Shutdown template

Launching the template asks “Which server group do you want to shut down?” It’s a required, multi-select survey question (variable: target_group, options: crc, web, db, windows) instead of a fixed host group baked into the playbook. The playbook reads target_group at runtime and joins whichever groups were picked.

Execute-only, by team

Scroll sideways to see the full screenshot →
AWX Team Roles for Fire Team, showing an Execute-only role on the Lab Shutdown Job Template

Fire Team holds an Execute role scoped to just the Lab Shutdown template: not Admin, not Edit, just permission to run it. Its members inherit that access by being on the team rather than each getting an individual grant on the template.

Access isn’t all-or-nothing

Scroll sideways to see the full screenshot →
AWX Access list for the Lab Shutdown Job Template, showing three different role levels

The same template shows three levels of access side by side: fguard001 gets Execute only, inherited through Fire Team membership; sguard001 holds that same team-inherited Execute role plus an individual Admin grant layered on top; and my own account holds System Administrator across the whole instance. Same object, layered permissions instead of one flat role.

Real Troubleshooting

Two bugs from the lab, start to finish

SSH to a Windows host, blocked by a firewall profile

Problem
winlab2025 (Windows Server 2025) wouldn’t accept SSH connections from control.lab, even with OpenSSH Server installed and the service running.
Root cause
The sshd firewall rule was scoped to the “Private” network profile, but Windows had classified the lab’s network adapter as “Public,” so the rule silently never applied.
Fix
Reclassified the adapter with PowerShell’s Set-NetConnectionProfile. Getting Ansible itself working against the host took a few more overrides: ansible_user=jayberryman (Windows has no root account), ansible_shell_type=cmd (Ansible defaults to POSIX shell syntax), and swapping the ad hoc test module from ping to win_ping.

A host var that wouldn’t stick

Problem
After the fix above, ansible_shell_type kept vanishing whenever it was set via group_vars/host_vars files alongside the AWX Source-Control-synced inventory. AWX’s stored Variables view wouldn’t show it, and jobs against winlab2025 failed again.
Root cause
Matches a known pattern reported in upstream ansible/awx GitHub issues #15438 and #12829, though I stopped short of calling it a confirmed, unfixable AWX bug.
Fix
Setting the same variable directly inside the flat inventory file (using INI-style key=value syntax instead of YAML-style key: value) gets picked up correctly on sync. group_vars/host_vars files placed next to an SCM-sourced inventory still get dropped; inline, INI-style inventory vars are now the reliable way to set it.

Inventory Layout

How the same five hosts get grouped for Ansible

The topology diagram up top shows how these hosts are wired together physically. This is the other layer: how Ansible’s inventory groups them logically, which is what target_group actually intersects against. IPs redacted below; everything else is the real file.

Five groups, one flat file

control.lab, node1.lab, node2.lab, node3.lab, node4.lab, rhel10-crc.local, and winlab2025 split into five groups: control, web, db, crc, and windows. Play 1 of the playbook above intersects target_group against the first four; Play 2 intersects it against windows alone.

Synced from GitHub, not retyped into AWX

AWX’s RHCE-Lab inventory is a Source Control-based Inventory Source pointed at this same file in the lab’s GitHub repo, rather than the groups being re-entered by hand in AWX’s UI. control.lab and AWX run against identical group definitions because they’re reading the same file.

The fix that actually stuck

ansible_shell_type=cmd lives right here under [windows:vars], using the inventory file’s own INI-style key=value syntax. That’s the fix from the troubleshooting section above: the same variable set through group_vars/host_vars files next to an SCM-synced inventory kept getting dropped on sync.

Ansible inventory · control.lab
Scroll sideways to see the full lines →
[control]
control.lab

[web]
node1.lab
node2.lab

[db]
node3.lab
node4.lab

[crc]
rhel10-crc.local

[windows]
winlab2025 ansible_user=jayberryman

[windows:vars]
ansible_shell_type=cmd

Playbook Structure & Role Design

The playbook behind the job template above

target_group, target_user_ID, target_comment, and target_temp_passwd are all AWX survey variables. Nothing in the playbook itself hardcodes a host list or a specific account.

One survey, two platforms

A single AWX survey variable, target_group, decides which play runs. Play 1’s hosts: line intersects it against ['crc', 'web', 'db', 'control'] for Linux; Play 2 intersects that same variable against ['windows']. One playbook covers both platforms instead of maintaining two.

Skips instead of failing

Both plays check for the account first, Linux via ansible.builtin.getent, Windows via win_user in query mode, and print a debug message instead of erroring out when it’s already there. Rerunning against an existing account is a no-op, not a failed job.

Temp password, forced rotation

Linux accounts get a password_hash('sha512')’d temp password, then chage -d 0 expires it immediately so the first login forces a change. Windows accounts reach the same result through win_user’s own password_expired: true.

playbooks/user_account_creation.yml View on GitHub
Scroll sideways to see the full lines →
# -------------------------------------------------------------
# Play 1: Create user accounts on local Linux VMs
# -------------------------------------------------------------
- name: Create user accounts on Linux Servers
  hosts: "{{ target_group | intersect(['crc', 'web', 'db', 'control']) | join(',') or 'no_match' }}"          # Matches your Linux group in your inventory
  become: true              # Escalates privileges to root
  gather_facts: false       # Optional: Skips setup phase to speed up the process
#
  tasks:
  - name: Checking to see if account exists
    ansible.builtin.getent:
      database: passwd
      key: "{{ target_user_ID }}"
      fail_key: False
#
  - name: Create Linux accounts
    when: ansible_facts.getent_passwd[target_user_ID] is none
    ansible.builtin.user:
      name: "{{ target_user_ID }}"
      comment: "{{ target_comment }}"
      password: "{{ target_temp_passwd | password_hash('sha512') }}"
#
  - name: Pass message stating account was skipped because it already exists
    when: ansible_facts.getent_passwd[target_user_ID] is not none
    ansible.builtin.debug:
      msg: "{{ target_user_ID  }} already exists. Skipping... "
#
  - name: Force password to change after login
    when: ansible_facts.getent_passwd[target_user_ID] is none
    ansible.builtin.command:
      chage -d 0 "{{ target_user_ID }}"

# -------------------------------------------------------------
# Play 2: Create user accounts on local Windows VM
# -------------------------------------------------------------
- name: Create user accounts on Windows Server
  hosts: "{{ target_group | intersect(['windows']) | join(',') or 'no_match' }}"          # Matches your Windows group in your inventory
  become: false
  gather_facts: false
#
  vars:
    ansible_shell_type: cmd
#
  tasks:
    - name: Check to see if account exists on Windows servers
      ansible.windows.win_user:
        name: "{{ target_user_ID }}"
        state: query
      register: state_status
#
    - name: Create user account on Windows servers
      when: state_status.state == 'absent'
      ansible.windows.win_user:
        fullname: "{{ target_comment }}"
        name: "{{ target_user_ID }}"
        password: "{{ target_temp_passwd }}"
        update_password: on_create
        password_expired: true
#
    - name: Pass message stating account was skipped because it already exists
      when: state_status.state == 'present'
      ansible.builtin.debug:
        msg: "{{ target_user_ID  }} already exists. Skipping... "

Real Test Output

Same playbook, two platforms, two runs

Each side shows a first run against a fresh account, then a rerun once that account already exists. Real AWX/console output, trimmed of IPs, SIDs, and PowerShell CLIXML noise; everything else, including a leftover typo in the test account’s name, is exactly what came back.

Windows · winlab2025
Scroll sideways to see the full lines →
TASK [Create user account on Windows servers] ****************************
changed: [winlab2025] => {
    "changed": true,
    "fullname": "Jane Does",
    "invocation": {
        "module_args": {
            "name": "jdoe001",
            "fullname": "Jane Does",
            "password": "VALUE_SPECIFIED_IN_NO_LOG_PARAMETER",
            "password_expired": true,
            "update_password": "on_create"
        }
    }
}

TASK [Pass message stating account was skipped because it already exists] **
skipping: [winlab2025] => {
    "false_condition": "state_status.state == 'present'"
}

PLAY RECAP *****************************************************************
winlab2025                 : ok=2    changed=1    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0

# --- rerun: account already exists ---

TASK [Check to see if account exists on Windows servers] *******************
ok: [winlab2025] => {
    "changed": false,
    "fullname": "Jane Does",
    "invocation": {
        "module_args": {
            "name": "jdoe001",
            "state": "query"
        }
    },
    "msg": "Querying user 'jdoe001'"
}

TASK [Create user account on Windows servers] *******************************
skipping: [winlab2025] => {
    "changed": false,
    "false_condition": "state_status.state == 'absent'",
    "skip_reason": "Conditional result was False"
}

TASK [Pass message stating account was skipped because it already exists] **
ok: [winlab2025] => {
    "msg": "jdoe001 already exists. Skipping... "
}

PLAY RECAP *****************************************************************
winlab2025                 : ok=2    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0
Linux · node1.lab–node4.lab
Scroll sideways to see the full lines →
TASK [Force password to change after login] ********************************
changed: [node2.lab] => {
    "changed": true,
    "cmd": ["chage", "-d", "0", "sguard001"],
    "rc": 0
}

[WARNING]: Could not match supplied host pattern, ignoring: no_match
PLAY [Create user accounts on Windows Server] *******************************
skipping: no hosts matched

PLAY RECAP *******************************************************************
node1.lab                  : ok=3    changed=2    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0
node2.lab                  : ok=3    changed=2    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0
node3.lab                  : ok=3    changed=2    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0
node4.lab                  : ok=3    changed=2    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0

# --- rerun: accounts already exist ---

TASK [Pass message stating account was skipped because it already exists] ***
ok: [node3.lab] => { "msg": "sguard001 already exists. Skipping... " }
ok: [node4.lab] => { "msg": "sguard001 already exists. Skipping... " }

TASK [Force password to change after login] *********************************
skipping: [node1.lab] => {
    "changed": false,
    "false_condition": "ansible_facts.getent_passwd[target_user_ID] is none",
    "skip_reason": "Conditional result was False"
}
skipping: [node2.lab] => { ...same... }
skipping: [node3.lab] => { ...same... }
skipping: [node4.lab] => { ...same... }

[WARNING]: Could not match supplied host pattern, ignoring: no_match
PLAY [Create user accounts on Windows Server] ********************************
skipping: no hosts matched

PLAY RECAP ********************************************************************
node1.lab                  : ok=2    changed=0    unreachable=0    failed=0    skipped=2    rescued=0    ignored=0
node2.lab                  : ok=2    changed=0    unreachable=0    failed=0    skipped=2    rescued=0    ignored=0
node3.lab                  : ok=2    changed=0    unreachable=0    failed=0    skipped=2    rescued=0    ignored=0
node4.lab                  : ok=2    changed=0    unreachable=0    failed=0    skipped=2    rescued=0    ignored=0
Questions about the lab?
Contact ·
Back to Skybaer Labs