AWX in Practice
In my previous post, I got AWX running on k3s with a custom Execution Environment for Cisco collections. Time to actually use it for something practical: a self-service VLAN provisioning job that lets anyone on the team provision a VLAN on specific switches by filling in a form — no CLI, no SSH, no risk of typos in config mode.
This post covers building it end to end, including the surprising number of gotchas I hit with surveys, host targeting, and variable scoping.
The Goal
A one-click (well, one-form) workflow where the operator:
- Opens AWX
- Fills in three fields: VLAN ID, VLAN Name, which switches
- Hits Launch
AWX does the rest — validates, provisions, verifies — and logs who did what and when.
The Inventory
My master inventory in AWX has two groups:
The Playbook
Two things worth noting here before we get to the gotchas:
hosts: "{{ limit }}"— the host targeting comes from the survey, not hardcoded. More on this below.| default(0)and| default('')on the vars — required to prevent Jinja2 from crashing during lazy evaluation. More on this too.
AWX Setup
Job Template
Templates → Add → Job Template
| Field | Value |
|---|---|
| Name | vlan-provisioning |
| Inventory | master |
| Project | your project |
| Playbook | vlan-provision.yaml |
| Execution Environment | awx-ee-cisco |
| Credentials | your switch Machine credential |
| Limit | (empty, prompt on launch: unchecked) |
Survey
Templates → vlan-provisioning → Survey → Add
Question 1 — VLAN ID
| Field | Value |
|---|---|
| Question | VLAN ID |
| Answer Variable Name | survey_vlan_id |
| Answer Type | Integer |
| Minimum | 2 |
| Maximum | 4094 |
| Required | ✅ |
Question 2 — VLAN Name
| Field | Value |
|---|---|
| Question | VLAN Name |
| Answer Variable Name | survey_vlan_name |
| Answer Type | Text |
| Required | ✅ |
Question 3 — Which switches?
| Field | Value |
|---|---|
| Question | Which switches? |
| Answer Variable Name | limit |
| Answer Type | Multiple Choice (single select) |
| Choices | switches, ios-switch-1, ios-switch-2 |
| Required | ✅ |
Enable the survey toggle → Save.
The Gotchas (there were many)
Gotcha 1 — hosts: can’t use arbitrary survey variables
My first instinct was:
With a survey variable target_switches. This fails immediately:
The hosts: field is evaluated during inventory parsing, before extra vars (including survey vars) are loaded. You can’t use arbitrary variable names here.
The fix: use the reserved name limit as your survey variable name. AWX passes survey variables as --extra-vars to ansible-playbook, and limit happens to be available early enough in evaluation to work in the hosts: field.
Gotcha 2 — Multi-select survey passes a list, not a string
I set up Question 3 as Multiple Choice (multiple select). The job ran but targeted all switches regardless of what was selected. The variables passed to the job showed why:
A list ["ios-switch-1"] instead of a string "ios-switch-1". Ansible’s hosts: field expects a string — it silently ignored the list and fell through to the full group.
The fix: change Answer Type to Multiple Choice (single select). If you need to target multiple specific switches, add combined options to the choices:
A comma-separated string is a valid Ansible host pattern.
Gotcha 3 — Hardcoded Limit in Job Template overrides the survey
After switching to single select, the job still hit all switches. The culprit: the Job Template had switches hardcoded in the Limit field. A hardcoded template Limit takes precedence over any survey variable — even one named limit.
The fix: clear the Limit field in the Job Template completely, and make sure Prompt on launch is unchecked. The survey drives everything.
Gotcha 4 — fail_msg with variable interpolation crashes on undefined vars
My original assert:
This caused:
Even though vlan_id is defined in vars: as "{{ survey_vlan_id | int }}", Jinja2’s lazy evaluation means it tries to resolve survey_vlan_id at assert evaluation time — and if the survey var hasn’t propagated yet, it crashes.
Two fixes together:
First, add | default() to the var definitions so they never resolve to undefined:
Second, remove the variable interpolation from fail_msg to break the evaluation chain:
The | default(0) also gives you a clean assertion failure (0 >= 2 evaluates to false) rather than an undefined variable crash — so the error message is actually useful.
Gotcha 5 — Job failing silently with rc=None
Early on, jobs were completing in ~4 seconds with no output at all. The awx-task logs showed:
rc=None means Ansible never ran — the Execution Environment container failed before the playbook started. The cause was an architecture mismatch: the EE image was built on Apple Silicon (linux/arm64) but the k3s nodes are amd64. The container silently failed to start.
The fix: always build EE images with --platform linux/amd64 on Apple Silicon:
The Final Working Flow
After all of the above, the launch sequence is a clean single survey with no extra prompts:
And the output:
Gotcha Summary
| # | Problem | Fix |
|---|---|---|
| 1 | Arbitrary variable in hosts: is undefined |
Use limit as the survey variable name |
| 2 | Multi-select passes a list, not a string | Use single select; add combined choices for multi-target |
| 3 | Hardcoded Job Template Limit overrides survey | Clear the Limit field in the template |
| 4 | fail_msg with vars crashes on undefined |
Add | default() to vars; remove vars from fail_msg |
| 5 | rc=None, job fails silently with no output |
Build EE with --platform linux/amd64 on Apple Silicon |
What’s Next
With the pattern working, the same survey-driven approach applies to:
- VLAN deletion — same structure,
state: absent - Interface assignment — assign a port to a VLAN on a specific switch
- Switch config backup — scheduled nightly, committed to Forgejo
- Compliance audit — gather running config, diff against Git baseline
The foundation is solid. Each new playbook is just another Job Template pointing at a different file in the same repo.