Validate AWX guide against real infrastructure, fix all bugs

End-to-end tested: Aether deploy triggers AWX post-deploy job,
container gets configured (hostname, packages, timezone, deploy-info),
decommission job runs on delete. All playbooks validated working.

Key fixes from testing:
- Playbooks: use Incus REST API (not SSH) — bridge subnet not routable
  from management VLAN, nftables blocks inbound forwarding
- Aether vars: ffsdn_* prefix (not vm_name/vm_ip/environment/owner)
- AWX EE paths: /runner/project/ (not /var/lib/awx/projects/)
- AWX access: NodePort 30080 (not Traefik ingress — 404 for IP access)
- deploy-awx: systemd-networkd (not netplan), manual project (not git
  SCM — EE can't reach private repo), Incus cert push, prereq install
- Remove unused base-config role (tasks inlined as setup script)
- Simplify ansible.cfg (no SSH settings needed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maarten 2026-02-24 01:53:54 +01:00
parent 6a74925fc4
commit 0b82b9ce3b
8 changed files with 837 additions and 633 deletions

View File

@ -15,12 +15,9 @@ incu-contrib/
├── .gitignore ├── .gitignore
├── ansible/ # Ansible playbooks for AWX/Aether lifecycle hooks ├── ansible/ # Ansible playbooks for AWX/Aether lifecycle hooks
│ ├── ansible.cfg # Project-level Ansible config │ ├── ansible.cfg # Project-level Ansible config
│ ├── playbooks/ │ └── playbooks/
│ │ ├── post-deploy.yml # Runs after Aether creates an instance │ ├── post-deploy.yml # Runs after Aether creates an instance (Incus API)
│ │ └── decommission.yml # Runs before Aether deletes an instance │ └── decommission.yml # Runs before Aether deletes an instance
│ └── roles/
│ └── base-config/ # Base OS configuration role
│ └── tasks/main.yml
├── incusos/ # IncusOS installation tooling ├── incusos/ # IncusOS installation tooling
│ ├── README.md # Detailed usage docs │ ├── README.md # Detailed usage docs
│ ├── incusos-iso # ISO/IMG builder (wraps flasher-tool) │ ├── incusos-iso # ISO/IMG builder (wraps flasher-tool)
@ -826,22 +823,40 @@ sshpass -p "$PROXMOX_ROOT_PASSWORD" ssh -o StrictHostKeyChecking=no \
Tower). Deployed as a Debian 12 VM running K3s + AWX Operator on the cluster. Tower). Deployed as a Debian 12 VM running K3s + AWX Operator on the cluster.
- **Lab VM**: `awx` on oc-node-02, IP 192.168.102.161/22 (VLAN 69, adjacent - **Lab VM**: `awx` on oc-node-02, IP 192.168.102.161/22 (VLAN 69, adjacent
to Aether at .160). 4 vCPU, 8 GiB RAM, 40 GiB disk. to Aether at .160). 4 vCPU, 8 GiB RAM, 40 GiB disk.
- **AWX URL**: `http://192.168.102.161:30080` — exposed via K3s NodePort.
Traefik ingress returns 404 for IP-based access; use NodePort directly.
- **`deploy-awx`** script manages the full lifecycle: `--deploy`, `--status`, - **`deploy-awx`** script manages the full lifecycle: `--deploy`, `--status`,
`--heal`, `--configure`, `--join-aether`, `--cleanup`, `--doctor`. `--heal`, `--configure`, `--join-aether`, `--cleanup`, `--doctor`.
- **K8s manifests** in `incusos/awx-manifests/` (operator + AWX CR via kustomize). - **K8s manifests** in `incusos/awx-manifests/` (operator + AWX CR via kustomize).
- **Ansible playbooks** in `ansible/` directory: - **Ansible playbooks** in `ansible/` directory:
- `playbooks/post-deploy.yml` -- runs after Aether creates an instance - `playbooks/post-deploy.yml` -- runs after Aether creates an instance
- `playbooks/decommission.yml` -- runs before Aether deletes an instance - `playbooks/decommission.yml` -- runs before Aether deletes an instance
- `roles/base-config/` -- hostname, packages, timezone, deploy metadata - **Aether extra vars use `ffsdn_` prefix**: Aether passes `ffsdn_instance_name`,
- **Aether integration**: AWX endpoint registered at `/awx-endpoints` with `ffsdn_instance_ip`, `ffsdn_cluster_id`, `ffsdn_cluster_name`,
a Personal Access Token. Per-cluster config binds post-deploy and `ffsdn_deployed_by`, `ffsdn_image_os`, `ffsdn_image_release`,
decommission template IDs. Aether calls `POST /api/v2/job_templates/{id}/launch/` `ffsdn_image_alias`. It does NOT pass `vm_name`, `vm_ip`, `environment`,
with extra vars (`vm_name`, `vm_ip`, `environment`, `owner`, `cost_center`). `owner`, or `cost_center` (the original plan assumed these).
- **Playbook pattern — Incus REST API, not SSH**: AWX cannot SSH to containers
on incusbr0 (bridge subnet not routable from management VLAN, IncusOS
nftables blocks inbound forwarding). Playbooks use the Incus REST API
(`uri` module with client cert) for file push + exec. The cert is at
`/runner/project/incus-client.crt` during job execution (AWX EE mounts
projects at `/runner/project/`, NOT `/var/lib/awx/projects/`).
- **Manual project (local_path)**: AWX EE containers cannot reach the private
git repo. Use `scm_type: ""` with `local_path: "incus-contrib"` and push
playbooks directly to the AWX task pod at
`/var/lib/awx/projects/incus-contrib/playbooks/`.
- **Lifecycle hooks**: post-deploy failure triggers auto-rollback (instance - **Lifecycle hooks**: post-deploy failure triggers auto-rollback (instance
deleted). Decommission failure does NOT block deletion. deleted). Decommission failure does NOT block deletion.
- **Playbook pattern**: Aether passes `vm_ip` → playbook uses `add_host` to - **Aether cluster AWX config API bug**: `PUT /api/clusters/{id}/awx-config`
create dynamic inventory → SSH to target for configuration. External system returns "Invalid cluster ID" for valid IDs. Workaround: direct PostgreSQL
tasks use `delegate_to: localhost`. UPDATE on the `clusters` table from within the Aether container.
- **Self-referencing vars cause infinite recursion in Ansible**: patterns like
`vm_ip: "{{ vm_ip | default('') }}"` cause `AnsibleUndefinedVariable`
recursive loop when the variable is not provided. Use `ffsdn_*` vars
directly without redefining them.
- **Ansible `environment` is a reserved keyword**: using it as an extra var
resolves to `[]` instead of the string value.
- See `notes/awx-guide.md` for full deployment guide and troubleshooting. - See `notes/awx-guide.md` for full deployment guide and troubleshooting.
## Git workflow ## Git workflow

View File

@ -31,13 +31,13 @@ See [`incusos/README.md`](incusos/README.md) for full documentation.
### [`ansible/`](ansible/) ### [`ansible/`](ansible/)
Ansible playbooks and roles for AWX/Aether lifecycle hooks: Ansible playbooks for AWX/Aether lifecycle hooks. Playbooks use the Incus REST
API (not SSH) for all instance operations — works regardless of network topology.
- **`playbooks/post-deploy.yml`** -- Runs after Aether creates an instance. - **`playbooks/post-deploy.yml`** -- Runs after Aether creates an instance.
Configures hostname, installs base packages, sets timezone, logs deployment. Configures hostname, installs base packages, sets timezone, logs deployment.
- **`playbooks/decommission.yml`** -- Runs before Aether deletes an instance. - **`playbooks/decommission.yml`** -- Runs before Aether deletes an instance.
Graceful service shutdown and decommission logging (best-effort). Graceful service shutdown and decommission logging (best-effort).
- **`roles/base-config/`** -- Base OS configuration role used by post-deploy.
### [`notes/`](notes/) ### [`notes/`](notes/)

View File

@ -2,10 +2,7 @@
# Inventory — AWX manages inventory; this is for local testing only # Inventory — AWX manages inventory; this is for local testing only
inventory = localhost, inventory = localhost,
# Roles path # Reduce overhead
roles_path = roles
# Reduce SSH overhead
forks = 10 forks = 10
timeout = 30 timeout = 30
@ -18,11 +15,7 @@ stdout_callback = yaml
# Host key checking — disabled for dynamic lab instances # Host key checking — disabled for dynamic lab instances
host_key_checking = False host_key_checking = False
[privilege_escalation] # Note: playbooks use the Incus REST API (uri module with client cert)
become = True # for all instance operations — no SSH connection to instances is needed.
become_method = sudo # The [ssh_connection] and [privilege_escalation] sections are therefore
# not required.
[ssh_connection]
# Speed up SSH connections
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=no

View File

@ -1,78 +1,77 @@
--- ---
# decommission.yml — Runs before Aether deletes an instance # decommission.yml — Runs before Aether deletes an instance
# #
# Aether passes the same extra vars as post-deploy: # Aether passes the same ffsdn_ extra vars as post-deploy:
# vm_name, vm_ip, environment, owner, cost_center # ffsdn_instance_name, ffsdn_instance_ip, ffsdn_cluster_id,
# ffsdn_cluster_name, ffsdn_deployed_by
# #
# This playbook runs best-effort: failures do NOT block instance # This playbook runs best-effort: failures do NOT block instance
# deletion in Aether. The playbook should be defensive and handle # deletion in Aether. Uses the Incus REST API for all operations
# unreachable hosts gracefully. # (no SSH needed).
- name: Decommission — prepare instance for deletion - name: Decommission — graceful shutdown and logging
hosts: localhost hosts: localhost
gather_facts: false gather_facts: false
connection: local connection: local
vars: vars:
vm_name: "{{ vm_name | default('unknown') }}" incus_api: "https://192.168.102.140:8443"
vm_ip: "{{ vm_ip | default('') }}" # AWX runner mounts project at /runner/project/ during job execution
environment_name: "{{ environment | default('lab') }}" incus_cert: "/runner/project/incus-client.crt"
owner: "{{ owner | default('unassigned') }}" incus_key: "/runner/project/incus-client.key"
cost_center: "{{ cost_center | default('') }}"
ssh_user: root
tasks: tasks:
- name: Add target host to in-memory inventory - name: Check if instance still exists
ansible.builtin.add_host: ansible.builtin.uri:
name: "{{ vm_ip }}" url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}"
groups: target method: GET
ansible_user: "{{ ssh_user }}" client_cert: "{{ incus_cert }}"
ansible_host: "{{ vm_ip }}" client_key: "{{ incus_key }}"
when: vm_ip | length > 0 validate_certs: false
return_content: true
- name: Attempt graceful shutdown on target status_code: [200, 404]
hosts: target register: instance_check
gather_facts: false when: ffsdn_instance_name is defined
ignore_unreachable: true
tasks:
- name: Check if host is reachable
ansible.builtin.wait_for_connection:
delay: 0
timeout: 15
register: host_check
ignore_errors: true
- name: Stop application services (best-effort) - name: Stop application services (best-effort)
ansible.builtin.shell: | ansible.builtin.uri:
# Stop user-facing services before deletion 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"
- "-c"
- |
systemctl list-units --type=service --state=running --no-legend \ systemctl list-units --type=service --state=running --no-legend \
| awk '{print $1}' \ | awk '{print $1}' \
| grep -vE '^(ssh|systemd|dbus|cron|networking|rsyslog)' \ | grep -vE '^(ssh|systemd|dbus|cron|networking|rsyslog)' \
| head -20 \ | head -20 \
| xargs -r -n1 systemctl stop | xargs -r -n1 systemctl stop 2>/dev/null || true
register: service_stop journalctl --flush 2>/dev/null || true
record-output: false
interactive: false
wait-for-websocket: false
status_code: [202]
ignore_errors: true ignore_errors: true
when: host_check is succeeded when:
- instance_check is defined
- instance_check.status == 200
- instance_check.json.metadata.status is defined
- instance_check.json.metadata.status == "Running"
- name: Flush logs to persistent storage
ansible.builtin.command: journalctl --flush
ignore_errors: true
when: host_check is succeeded
- name: Log decommission
hosts: localhost
gather_facts: false
connection: local
tasks:
- name: Record decommission in ledger - name: Record decommission in ledger
ansible.builtin.lineinfile: ansible.builtin.lineinfile:
path: /var/log/awx-deploy-ledger.log path: /tmp/awx-deploy-ledger.log
line: >- line: >-
{{ ansible_date_time.iso8601 | default(lookup('pipe', 'date -Iseconds')) }} {{ lookup('pipe', 'date -Iseconds') }}
DECOMMISSION vm_name={{ vm_name }} vm_ip={{ vm_ip }} DECOMMISSION instance={{ ffsdn_instance_name | default('unknown') }}
environment={{ environment_name }} owner={{ owner }} ip={{ ffsdn_instance_ip | default('') }}
cost_center={{ cost_center }} status=completed cluster={{ ffsdn_cluster_name | default('unknown') }}
deployed_by={{ ffsdn_deployed_by | default('unknown') }}
status=completed
create: true create: true
mode: "0644" mode: "0644"

View File

@ -1,88 +1,200 @@
--- ---
# post-deploy.yml — Runs after Aether creates an instance # post-deploy.yml — Runs after Aether creates an instance
# #
# Aether passes extra vars: # Aether passes these extra vars (ffsdn_ prefix):
# vm_name — instance hostname # ffsdn_instance_name — instance name
# vm_ip — IP address assigned by Aether # ffsdn_instance_ip — IP address assigned by Aether
# environment — deployment environment (dev, staging, prod) # ffsdn_cluster_id — numeric cluster ID
# owner — VM owner or team # ffsdn_cluster_name — cluster display name
# cost_center — billing/cost allocation # ffsdn_deployed_by — Aether username who triggered the deploy
# (plus any custom tags from the blueprint) # ffsdn_image_os — image OS (e.g., "Debian")
# ffsdn_image_release — image release (e.g., "bookworm")
# ffsdn_image_alias — image alias (may be empty)
# #
# AWX job template must have "ask_variables_on_launch: true" so Aether # This playbook configures new instances via the Incus REST API
# can inject these variables at runtime. # (file push + exec), which works regardless of network topology.
# AWX does NOT need SSH access to the container — it talks to the
# Incus cluster API at the node level.
#
# Requirements:
# - Incus client cert/key at /var/lib/awx/projects/incus-contrib/
# - Cluster API reachable from AWX (e.g., https://192.168.102.140:8443)
- name: Post-deploy — configure newly provisioned instance - name: Post-deploy — configure instance via Incus API
hosts: localhost hosts: localhost
gather_facts: false gather_facts: false
connection: local connection: local
vars: vars:
# Defaults (overridden by Aether extra vars) # Incus cluster API (any cluster member works)
vm_name: "{{ vm_name | default('unknown') }}" incus_api: "https://192.168.102.140:8443"
vm_ip: "{{ vm_ip | default('') }}" # AWX runner mounts project at /runner/project/ during job execution
environment_name: "{{ environment | default('lab') }}" incus_cert: "/runner/project/incus-client.crt"
owner: "{{ owner | default('unassigned') }}" incus_key: "/runner/project/incus-client.key"
cost_center: "{{ cost_center | default('') }}" base_packages: "curl vim htop jq tmux rsync unattended-upgrades"
ssh_user: root
base_packages:
- curl
- vim
- htop
- jq
- tmux
- rsync
- unattended-upgrades
pre_tasks: tasks:
- name: Validate required variables - name: Validate required variables
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- vm_ip | length > 0 - ffsdn_instance_name is defined
- vm_name | length > 0 - ffsdn_instance_name | length > 0
fail_msg: "vm_ip and vm_name must be provided by Aether" fail_msg: "ffsdn_instance_name must be provided by Aether"
- name: Add target host to in-memory inventory - name: Wait for instance to be running (up to 60s)
ansible.builtin.add_host: ansible.builtin.uri:
name: "{{ vm_ip }}" url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/state"
groups: target method: GET
ansible_user: "{{ ssh_user }}" client_cert: "{{ incus_cert }}"
ansible_host: "{{ vm_ip }}" client_key: "{{ incus_key }}"
validate_certs: false
- name: Wait for instance and apply base configuration return_content: true
hosts: target register: instance_state
gather_facts: false until: instance_state.json.metadata.status == "Running"
retries: 12
pre_tasks:
- name: Wait for SSH connectivity (up to 5 minutes)
ansible.builtin.wait_for_connection:
delay: 5 delay: 5
timeout: 300
- name: Gather facts after connection - name: Generate setup script
ansible.builtin.setup: ansible.builtin.set_fact:
setup_script: |
#!/bin/bash
set -e
roles: echo "=== Post-deploy configuration starting ==="
- role: base-config
vars:
hostname: "{{ hostvars['localhost']['vm_name'] }}"
deploy_environment: "{{ hostvars['localhost']['environment_name'] }}"
deploy_owner: "{{ hostvars['localhost']['owner'] }}"
packages: "{{ hostvars['localhost']['base_packages'] }}"
- name: Log deployment # Set hostname
hosts: localhost hostname {{ ffsdn_instance_name }}
gather_facts: false echo "{{ ffsdn_instance_name }}" > /etc/hostname
connection: local sed -i '/^127\.0\.1\.1/d' /etc/hosts
echo "127.0.1.1 {{ ffsdn_instance_name }}" >> /etc/hosts
echo "[OK] Hostname set to {{ ffsdn_instance_name }}"
# Update package cache and install base packages
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq {{ base_packages }} 2>&1 | tail -1
echo "[OK] Base packages installed"
# Set timezone
if command -v timedatectl >/dev/null 2>&1; then
timedatectl set-timezone UTC
else
ln -sf /usr/share/zoneinfo/UTC /etc/localtime
echo "UTC" > /etc/timezone
fi
echo "[OK] Timezone set to UTC"
# Configure unattended upgrades
cat > /etc/apt/apt.conf.d/20auto-upgrades << 'AUTOUPGRADE'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
AUTOUPGRADE
echo "[OK] Unattended upgrades configured"
# Write deployment metadata
cat > /etc/deploy-info << DEPLOYINFO
hostname={{ ffsdn_instance_name }}
ip={{ ffsdn_instance_ip | default('unknown') }}
cluster={{ ffsdn_cluster_name | default('unknown') }}
deployed_by={{ ffsdn_deployed_by | default('unknown') }}
image={{ ffsdn_image_os | default('') }}/{{ ffsdn_image_release | default('') }}
deployed=$(date -Iseconds)
DEPLOYINFO
echo "[OK] Deployment metadata written to /etc/deploy-info"
echo "=== Post-deploy configuration complete ==="
- name: Push setup script to instance
ansible.builtin.uri:
url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/files?path=/tmp/post-deploy-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"
status_code: [200]
- name: Execute setup script via Incus API
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/post-deploy-setup.sh"]
record-output: true
interactive: false
wait-for-websocket: false
environment:
DEBIAN_FRONTEND: "noninteractive"
status_code: [202]
return_content: true
register: exec_result
- name: Wait for setup script to complete (up to 5 minutes)
ansible.builtin.uri:
url: "{{ incus_api }}{{ exec_result.json.operation }}/wait?timeout=300"
method: GET
client_cert: "{{ incus_cert }}"
client_key: "{{ incus_key }}"
validate_certs: false
return_content: true
register: exec_wait
failed_when: exec_wait.json.metadata.status != "Success"
- name: Verify exit code
ansible.builtin.assert:
that:
- exec_wait.json.metadata.metadata.return | int == 0
fail_msg: "Setup script exited with code {{ exec_wait.json.metadata.metadata.return }}"
success_msg: "Setup completed successfully (exit code 0)"
- name: Verify deployment metadata was written
ansible.builtin.uri:
url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/files?path=/etc/deploy-info"
method: GET
client_cert: "{{ incus_cert }}"
client_key: "{{ incus_key }}"
validate_certs: false
return_content: true
register: deploy_info
- name: Display deployment metadata
ansible.builtin.debug:
msg: "{{ deploy_info.content }}"
- name: Clean up 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/rm", "-f", "/tmp/post-deploy-setup.sh"]
record-output: false
interactive: false
wait-for-websocket: false
status_code: [202]
ignore_errors: true
tasks:
- name: Record deployment in ledger - name: Record deployment in ledger
ansible.builtin.lineinfile: ansible.builtin.lineinfile:
path: /var/log/awx-deploy-ledger.log path: /tmp/awx-deploy-ledger.log
line: >- line: >-
{{ ansible_date_time.iso8601 | default(lookup('pipe', 'date -Iseconds')) }} {{ lookup('pipe', 'date -Iseconds') }}
DEPLOY vm_name={{ vm_name }} vm_ip={{ vm_ip }} DEPLOY instance={{ ffsdn_instance_name | default('unknown') }}
environment={{ environment_name }} owner={{ owner }} ip={{ ffsdn_instance_ip | default('') }}
cost_center={{ cost_center }} status=success cluster={{ ffsdn_cluster_name | default('unknown') }}
deployed_by={{ ffsdn_deployed_by | default('unknown') }}
image={{ ffsdn_image_os | default('') }}/{{ ffsdn_image_release | default('') }}
status=success
create: true create: true
mode: "0644" mode: "0644"

View File

@ -1,53 +0,0 @@
---
# base-config — Minimal post-deploy configuration for new instances
#
# Variables (passed from post-deploy.yml):
# hostname — desired hostname
# deploy_environment — dev/staging/prod
# deploy_owner — owner tag
# packages — list of packages to install
- name: Set hostname
ansible.builtin.hostname:
name: "{{ hostname }}"
- name: Add hostname to /etc/hosts
ansible.builtin.lineinfile:
path: /etc/hosts
regexp: '^127\.0\.1\.1'
line: "127.0.1.1 {{ hostname }}"
- name: Update apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
when: ansible_os_family == "Debian"
- name: Install base packages
ansible.builtin.apt:
name: "{{ packages }}"
state: present
when: ansible_os_family == "Debian"
- name: Set timezone to UTC
community.general.timezone:
name: UTC
- name: Enable unattended security upgrades
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/20auto-upgrades
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
mode: "0644"
when: ansible_os_family == "Debian"
- name: Add deployment metadata to /etc/deploy-info
ansible.builtin.copy:
dest: /etc/deploy-info
content: |
hostname={{ hostname }}
environment={{ deploy_environment }}
owner={{ deploy_owner }}
deployed={{ ansible_date_time.iso8601 }}
mode: "0644"

View File

@ -212,10 +212,16 @@ vm_exec() {
incus exec "$(vm_ref)" -- "$@" incus exec "$(vm_ref)" -- "$@"
} }
awx_url() {
# AWX is exposed via K3s NodePort on port 30080 (HTTP)
# Traefik ingress returns 404 for IP-based access
echo "http://${AWX_IP}:30080"
}
awx_api() { awx_api() {
local method="$1" path="$2" local method="$1" path="$2"
shift 2 shift 2
curl -sk -X "$method" "https://${AWX_IP}${path}" \ curl -sk -X "$method" "$(awx_url)${path}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"$@" "$@"
} }
@ -225,7 +231,7 @@ awx_api_auth() {
shift 2 shift 2
local token local token
token=$(get_awx_admin_password) token=$(get_awx_admin_password)
curl -sk -X "$method" "https://${AWX_IP}${path}" \ curl -sk -X "$method" "$(awx_url)${path}" \
--user "admin:${token}" \ --user "admin:${token}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"$@" "$@"
@ -255,7 +261,7 @@ wait_for_awx_web() {
local timeout="${1:-600}" local timeout="${1:-600}"
local elapsed=0 local elapsed=0
info "Waiting for AWX web interface (up to ${timeout}s)..." info "Waiting for AWX web interface (up to ${timeout}s)..."
while ! curl -sk -o /dev/null -w '%{http_code}' "https://${AWX_IP}/api/v2/ping/" 2>/dev/null | grep -q '200'; do while ! curl -sk -o /dev/null -w '%{http_code}' "$(awx_url)/api/v2/ping/" 2>/dev/null | grep -q '200'; do
sleep 10 sleep 10
elapsed=$((elapsed + 10)) elapsed=$((elapsed + 10))
if [[ $elapsed -ge $timeout ]]; then if [[ $elapsed -ge $timeout ]]; then
@ -390,8 +396,8 @@ action_deploy() {
echo echo
success "AWX deployment complete!" success "AWX deployment complete!"
info " Web UI: https://${AWX_IP}/" info " Web UI: $(awx_url)/"
info " API: https://${AWX_IP}/api/v2/ping/" info " API: $(awx_url)/api/v2/ping/"
local pw local pw
pw=$(get_awx_admin_password 2>/dev/null || echo "<retrieve with --status>") pw=$(get_awx_admin_password 2>/dev/null || echo "<retrieve with --status>")
info " Admin: admin / ${pw}" info " Admin: admin / ${pw}"
@ -436,23 +442,28 @@ phase_configure_network() {
return 0 return 0
fi fi
# Debian 12 images use systemd-networkd (not netplan)
# The macvlan NIC appears as enp5s0 in Incus VMs
vm_exec bash -c " vm_exec bash -c "
hostnamectl set-hostname '${VM_NAME}' hostnamectl set-hostname '${VM_NAME}'
cat > /etc/netplan/50-static.yaml << 'NETPLAN' # Disable any existing DHCP config
network: rm -f /etc/network/interfaces.d/* 2>/dev/null || true
version: 2
ethernets: # Configure static IP via systemd-networkd
enp5s0: mkdir -p /etc/systemd/network
addresses: [${AWX_IP}/${AWX_PREFIX}] cat > /etc/systemd/network/10-static.network << 'NETWORKD'
routes: [Match]
- to: default Name=enp5s0
via: ${AWX_GATEWAY}
nameservers: [Network]
addresses: [${AWX_DNS}] Address=${AWX_IP}/${AWX_PREFIX}
NETPLAN Gateway=${AWX_GATEWAY}
chmod 600 /etc/netplan/50-static.yaml DNS=${AWX_DNS}
netplan apply NETWORKD
systemctl enable systemd-networkd
systemctl restart systemd-networkd
" "
success "Static IP ${AWX_IP}/${AWX_PREFIX} configured" success "Static IP ${AWX_IP}/${AWX_PREFIX} configured"
@ -474,6 +485,14 @@ phase_install_k3s() {
return 0 return 0
fi fi
# Ensure curl is available (minimal Debian 12 may not have it)
info "Installing prerequisites..."
vm_exec bash -c "
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq curl python3 2>&1 | tail -1
"
vm_exec bash -c " vm_exec bash -c "
curl -sfL https://get.k3s.io | sh -s - --write-kubeconfig-mode 644 curl -sfL https://get.k3s.io | sh -s - --write-kubeconfig-mode 644
" "
@ -572,9 +591,9 @@ phase_verify() {
admin_pw=$(get_awx_admin_password) admin_pw=$(get_awx_admin_password)
success "Admin password retrieved" success "Admin password retrieved"
# Test API ping # Test API ping (NodePort 30080)
local ping_result local ping_result
ping_result=$(curl -sk "https://${AWX_IP}/api/v2/ping/" 2>/dev/null) ping_result=$(curl -sk "$(awx_url)/api/v2/ping/" 2>/dev/null)
if echo "$ping_result" | python3 -c "import sys,json; json.load(sys.stdin)" &>/dev/null; then if echo "$ping_result" | python3 -c "import sys,json; json.load(sys.stdin)" &>/dev/null; then
success "AWX API responding" success "AWX API responding"
else else
@ -629,11 +648,11 @@ action_status() {
warn "Pods: ${running}/${total} running" warn "Pods: ${running}/${total} running"
fi fi
# AWX web # AWX web (NodePort 30080)
local http_code local http_code
http_code=$(curl -sk -o /dev/null -w '%{http_code}' "https://${AWX_IP}/api/v2/ping/" 2>/dev/null || echo "000") http_code=$(curl -sk -o /dev/null -w '%{http_code}' "$(awx_url)/api/v2/ping/" 2>/dev/null || echo "000")
if [[ "$http_code" == "200" ]]; then if [[ "$http_code" == "200" ]]; then
success "AWX API: responding (HTTP ${http_code})" success "AWX API: responding at $(awx_url) (HTTP ${http_code})"
else else
error "AWX API: not responding (HTTP ${http_code})" error "AWX API: not responding (HTTP ${http_code})"
fi fi
@ -649,7 +668,7 @@ action_status() {
# AWX version # AWX version
local version local version
version=$(curl -sk "https://${AWX_IP}/api/v2/ping/" 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('version','unknown'))" 2>/dev/null || echo "unknown") version=$(curl -sk "$(awx_url)/api/v2/ping/" 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('version','unknown'))" 2>/dev/null || echo "unknown")
info "AWX version: ${version}" info "AWX version: ${version}"
# Aether connectivity # Aether connectivity
@ -707,10 +726,10 @@ action_heal() {
success "All pods healthy" success "All pods healthy"
fi fi
# 3. AWX web responsiveness # 3. AWX web responsiveness (NodePort 30080)
info "Checking AWX web..." info "Checking AWX web..."
local http_code local http_code
http_code=$(curl -sk -o /dev/null -w '%{http_code}' "https://${AWX_IP}/api/v2/ping/" 2>/dev/null || echo "000") http_code=$(curl -sk -o /dev/null -w '%{http_code}' "$(awx_url)/api/v2/ping/" 2>/dev/null || echo "000")
if [[ "$http_code" == "200" ]]; then if [[ "$http_code" == "200" ]]; then
success "AWX web: responding" success "AWX web: responding"
else else
@ -763,98 +782,85 @@ action_configure() {
awx_auth() { awx_auth() {
local method="$1" path="$2" local method="$1" path="$2"
shift 2 shift 2
curl -sk -X "$method" "https://${AWX_IP}${path}" \ curl -sk -X "$method" "$(awx_url)${path}" \
--user "admin:${admin_pw}" \ --user "admin:${admin_pw}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"$@" "$@"
} }
# 1. Create SCM credential for private Git repo # 1. Push Incus client cert/key to AWX project directory
step "Creating SCM credential" # Playbooks use Incus REST API (not SSH) — cert is required.
local ssh_key="" # AWX EE mounts project dir at /runner/project/ during job execution.
local ssh_key_path="${HOME}/.ssh/id_ed25519" step "Pushing Incus client certificate to AWX"
[[ ! -f "$ssh_key_path" ]] && ssh_key_path="${HOME}/.ssh/id_rsa" local incus_cert="${HOME}/.config/incus/client.crt"
if [[ -f "$ssh_key_path" ]]; then local incus_key="${HOME}/.config/incus/client.key"
ssh_key=$(cat "$ssh_key_path") if [[ -f "$incus_cert" && -f "$incus_key" ]]; then
info "Using SSH key: ${ssh_key_path}" local task_pod
task_pod=$(vm_exec kubectl -n "$AWX_NAMESPACE" get pods \
-l app.kubernetes.io/component=awx-task -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)
if [[ -n "$task_pod" ]]; then
local project_dir="/var/lib/awx/projects/incus-contrib"
vm_exec kubectl -n "$AWX_NAMESPACE" exec "$task_pod" -- mkdir -p "$project_dir"
# Push cert via base64 to avoid file push permission issues
local cert_b64 key_b64
cert_b64=$(base64 -w0 "$incus_cert")
key_b64=$(base64 -w0 "$incus_key")
vm_exec kubectl -n "$AWX_NAMESPACE" exec "$task_pod" -- \
bash -c "echo '${cert_b64}' | base64 -d > ${project_dir}/incus-client.crt"
vm_exec kubectl -n "$AWX_NAMESPACE" exec "$task_pod" -- \
bash -c "echo '${key_b64}' | base64 -d > ${project_dir}/incus-client.key"
vm_exec kubectl -n "$AWX_NAMESPACE" exec "$task_pod" -- \
chmod 600 "${project_dir}/incus-client.key"
success "Incus client cert/key pushed to AWX task pod"
else else
warn "No SSH key found at ~/.ssh/id_ed25519 or ~/.ssh/id_rsa" warn "Cannot find AWX task pod — push cert manually later"
warn "SCM credential will be created without a key — update manually in AWX" fi
else
warn "Incus client cert not found at ${incus_cert}"
warn "Playbooks need the cert to talk to Incus API — push it manually"
fi fi
local scm_cred_id # 2. Create manual project (local_path)
scm_cred_id=$(awx_auth GET "/api/v2/credentials/?name=incus-contrib-scm" 2>/dev/null \ # AWX EE containers cannot reach the private git repo, so we use a
| python3 -c "import sys,json; r=json.load(sys.stdin); print(r['results'][0]['id'] if r.get('count',0)>0 else '')" 2>/dev/null || true) # manual project with files pushed directly to the task pod.
if [[ -z "$scm_cred_id" ]]; then
if [[ -n "$ssh_key" ]]; then
scm_cred_id=$(awx_auth POST "/api/v2/credentials/" \
-d "$(python3 -c "
import json, sys
print(json.dumps({
'name': 'incus-contrib-scm',
'organization': 1,
'credential_type': 2,
'inputs': {'ssh_key_data': sys.stdin.read()}
}))" <<< "$ssh_key")" 2>/dev/null \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
else
scm_cred_id=$(awx_auth POST "/api/v2/credentials/" \
-d '{"name":"incus-contrib-scm","organization":1,"credential_type":2,"inputs":{}}' 2>/dev/null \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
fi
[[ -n "$scm_cred_id" ]] && success "SCM credential created (ID: ${scm_cred_id})" || warn "Failed to create SCM credential"
else
success "SCM credential exists (ID: ${scm_cred_id})"
fi
# 2. Create project
step "Creating project" step "Creating project"
local project_id local project_id
project_id=$(awx_auth GET "/api/v2/projects/?name=incus-contrib" 2>/dev/null \ project_id=$(awx_auth GET "/api/v2/projects/?name=incus-contrib" 2>/dev/null \
| python3 -c "import sys,json; r=json.load(sys.stdin); print(r['results'][0]['id'] if r.get('count',0)>0 else '')" 2>/dev/null || true) | python3 -c "import sys,json; r=json.load(sys.stdin); print(r['results'][0]['id'] if r.get('count',0)>0 else '')" 2>/dev/null || true)
if [[ -z "$project_id" ]]; then if [[ -z "$project_id" ]]; then
local project_payload project_id=$(awx_auth POST "/api/v2/projects/" \
project_payload=$(python3 -c " -d '{"name":"incus-contrib","organization":1,"scm_type":"","local_path":"incus-contrib"}' 2>/dev/null \
import json
d = {
'name': 'incus-contrib',
'organization': 1,
'scm_type': 'git',
'scm_url': '${GIT_REPO}',
'scm_branch': '${GIT_BRANCH}',
'scm_update_on_launch': True
}
print(json.dumps(d))")
[[ -n "$scm_cred_id" ]] && project_payload=$(echo "$project_payload" | python3 -c "
import sys,json; d=json.load(sys.stdin); d['credential']=${scm_cred_id}; print(json.dumps(d))")
project_id=$(awx_auth POST "/api/v2/projects/" -d "$project_payload" 2>/dev/null \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
[[ -n "$project_id" ]] && success "Project created (ID: ${project_id})" || warn "Failed to create project" [[ -n "$project_id" ]] && success "Project created (ID: ${project_id}, local_path)" || warn "Failed to create project"
else else
success "Project exists (ID: ${project_id})" success "Project exists (ID: ${project_id})"
fi fi
# Wait for initial project sync # Push playbooks to AWX task pod project directory
if [[ -n "$project_id" ]]; then if [[ -n "$project_id" ]]; then
info "Waiting for project sync..." local task_pod
local sync_elapsed=0 task_pod=$(vm_exec kubectl -n "$AWX_NAMESPACE" get pods \
while [[ $sync_elapsed -lt 120 ]]; do -l app.kubernetes.io/component=awx-task -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)
local status if [[ -n "$task_pod" ]]; then
status=$(awx_auth GET "/api/v2/projects/${project_id}/" 2>/dev/null \ local project_dir="/var/lib/awx/projects/incus-contrib"
| python3 -c "import sys,json; print(json.load(sys.stdin).get('status',''))" 2>/dev/null || true) vm_exec kubectl -n "$AWX_NAMESPACE" exec "$task_pod" -- mkdir -p "${project_dir}/playbooks"
if [[ "$status" == "successful" ]]; then for playbook in post-deploy.yml decommission.yml; do
success "Project synced" local pb_path="${SCRIPT_DIR}/../ansible/playbooks/${playbook}"
break if [[ -f "$pb_path" ]]; then
elif [[ "$status" == "failed" || "$status" == "error" ]]; then local pb_b64
warn "Project sync failed — check SCM credential and repo URL" pb_b64=$(base64 -w0 "$pb_path")
break vm_exec kubectl -n "$AWX_NAMESPACE" exec "$task_pod" -- \
bash -c "echo '${pb_b64}' | base64 -d > ${project_dir}/playbooks/${playbook}"
detail "Pushed ${playbook}"
else
warn "Playbook not found: ${pb_path}"
fi fi
sleep 5
sync_elapsed=$((sync_elapsed + 5))
done done
success "Playbooks pushed to AWX project directory"
else
warn "Cannot find AWX task pod — push playbooks manually"
fi
fi fi
# 3. Create inventory # 3. Create inventory
@ -872,26 +878,16 @@ import sys,json; d=json.load(sys.stdin); d['credential']=${scm_cred_id}; print(j
success "Inventory exists (ID: ${inventory_id})" success "Inventory exists (ID: ${inventory_id})"
fi fi
# 4. Create machine credential (SSH to instances) # 4. Create machine credential (required by AWX templates, though
# playbooks use Incus API not SSH)
step "Creating machine credential" step "Creating machine credential"
local machine_cred_id local machine_cred_id
machine_cred_id=$(awx_auth GET "/api/v2/credentials/?name=incus-instances" 2>/dev/null \ machine_cred_id=$(awx_auth GET "/api/v2/credentials/?name=incus-instances" 2>/dev/null \
| python3 -c "import sys,json; r=json.load(sys.stdin); print(r['results'][0]['id'] if r.get('count',0)>0 else '')" 2>/dev/null || true) | python3 -c "import sys,json; r=json.load(sys.stdin); print(r['results'][0]['id'] if r.get('count',0)>0 else '')" 2>/dev/null || true)
if [[ -z "$machine_cred_id" ]]; then if [[ -z "$machine_cred_id" ]]; then
local machine_cred_payload='{"name":"incus-instances","organization":1,"credential_type":1,"inputs":{"username":"root"}}' machine_cred_id=$(awx_auth POST "/api/v2/credentials/" \
if [[ -n "$ssh_key" ]]; then -d '{"name":"incus-instances","organization":1,"credential_type":1,"inputs":{"username":"root"}}' 2>/dev/null \
machine_cred_payload=$(python3 -c "
import json, sys
key = sys.stdin.read()
print(json.dumps({
'name': 'incus-instances',
'organization': 1,
'credential_type': 1,
'inputs': {'username': 'root', 'ssh_key_data': key}
}))" <<< "$ssh_key")
fi
machine_cred_id=$(awx_auth POST "/api/v2/credentials/" -d "$machine_cred_payload" 2>/dev/null \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
[[ -n "$machine_cred_id" ]] && success "Machine credential created (ID: ${machine_cred_id})" || warn "Failed to create machine credential" [[ -n "$machine_cred_id" ]] && success "Machine credential created (ID: ${machine_cred_id})" || warn "Failed to create machine credential"
else else
@ -971,7 +967,7 @@ action_join_aether() {
admin_pw=$(get_awx_admin_password) admin_pw=$(get_awx_admin_password)
local pat_response local pat_response
pat_response=$(curl -sk -X POST "https://${AWX_IP}/api/v2/users/1/personal_tokens/" \ pat_response=$(curl -sk -X POST "$(awx_url)/api/v2/users/1/personal_tokens/" \
--user "admin:${admin_pw}" \ --user "admin:${admin_pw}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"description": "Aether integration", "scope": "write"}' 2>/dev/null) -d '{"description": "Aether integration", "scope": "write"}' 2>/dev/null)
@ -988,10 +984,10 @@ action_join_aether() {
# 2. Get template IDs # 2. Get template IDs
local post_deploy_id decommission_id local post_deploy_id decommission_id
post_deploy_id=$(curl -sk "https://${AWX_IP}/api/v2/job_templates/?name=post-deploy" \ post_deploy_id=$(curl -sk "$(awx_url)/api/v2/job_templates/?name=post-deploy" \
--user "admin:${admin_pw}" 2>/dev/null \ --user "admin:${admin_pw}" 2>/dev/null \
| python3 -c "import sys,json; r=json.load(sys.stdin); print(r['results'][0]['id'] if r.get('count',0)>0 else '')" 2>/dev/null || true) | python3 -c "import sys,json; r=json.load(sys.stdin); print(r['results'][0]['id'] if r.get('count',0)>0 else '')" 2>/dev/null || true)
decommission_id=$(curl -sk "https://${AWX_IP}/api/v2/job_templates/?name=decommission" \ decommission_id=$(curl -sk "$(awx_url)/api/v2/job_templates/?name=decommission" \
--user "admin:${admin_pw}" 2>/dev/null \ --user "admin:${admin_pw}" 2>/dev/null \
| python3 -c "import sys,json; r=json.load(sys.stdin); print(r['results'][0]['id'] if r.get('count',0)>0 else '')" 2>/dev/null || true) | python3 -c "import sys,json; r=json.load(sys.stdin); print(r['results'][0]['id'] if r.get('count',0)>0 else '')" 2>/dev/null || true)
@ -1005,7 +1001,7 @@ action_join_aether() {
info "2. Navigate to Settings → Ansible Automation (/awx-endpoints)" info "2. Navigate to Settings → Ansible Automation (/awx-endpoints)"
info "3. Add Endpoint:" info "3. Add Endpoint:"
info " Name: lab-awx" info " Name: lab-awx"
info " URL: https://${AWX_IP}" info " URL: $(awx_url)"
info " Token: ${awx_token}" info " Token: ${awx_token}"
info " Verify SSL: unchecked" info " Verify SSL: unchecked"
info "4. Configure cluster AWX binding:" info "4. Configure cluster AWX binding:"
@ -1023,7 +1019,7 @@ action_join_aether() {
detail " -H 'Referer: ${AETHER_URL}/' \\" detail " -H 'Referer: ${AETHER_URL}/' \\"
detail " -X POST ${AETHER_URL}/api/awx/endpoints \\" detail " -X POST ${AETHER_URL}/api/awx/endpoints \\"
detail " -H 'Content-Type: application/json' \\" detail " -H 'Content-Type: application/json' \\"
detail " -d '{\"name\":\"lab-awx\",\"url\":\"https://${AWX_IP}\",\"token\":\"${awx_token}\",\"verify_ssl\":false}'" detail " -d '{\"name\":\"lab-awx\",\"url\":\"http://${AWX_IP}:30080\",\"token\":\"${awx_token}\",\"verify_ssl\":false}'"
detail "" detail ""
detail "# Bind cluster (replace ENDPOINT_ID):" detail "# Bind cluster (replace ENDPOINT_ID):"
detail "curl -sSk -b cookies.txt -H 'X-CSRF-Token: \$CSRF' \\" detail "curl -sSk -b cookies.txt -H 'X-CSRF-Token: \$CSRF' \\"

File diff suppressed because it is too large Load Diff