# 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

**Automated path**: the `incusos/deploy-awx` script automates the entire
deployment and configuration process. Run `deploy-awx --deploy` for a full
install, `deploy-awx --configure` to set up project/templates, and
`deploy-awx --status` to check health. The manual steps below are for
reference, troubleshooting, and understanding what the script does.

### 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
incus exec oc-node-02:awx -- mkdir -p /opt/awx/base
incus file push incusos/awx-manifests/base/awx.yaml \
    oc-node-02:awx/opt/awx/base/awx.yaml
incus file push incusos/awx-manifests/base/kustomization.yaml \
    oc-node-02:awx/opt/awx/base/kustomization.yaml

# 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-migration-24.6.1-xxx              0/1     Completed
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
```

The migration pod runs once during initial deployment and shows `Completed`
status — this is expected.

#### 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

Both must have `ask_variables_on_launch: true` so Aether can pass `ffsdn_*`
variables at launch time.

**Look up resource IDs first** — they vary by installation:
```bash
# Find your project, inventory, and credential IDs
curl -sk http://192.168.102.161:30080/api/v2/projects/ \
    -H "Authorization: Bearer $AWX_TOKEN" \
    | python3 -c "import sys,json; [print(f'{p[\"id\"]:3d} {p[\"name\"]}') \
        for p in json.load(sys.stdin)['results']]"

curl -sk http://192.168.102.161:30080/api/v2/inventories/ \
    -H "Authorization: Bearer $AWX_TOKEN" \
    | python3 -c "import sys,json; [print(f'{i[\"id\"]:3d} {i[\"name\"]}') \
        for i in json.load(sys.stdin)['results']]"

curl -sk http://192.168.102.161:30080/api/v2/credentials/ \
    -H "Authorization: Bearer $AWX_TOKEN" \
    | python3 -c "import sys,json; [print(f'{c[\"id\"]:3d} {c[\"name\"]}') \
        for c in json.load(sys.stdin)['results']]"
```

Create templates using the IDs from above (example uses lab values):
```bash
# Replace PROJECT_ID, INVENTORY_ID with values from the lookups above
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": PROJECT_ID,
        "playbook": "playbooks/post-deploy.yml",
        "inventory": INVENTORY_ID,
        "ask_variables_on_launch": true
    }'
```

**Attach credentials** to templates (required even if playbook doesn't use SSH):
```bash
# Replace TEMPLATE_ID and CREDENTIAL_ID with actual values
curl -sk -X POST http://192.168.102.161:30080/api/v2/job_templates/TEMPLATE_ID/credentials/ \
    -H "Authorization: Bearer $AWX_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"id": CREDENTIAL_ID}'
```

In the lab, the current IDs are: project=9, inventory=2, credential=4,
post-deploy template=10, decommission template=11.

### 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

**Automated path**: `deploy-awx --join-aether` creates a PAT and prints
the Aether registration details (endpoint URL, token, template IDs).
Then complete registration via the Aether UI below.

### Step 1: Register AWX endpoint

1. Log into Aether at `https://192.168.102.160:8443`
2. Navigate to **Ansible Automation** (`/awx-endpoints`)
3. Add a new endpoint:
   - **Name**: `lab-awx`
   - **URL**: `http://192.168.102.161:30080`
   - **Token**: the AWX PAT (from the PAT creation step above, or
     from `deploy-awx --join-aether` output)
   - **Verify SSL**: unchecked (AWX is HTTP on NodePort)

After saving, Aether checks AWX health automatically. Verify at
`/api/health/awx`:
```json
{"awx_healthy":true,"awx_version":"24.6.1","awx_status":"ok"}
```

The API equivalent (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
    }'
```

### Step 2: Configure AWX template IDs

AWX template IDs can be configured at two levels:

**Per-blueprint** (recommended): In **Blueprint Design** (`/blueprintdesign`),
each blueprint has optional fields for **AWX Post-Deploy Template ID** and
**AWX Decommission Template ID**. Set these to the template IDs from the
"Job templates" section above.

**Per-cluster** (global default): In **Manage INCUS Clusters** (`/incus-infra`),
select the cluster → **Settings** tab. Configure the AWX endpoint and
default template IDs here. These apply to all deploys on this cluster
unless overridden by blueprint-level settings.

**Note**: The `PUT /api/clusters/{id}/awx-config` API endpoint was
non-functional in Aether v6.4.317 (returned "Invalid cluster ID").
Use the UI for cluster-level configuration.

### Verify integration

Deploy a test instance via Aether. If AWX is configured, Aether
automatically triggers the post-deploy job template after instance creation.

```bash
# Job history visible at: /deploy page → "Your Recent Ansible Automation Jobs"

# Check AWX health from Aether's perspective
curl -sSk -b /tmp/aether-cookies.txt \
    https://192.168.102.160:8443/api/health/awx
```

## 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. Use the Aether UI (cluster Settings
tab or per-blueprint configuration) instead.

### 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 <pod-name>

# View pod logs
incus exec oc-node-02:awx -- kubectl -n awx logs <pod-name>
```

### 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 <pod-name>
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 <task-pod> -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 (replace 10 with your template ID)
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.
