# AWX — Ansible Automation for Aether Lifecycle Hooks AWX (the open-source upstream of Ansible Tower) provides a web UI and REST API for running Ansible playbooks. Aether integrates with AWX to run post-deploy and decommission playbooks as lifecycle hooks on every instance create/delete. This guide covers deploying AWX on the Incus cluster, connecting it to Aether, writing lifecycle playbooks, and troubleshooting. ## Architecture ``` User deploys instance via Aether UI | v Aether creates instance on Incus cluster | v Aether calls AWX API: POST /api/v2/job_templates/{id}/launch/ with extra_vars: ffsdn_instance_name, ffsdn_instance_ip, ... | v AWX runs post-deploy.yml playbook 1. Pushes setup script to instance (Incus file API) 2. Executes script (Incus exec API) 3. Verifies /etc/deploy-info was written 4. Logs to deployment ledger | v Aether marks deployment complete (or rolls back on failure) ``` Decommission works in reverse — Aether triggers the decommission template before deleting the instance. Decommission failures do NOT block deletion. ### Separation of concerns | Layer | Tool | Responsibility | |-------|------|----------------| | Infrastructure | Aether + Incus | Instance creation, networking, resources, lifecycle | | Configuration | AWX + Ansible | OS config, packages, services, integrations | | Automation glue | Aether AWX binding | Triggers playbooks at create/delete | ### What Aether passes to AWX (validated) Every AWX job launched by Aether receives these `ffsdn_` prefixed extra vars: | Variable | Description | Example | |----------|-------------|---------| | `ffsdn_instance_name` | Instance name | `awx-e2e-test` | | `ffsdn_instance_ip` | IP address assigned by Aether | `10.207.217.5` | | `ffsdn_cluster_id` | Numeric cluster ID | `52` | | `ffsdn_cluster_name` | Cluster display name | `oc-lab-cluster` | | `ffsdn_deployed_by` | Aether username who triggered deploy | `admin` | | `ffsdn_image_os` | Image OS | `Debian` | | `ffsdn_image_release` | Image release | `bookworm` | | `ffsdn_image_alias` | Image alias (may be empty) | `""` | **Important**: Aether does NOT pass `vm_name`, `vm_ip`, `environment`, `owner`, or `cost_center`. The original plan assumed these names, but real testing revealed Aether uses the `ffsdn_` prefix exclusively. ### AWX API endpoints Aether uses | Endpoint | Purpose | |----------|---------| | `GET /api/v2/ping/` | Health check | | `GET /api/v2/job_templates/{id}/` | Validate template exists | | `POST /api/v2/job_templates/{id}/launch/` | Trigger job with extra_vars | | `GET /api/v2/jobs/{id}/` | Poll job status | ### Lifecycle behavior | Hook | Trigger | On failure | |------|---------|------------| | Post-deploy | After instance creation | Aether auto-rollbacks (deletes instance) | | Decommission | Before instance deletion | Failure does NOT block deletion | Aether polls `GET /api/v2/jobs/{id}/` until the job reaches a terminal state (successful, failed, error) or the timeout expires. Post-deploy rollback means the instance is deleted — the user sees "Ansible job N finished with status: failed. Instance has been deleted." ## Lab deployment ### Resource requirements | Resource | Recommended | Minimum | |----------|-------------|---------| | vCPU | 4 | 2 | | RAM | 8 GiB | 4 GiB | | Disk | 40 GiB | 20 GiB | K3s uses ~600 MiB, AWX pods ~3 GiB, PostgreSQL ~400 MiB. The web pod was originally set to 1 GiB limit but OOMKilled; 2 GiB is the tested minimum. ### Lab details | Setting | Value | |---------|-------| | VM name | `awx` | | Location | oc-node-02 | | IP | 192.168.102.161/22 (VLAN 69) | | Gateway | 192.168.100.1 | | DNS | 192.168.100.1 | | AWX API port | 30080 (K3s NodePort) | | OS | Debian 12 | | K8s | K3s (single-node) | | AWX | 24.6.1 | | AWX Operator | 2.19.1 | **Note**: AWX is exposed via K3s NodePort on port 30080, NOT via Traefik ingress. The ingress returns 404 when accessed by IP (requires hostname). NodePort works reliably with IP-based access. ### Manual deployment (tested) #### Step 1: Create VM ```bash # Launch Debian 12 VM on the cluster incus launch images:debian/12 oc-node-02:awx --vm \ --target oc-node-02 \ -c limits.cpu=4 -c limits.memory=8GiB \ -d root,size=40GiB # Replace default NIC with macvlan for direct VLAN access # IMPORTANT: use 'config device add' not 'profile device remove' # Profile device removal fails on cluster members incus config device add oc-node-02:awx eth0 nic \ nictype=macvlan parent=mgmt ``` **Bug found**: `incus profile device remove` fails on cluster members with "Profile device eth0 not found" because profiles are cluster-wide. Use `incus config device add` instead, which creates an instance-level override that takes priority over the profile. #### Step 2: Configure static IP Debian 12 uses `systemd-networkd`, **not netplan**. The original plan assumed netplan but that's Ubuntu-only. ```bash incus exec oc-node-02:awx -- bash -c " hostnamectl set-hostname awx # Debian 12 uses systemd-networkd cat > /etc/systemd/network/10-static.network << 'NETCFG' [Match] Name=enp5s0 [Network] Address=192.168.102.161/22 Gateway=192.168.100.1 DNS=192.168.100.1 NETCFG systemctl restart systemd-networkd " ``` After IP change, wait a few seconds for the new IP to take effect. #### Step 3: Install prerequisites and K3s ```bash # Debian 12 minimal doesn't have curl or git incus exec oc-node-02:awx -- bash -c " apt-get update && apt-get install -y curl git curl -sfL https://get.k3s.io | sh -s - --write-kubeconfig-mode 644 kubectl wait --for=condition=Ready node --all --timeout=120s " ``` #### Step 4: Deploy AWX Operator ```bash incus exec oc-node-02:awx -- bash -c " kubectl create namespace awx mkdir -p /opt/awx/operator cat > /opt/awx/operator/kustomization.yaml << 'EOF' apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - github.com/ansible/awx-operator/config/default?ref=2.19.1 images: - name: quay.io/ansible/awx-operator newTag: 2.19.1 namespace: awx EOF kubectl apply -k /opt/awx/operator/ # Wait for operator (~2-3 min) kubectl -n awx wait --for=condition=Available \ deployment/awx-operator-controller-manager --timeout=300s " ``` #### Step 5: Deploy AWX instance The AWX custom resource is defined in `incusos/awx-manifests/base/awx.yaml`. Key settings discovered during testing: - **PVC**: must use `ReadWriteOnce` (not `ReadWriteMany`) for K3s local-path - **Web pod memory**: 2 GiB limit minimum (1 GiB causes OOMKill) - **Service type**: `NodePort` on port 30080 (ingress returns 404 for IP access) ```bash # Push manifests to VM and apply incus exec oc-node-02:awx -- mkdir -p /opt/awx/base # Push awx.yaml and kustomization.yaml (from incusos/awx-manifests/base/) # Then apply: incus exec oc-node-02:awx -- kubectl apply -k /opt/awx/base/ # Wait 5-10 min for all pods to start incus exec oc-node-02:awx -- kubectl -n awx get pods -w ``` Expected final pod state: ``` awx-operator-controller-manager-xxx 2/2 Running awx-postgres-15-0 1/1 Running awx-task-xxx 4/4 Running awx-web-xxx 3/3 Running ``` #### Step 6: Verify ```bash # Get admin password ADMIN_PW=$(incus exec oc-node-02:awx -- kubectl -n awx get secret \ awx-admin-password -o jsonpath='{.data.password}' | base64 -d) echo "Admin password: $ADMIN_PW" # Test API via NodePort curl -sk http://192.168.102.161:30080/api/v2/ping/ ``` AWX web UI: `http://192.168.102.161:30080/` — login with `admin` / password above. ## AWX configuration (tested) ### Project (manual, no Git dependency) AWX Git-based projects require SSH key configuration and network access to the Git server. For simplicity, use a **manual project** — playbook files are pushed directly to the AWX task pod: ```bash # Create manual project via API AWX_TOKEN="..." # from PAT creation below curl -sk -X POST http://192.168.102.161:30080/api/v2/projects/ \ -H "Authorization: Bearer $AWX_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "incus-contrib", "organization": 1, "scm_type": "", "local_path": "incus-contrib" }' ``` Then push playbooks to the task pod: ```bash AWX_POD=$(incus exec oc-node-02:awx -- kubectl -n awx get pods \ -l app.kubernetes.io/name=awx-task -o jsonpath='{.items[0].metadata.name}') # Create project directory structure incus exec oc-node-02:awx -- kubectl -n awx exec $AWX_POD -- \ mkdir -p /var/lib/awx/projects/incus-contrib/playbooks # Push playbooks (via base64 to handle stdin issues) B64=$(base64 -w0 ansible/playbooks/post-deploy.yml) incus exec oc-node-02:awx -- bash -c "echo '$B64' | base64 -d > /root/post-deploy.yml" incus exec oc-node-02:awx -- kubectl -n awx cp /root/post-deploy.yml \ $AWX_POD:/var/lib/awx/projects/incus-contrib/playbooks/post-deploy.yml ``` **Important**: also push the Incus client certificate and key to the project directory — the playbooks use these for Incus API calls: ```bash incus exec oc-node-02:awx -- kubectl -n awx cp /root/incus-client.crt \ $AWX_POD:/var/lib/awx/projects/incus-contrib/incus-client.crt incus exec oc-node-02:awx -- kubectl -n awx cp /root/incus-client.key \ $AWX_POD:/var/lib/awx/projects/incus-contrib/incus-client.key ``` ### Inventory The playbooks use `localhost` exclusively (no SSH, Incus API only). The inventory is a placeholder required by AWX: ```bash curl -sk -X POST http://192.168.102.161:30080/api/v2/inventories/ \ -H "Authorization: Bearer $AWX_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "incus-instances", "organization": 1}' ``` ### Credentials A machine credential (SSH key) is created but only used as a fallback. The primary playbook approach uses the Incus REST API with client certificates, not SSH. ```bash # Generate SSH key on AWX VM incus exec oc-node-02:awx -- ssh-keygen -t ed25519 -f /root/.ssh/awx_key -N "" # Create machine credential in AWX PRIV_KEY=$(incus exec oc-node-02:awx -- cat /root/.ssh/awx_key) curl -sk -X POST http://192.168.102.161:30080/api/v2/credentials/ \ -H "Authorization: Bearer $AWX_TOKEN" \ -H "Content-Type: application/json" \ -d "{ \"name\": \"incus-instances\", \"organization\": 1, \"credential_type\": 1, \"inputs\": {\"username\": \"root\", \"ssh_key_data\": $(echo "$PRIV_KEY" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))')} }" ``` ### Job templates | Template | Playbook | Template ID | |----------|----------|-------------| | `post-deploy` | `playbooks/post-deploy.yml` | 10 | | `decommission` | `playbooks/decommission.yml` | 11 | Both must have `ask_variables_on_launch: true`: ```bash curl -sk -X POST http://192.168.102.161:30080/api/v2/job_templates/ \ -H "Authorization: Bearer $AWX_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "post-deploy", "organization": 1, "project": 6, "playbook": "playbooks/post-deploy.yml", "inventory": 2, "ask_variables_on_launch": true }' ``` **Attach credentials** to templates (required even if playbook doesn't use SSH): ```bash curl -sk -X POST http://192.168.102.161:30080/api/v2/job_templates/10/credentials/ \ -H "Authorization: Bearer $AWX_TOKEN" \ -H "Content-Type: application/json" \ -d '{"id": 4}' ``` ### Personal Access Token ```bash AWX_TOKEN=$(curl -sk -X POST http://192.168.102.161:30080/api/v2/users/1/personal_tokens/ \ --user "admin:${ADMIN_PW}" \ -H "Content-Type: application/json" \ -d '{"description": "Aether integration", "scope": "write"}' \ | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])") ``` ## Aether integration ### Register AWX endpoint Via Aether API (requires session cookies + CSRF token): ```bash curl -sSk -b /tmp/aether-cookies.txt \ -H "X-CSRF-Token: $CSRF" \ -H "Referer: https://192.168.102.160:8443/" \ -X POST https://192.168.102.160:8443/api/awx/endpoints \ -H "Content-Type: application/json" \ -d '{ "name": "lab-awx", "url": "http://192.168.102.161:30080", "token": "'$AWX_TOKEN'", "verify_ssl": false }' ``` **Tested result**: endpoint ID 2 created, AWX health shows "healthy" at `/api/health/awx`: ```json {"awx_healthy":true,"awx_version":"24.6.1","awx_status":"ok"} ``` ### Bind cluster to AWX **Known bug**: `PUT /api/clusters/{id}/awx-config` returns `{"error":"Invalid cluster ID"}` for all valid cluster IDs. This Aether API endpoint appears non-functional in v6.4.317. **Workaround**: direct PostgreSQL update on the Aether database: ```bash incus exec oc-node-01:aether -- bash -c " docker exec aether-postgres psql -U aether -d incusovnsdnc -c \" UPDATE incus_ovn_clusters SET awx_endpoint_id = 2, awx_post_deploy_template_id = 10, awx_decommission_template_id = 11, awx_job_timeout_seconds = 600 WHERE cluster_id = 52; \" " ``` ### Verify integration ```bash # Check Aether's AWX health endpoint curl -sSk -b /tmp/aether-cookies.txt \ https://192.168.102.160:8443/api/health/awx # Deploy a test instance via Aether → AWX job triggers automatically # Job history visible at: /deploy page → "Your Recent Ansible Automation Jobs" ``` ## Writing playbooks ### Key design decision: Incus API, not SSH The playbooks use the **Incus REST API** (file push + exec) instead of SSH for all instance configuration. This is necessary because: 1. **Bridge network isolation**: containers on incusbr0 (10.207.217.0/24) are not routable from AWX (192.168.102.161). The bridge is NAT'd and IncusOS nodes don't forward inbound traffic to the bridge. 2. **No SSH dependency**: fresh Debian 12 containers don't have python3 installed. With SSH, a raw bootstrap step is needed first. 3. **Works with any network**: the Incus API is accessible at the cluster node level (192.168.102.140-142:8443), regardless of which overlay or bridge network the container is on. The trade-off: playbooks require an Incus client certificate in the AWX project directory, and the playbook pattern is less "Ansible-native" (no SSH connection plugins, no gather_facts, no modules running on the target). ### File paths in AWX AWX runs playbooks inside an **Execution Environment** (EE) container via receptor. During job execution: | Path | Description | |------|-------------| | `/runner/project/` | Project root (maps to the manual project directory) | | `/runner/project/playbooks/` | Playbook directory | | `/runner/project/incus-client.crt` | Incus client certificate | | `/runner/project/incus-client.key` | Incus client key | | `/runner/artifacts/{job_id}/` | Job artifacts (logs, SSH keys) | **Important**: files at `/var/lib/awx/projects/` (the task pod) are NOT accessible at the same path during job execution. The EE mounts the project at `/runner/project/`. ### post-deploy.yml pattern (tested, validated) ```yaml - name: Post-deploy — configure instance via Incus API hosts: localhost gather_facts: false connection: local vars: incus_api: "https://192.168.102.140:8443" incus_cert: "/runner/project/incus-client.crt" incus_key: "/runner/project/incus-client.key" tasks: # 1. Validate required ffsdn_* variables from Aether - name: Validate required variables ansible.builtin.assert: that: - ffsdn_instance_name is defined - ffsdn_instance_name | length > 0 # 2. Wait for instance to be Running - name: Wait for instance to be running ansible.builtin.uri: url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/state" client_cert: "{{ incus_cert }}" client_key: "{{ incus_key }}" validate_certs: false register: state until: state.json.metadata.status == "Running" retries: 12 delay: 5 # 3. Push setup script via Incus file API - name: Push setup script ansible.builtin.uri: url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/files?path=/tmp/setup.sh" method: POST client_cert: "{{ incus_cert }}" client_key: "{{ incus_key }}" validate_certs: false body: "{{ setup_script }}" headers: Content-Type: "application/octet-stream" X-Incus-type: "file" X-Incus-mode: "0755" # 4. Execute via Incus exec API - name: Execute setup script ansible.builtin.uri: url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/exec" method: POST client_cert: "{{ incus_cert }}" client_key: "{{ incus_key }}" validate_certs: false body_format: json body: command: ["/bin/bash", "/tmp/setup.sh"] record-output: true interactive: false wait-for-websocket: false status_code: [202] register: exec_result # 5. Wait for completion via operation API - name: Wait for script to complete ansible.builtin.uri: url: "{{ incus_api }}{{ exec_result.json.operation }}/wait?timeout=300" client_cert: "{{ incus_cert }}" client_key: "{{ incus_key }}" validate_certs: false register: exec_wait # 6. Verify exit code - name: Verify exit code ansible.builtin.assert: that: - exec_wait.json.metadata.metadata.return | int == 0 # 7. Verify result via file API - name: Read /etc/deploy-info ansible.builtin.uri: url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/files?path=/etc/deploy-info" client_cert: "{{ incus_cert }}" client_key: "{{ incus_key }}" validate_certs: false return_content: true register: deploy_info ``` ### Incus API endpoints used by playbooks | Endpoint | Method | Purpose | |----------|--------|---------| | `/1.0/instances/{name}/state` | GET | Check if instance is Running | | `/1.0/instances/{name}/files?path=...` | POST | Push files to instance | | `/1.0/instances/{name}/files?path=...` | GET | Read files from instance | | `/1.0/instances/{name}/exec` | POST | Execute commands in instance | | `/1.0/operations/{uuid}/wait` | GET | Wait for async operation | | `/1.0/instances/{name}` | GET | Check if instance exists | **Note**: exec output (stdout/stderr from `record-output: true`) is NOT retrievable via the `/1.0/instances/{name}/logs/` API. The log filename format (`exec_UUID.stdout`) is rejected as "not valid". Verify results by reading files written by the script instead. ### decommission.yml pattern (tested, validated) ```yaml - name: Decommission — graceful shutdown and logging hosts: localhost gather_facts: false connection: local vars: incus_api: "https://192.168.102.140:8443" incus_cert: "/runner/project/incus-client.crt" incus_key: "/runner/project/incus-client.key" tasks: - name: Check if instance still exists ansible.builtin.uri: url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}" client_cert: "{{ incus_cert }}" client_key: "{{ incus_key }}" validate_certs: false status_code: [200, 404] register: instance_check - name: Stop services (best-effort) ansible.builtin.uri: url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/exec" method: POST # ... service stop command ignore_errors: true when: instance_check.status == 200 - name: Record in ledger ansible.builtin.lineinfile: path: /tmp/awx-deploy-ledger.log # ... log entry ``` ## Bugs discovered during validation ### Ansible reserved keyword: `environment` Ansible's `environment` keyword is reserved — it sets process environment variables for tasks. If Aether passed `environment: "lab"` as an extra var, Ansible would interpret it as the keyword, resolving to `[]` instead of the string value. The fix was to use `deploy_env` instead. In practice, Aether uses `ffsdn_*` prefixes which avoid this entirely. ### Recursive template loop with self-referencing vars ```yaml # BROKEN — causes infinite recursion if vm_ip is not provided vars: vm_ip: "{{ vm_ip | default('') }}" ``` When the variable name matches the template reference AND the variable is not provided as an extra var, Ansible enters infinite recursion. The fix: don't self-reference. Use the `ffsdn_*` variables directly without redeclaring them in vars blocks. ### AWX EE runs at /runner/project/, not /var/lib/awx/projects/ Files pushed to the AWX task pod at `/var/lib/awx/projects/incus-contrib/` are mounted in the EE container at `/runner/project/`. All file paths in playbooks must use the `/runner/project/` prefix. ### Bridge network not routable from AWX VM Containers on incusbr0 (10.207.217.0/24) are NOT reachable from the AWX VM (192.168.102.161). The bridge is NAT'd outbound only; IncusOS nodes don't forward inbound traffic from the management network to the bridge. Adding a static route on the AWX VM (`ip route add 10.207.217.0/24 via 192.168.102.140`) doesn't work because the node's nftables rules block non-established connections from external interfaces to the bridge. **Solution**: use the Incus REST API instead of SSH. The cluster API (192.168.102.140:8443) is always reachable, and provides file push + exec endpoints that work regardless of instance network topology. ### Aether cluster AWX config API bug `PUT /api/clusters/{id}/awx-config` returns `{"error":"Invalid cluster ID"}` for all valid cluster IDs. Tested with different CSRF tokens, session cookies, request formats (JSON fields, POST vs PUT). The endpoint is not documented in Aether's swagger.yaml. Workaround: direct PostgreSQL UPDATE. ### AWX web pod OOMKill at 1 GiB The AWX web pod (nginx + uwsgi) needs at least 2 GiB memory limit. With 1 GiB, it starts successfully but gets OOMKilled under load. Set in the AWX CR: `web_resource_requirements.limits.memory: 2Gi`. ### AWX PVC needs ReadWriteOnce K3s local-path provisioner creates `hostPath` volumes which only support `ReadWriteOnce`. If the AWX CR specifies `ReadWriteMany`, the PVC stays Pending and PostgreSQL can't start. Set: `postgres_storage_class: local-path` and ensure `ReadWriteOnce` access mode. ## Troubleshooting ### Pod status ```bash # List all AWX pods incus exec oc-node-02:awx -- kubectl -n awx get pods -o wide # Describe a failing pod incus exec oc-node-02:awx -- kubectl -n awx describe pod # View pod logs incus exec oc-node-02:awx -- kubectl -n awx logs ``` ### Job output ```bash # Get real-time job output (text format) curl -sk http://192.168.102.161:30080/api/v2/jobs/{id}/stdout/?format=txt \ -H "Authorization: Bearer $AWX_TOKEN" # Check job status curl -sk http://192.168.102.161:30080/api/v2/jobs/{id}/ \ -H "Authorization: Bearer $AWX_TOKEN" \ | python3 -c "import sys,json; d=json.load(sys.stdin); \ print('status:', d['status'], 'failed:', d['failed'])" ``` ### Common issues **AWX pods stuck in Pending**: insufficient resources or PVC issue. Check: ```bash incus exec oc-node-02:awx -- kubectl -n awx describe pod incus exec oc-node-02:awx -- kubectl -n awx get pvc ``` **Job fails with recursive template error**: self-referencing var definition. Check playbook vars blocks for patterns like `var: "{{ var | default('') }}"`. **Job fails with "No such file or directory"**: cert/key not at the correct path. Remember: EE uses `/runner/project/`, not `/var/lib/awx/projects/`. **Job fails with "Connection failure"**: Incus API not reachable from AWX pod, or client cert not trusted by cluster. Test from inside the pod: ```bash incus exec oc-node-02:awx -- kubectl -n awx exec -c awx-ee -- \ curl -sk --cert /runner/project/incus-client.crt \ --key /runner/project/incus-client.key \ https://192.168.102.140:8443/1.0 ``` **Aether deploy rolls back immediately**: AWX job failed. Check the AWX job output for the specific error. Common causes: template misconfiguration, missing credential attachment, playbook syntax errors. ### Useful commands ```bash # AWX version curl -sk http://192.168.102.161:30080/api/v2/ping/ # List job templates curl -sk http://192.168.102.161:30080/api/v2/job_templates/ \ -H "Authorization: Bearer $AWX_TOKEN" \ | python3 -c "import sys,json; [print(f'{t[\"id\"]:3d} {t[\"name\"]}') \ for t in json.load(sys.stdin)['results']]" # List recent jobs curl -sk "http://192.168.102.161:30080/api/v2/jobs/?order_by=-id&page_size=5" \ -H "Authorization: Bearer $AWX_TOKEN" \ | python3 -c "import sys,json; [print(f'{j[\"id\"]:4d} {j[\"status\"]:12s} {j[\"name\"]}') \ for j in json.load(sys.stdin)['results']]" # Manually trigger post-deploy curl -sk -X POST http://192.168.102.161:30080/api/v2/job_templates/10/launch/ \ -H "Authorization: Bearer $AWX_TOKEN" \ -H "Content-Type: application/json" \ -d '{"extra_vars": { "ffsdn_instance_name": "test-ct", "ffsdn_instance_ip": "10.207.217.99", "ffsdn_cluster_name": "oc-lab-cluster", "ffsdn_deployed_by": "admin", "ffsdn_image_os": "Debian", "ffsdn_image_release": "bookworm" }}' ``` ### Rollback If AWX is broken beyond repair: ```bash incus delete oc-node-02:awx --force # Then redeploy from Step 1 ``` The AWX VM is a standalone workload with no cluster dependencies. Destroying and redeploying has zero impact on the Incus cluster or other workloads.