#!/usr/bin/env bash # deploy-awx - Deploy and manage AWX on an Incus cluster # # Deploys a Debian 12 VM with K3s + AWX Operator, configures AWX for # Aether integration (project, inventory, job templates), and provides # ongoing health checks and healing. # # Usage: deploy-awx [OPTIONS] # Run 'deploy-awx --help' for full usage information. set -euo pipefail readonly VERSION="0.1.0" readonly SCRIPT_NAME="deploy-awx" readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly MANIFESTS_DIR="${SCRIPT_DIR}/awx-manifests" readonly AWX_NAMESPACE="awx" # --------------------------------------------------------------------------- # Defaults (overridden by config file or CLI flags) # --------------------------------------------------------------------------- VM_NAME="awx" TARGET_REMOTE="oc-node-02" AWX_IP="192.168.102.161" AWX_PREFIX="22" AWX_GATEWAY="192.168.100.1" AWX_DNS="192.168.100.1" AWX_CPU="4" AWX_MEMORY="8GiB" AWX_DISK="40GiB" GIT_REPO="ssh://git@192.168.1.200:2222/maarten/incus-contrib.git" GIT_BRANCH="master" PLAYBOOK_DIR="playbooks" AETHER_URL="https://192.168.102.160:8443" AETHER_CLUSTER_ID="52" # Runtime flags DRY_RUN=false VERBOSE=false QUIET=false CONFIG_FILE="" ACTION="" # --------------------------------------------------------------------------- # Color and formatting # --------------------------------------------------------------------------- setup_colors() { if [[ -t 1 ]] && [[ "${NO_COLOR:-}" != "1" ]] && [[ "${TERM:-}" != "dumb" ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' else RED='' GREEN='' YELLOW='' BLUE='' CYAN='' BOLD='' DIM='' RESET='' fi } info() { [[ "$QUIET" == true ]] && return; echo -e "${BLUE}[info]${RESET} $*"; } success() { [[ "$QUIET" == true ]] && return; echo -e "${GREEN}[ok]${RESET} $*"; } warn() { echo -e "${YELLOW}[warn]${RESET} $*" >&2; } error() { echo -e "${RED}[error]${RESET} $*" >&2; } step() { [[ "$QUIET" == true ]] && return; echo -e "${CYAN}[step]${RESET} ${BOLD}$*${RESET}"; } detail() { [[ "$VERBOSE" != true ]] && return; echo -e "${DIM} $*${RESET}"; } dry_run_guard() { if [[ "$DRY_RUN" == true ]]; then info "(dry-run) $*" return 1 fi return 0 } # --------------------------------------------------------------------------- # Help # --------------------------------------------------------------------------- usage() { cat </dev/null 2>&1 } vm_running() { local state state=$(incus info "$(vm_ref)" 2>/dev/null | grep -i '^status:' | awk '{print $2}') [[ "$state" == "RUNNING" || "$state" == "Running" ]] } vm_exec() { 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() { local method="$1" path="$2" shift 2 curl -sk -X "$method" "$(awx_url)${path}" \ -H "Content-Type: application/json" \ "$@" } awx_api_auth() { local method="$1" path="$2" shift 2 local token token=$(get_awx_admin_password) curl -sk -X "$method" "$(awx_url)${path}" \ --user "admin:${token}" \ -H "Content-Type: application/json" \ "$@" } get_awx_admin_password() { vm_exec kubectl -n "$AWX_NAMESPACE" get secret awx-admin-password \ -o jsonpath='{.data.password}' 2>/dev/null | base64 -d } wait_for_vm_agent() { local timeout="${1:-120}" local elapsed=0 info "Waiting for VM agent (up to ${timeout}s)..." while ! vm_exec true &>/dev/null 2>&1; do sleep 5 elapsed=$((elapsed + 5)) if [[ $elapsed -ge $timeout ]]; then error "VM agent not available after ${timeout}s" return 1 fi done success "VM agent connected (${elapsed}s)" } wait_for_awx_web() { local timeout="${1:-600}" local elapsed=0 info "Waiting for AWX web interface (up to ${timeout}s)..." while ! curl -sk -o /dev/null -w '%{http_code}' "$(awx_url)/api/v2/ping/" 2>/dev/null | grep -q '200'; do sleep 10 elapsed=$((elapsed + 10)) if [[ $elapsed -ge $timeout ]]; then error "AWX web not responding after ${timeout}s" return 1 fi [[ "$VERBOSE" == true ]] && detail "Still waiting... (${elapsed}s)" done success "AWX web interface ready (${elapsed}s)" } # --------------------------------------------------------------------------- # Doctor — prerequisite checks # --------------------------------------------------------------------------- action_doctor() { step "Checking prerequisites" local ok=true # incus CLI if command -v incus &>/dev/null; then success "incus CLI: $(incus version 2>/dev/null || echo 'installed')" else error "incus CLI not found — install the Incus client" ok=false fi # curl if command -v curl &>/dev/null; then success "curl: $(curl --version | head -1 | awk '{print $2}')" else error "curl not found" ok=false fi # python3 if command -v python3 &>/dev/null; then success "python3: $(python3 --version 2>&1 | awk '{print $2}')" else error "python3 not found" ok=false fi # base64 if command -v base64 &>/dev/null; then success "base64: available" else error "base64 not found" ok=false fi # Manifest files if [[ -d "$MANIFESTS_DIR" ]]; then success "AWX manifests: $MANIFESTS_DIR" else error "AWX manifests not found at $MANIFESTS_DIR" ok=false fi # Target remote if incus remote list -f csv 2>/dev/null | grep -q "^${TARGET_REMOTE},"; then success "Incus remote '${TARGET_REMOTE}': reachable" else warn "Incus remote '${TARGET_REMOTE}' not found (needed for deployment)" fi # Existing VM if vm_exists; then info "AWX VM '$(vm_ref)' exists" if vm_running; then success " VM is running" # Check K3s if vm_exec kubectl get nodes &>/dev/null 2>&1; then success " K3s is responding" else warn " K3s not responding" fi # Check AWX pods local pod_count pod_count=$(vm_exec kubectl -n "$AWX_NAMESPACE" get pods --no-headers 2>/dev/null | wc -l || echo 0) info " AWX pods: ${pod_count}" else warn " VM is not running" fi else info "AWX VM '$(vm_ref)' does not exist (--deploy to create)" fi echo if [[ "$ok" == true ]]; then success "All prerequisites satisfied" else error "Some prerequisites missing — fix the issues above" return 1 fi } # --------------------------------------------------------------------------- # Deploy — full deployment pipeline # --------------------------------------------------------------------------- action_deploy() { step "Deploying AWX on $(vm_ref)" echo info " Target: ${TARGET_REMOTE}" info " VM: ${VM_NAME}" info " IP: ${AWX_IP}/${AWX_PREFIX}" info " CPU: ${AWX_CPU}" info " Memory: ${AWX_MEMORY}" info " Disk: ${AWX_DISK}" echo if vm_exists; then if vm_running; then error "AWX VM already exists and is running. Use --status, --heal, or --cleanup first." return 1 else warn "AWX VM exists but is not running — starting it" if dry_run_guard "incus start $(vm_ref)"; then incus start "$(vm_ref)" fi return 0 fi fi phase_create_vm phase_configure_network phase_install_k3s phase_deploy_awx_operator phase_deploy_awx_instance phase_verify echo success "AWX deployment complete!" info " Web UI: $(awx_url)/" info " API: $(awx_url)/api/v2/ping/" local pw pw=$(get_awx_admin_password 2>/dev/null || echo "") info " Admin: admin / ${pw}" echo info "Next steps:" info " ${SCRIPT_NAME} --configure # set up project, inventory, templates" info " ${SCRIPT_NAME} --join-aether # register with Aether" } phase_create_vm() { step "Phase 1: Create VM" if ! dry_run_guard "incus launch images:debian/12 $(vm_ref) --vm --target ${TARGET_REMOTE} ..."; then return 0 fi incus launch images:debian/12 "$(vm_ref)" --vm \ --target "$TARGET_REMOTE" \ -c "limits.cpu=${AWX_CPU}" \ -c "limits.memory=${AWX_MEMORY}" \ -d "root,size=${AWX_DISK}" success "VM created" detail "Waiting 10s for initial boot..." sleep 10 # Switch to macvlan for direct VLAN access info "Configuring macvlan NIC (parent=mgmt)" incus config device remove "$(vm_ref)" eth0 2>/dev/null || true incus config device add "$(vm_ref)" eth0 nic \ nictype=macvlan parent=mgmt success "NIC switched to macvlan — VM will restart networking" } phase_configure_network() { step "Phase 2: Configure static IP + hostname" wait_for_vm_agent 120 if ! dry_run_guard "Configure networking on $(vm_ref)"; then return 0 fi # Debian 12 images use systemd-networkd (not netplan) # The macvlan NIC appears as enp5s0 in Incus VMs vm_exec bash -c " hostnamectl set-hostname '${VM_NAME}' # Disable any existing DHCP config rm -f /etc/network/interfaces.d/* 2>/dev/null || true # Configure static IP via systemd-networkd mkdir -p /etc/systemd/network cat > /etc/systemd/network/10-static.network << 'NETWORKD' [Match] Name=enp5s0 [Network] Address=${AWX_IP}/${AWX_PREFIX} Gateway=${AWX_GATEWAY} DNS=${AWX_DNS} NETWORKD systemctl enable systemd-networkd systemctl restart systemd-networkd " success "Static IP ${AWX_IP}/${AWX_PREFIX} configured" # Verify connectivity detail "Verifying network connectivity..." sleep 3 if vm_exec ping -c 1 -W 3 "${AWX_GATEWAY}" &>/dev/null; then success "Gateway reachable from VM" else warn "Cannot reach gateway — check VLAN/network config" fi } phase_install_k3s() { step "Phase 3: Install K3s" if ! dry_run_guard "Install K3s on $(vm_ref)"; then return 0 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 " curl -sfL https://get.k3s.io | sh -s - --write-kubeconfig-mode 644 " success "K3s installed" info "Waiting for K3s node to be ready..." vm_exec kubectl wait --for=condition=Ready node --all --timeout=120s success "K3s node ready" } phase_deploy_awx_operator() { step "Phase 4: Deploy AWX Operator" if ! dry_run_guard "Deploy AWX Operator on $(vm_ref)"; then return 0 fi # Create namespace vm_exec kubectl create namespace "$AWX_NAMESPACE" 2>/dev/null || true # Push kustomization files info "Pushing operator manifests..." incus file push "${MANIFESTS_DIR}/operator/kustomization.yaml" \ "$(vm_ref)/tmp/awx-operator-kustomization.yaml" vm_exec bash -c " mkdir -p /opt/awx/operator cp /tmp/awx-operator-kustomization.yaml /opt/awx/operator/kustomization.yaml kubectl apply -k /opt/awx/operator/ " info "Waiting for AWX Operator (up to 5 min)..." vm_exec kubectl -n "$AWX_NAMESPACE" wait \ --for=condition=Available \ deployment/awx-operator-controller-manager \ --timeout=300s success "AWX Operator deployed" } phase_deploy_awx_instance() { step "Phase 5: Deploy AWX instance" if ! dry_run_guard "Deploy AWX instance on $(vm_ref)"; then return 0 fi # Push AWX CR manifests info "Pushing AWX instance manifests..." incus file push "${MANIFESTS_DIR}/base/awx.yaml" \ "$(vm_ref)/tmp/awx-cr.yaml" incus file push "${MANIFESTS_DIR}/base/kustomization.yaml" \ "$(vm_ref)/tmp/awx-kustomization.yaml" vm_exec bash -c " mkdir -p /opt/awx/base cp /tmp/awx-cr.yaml /opt/awx/base/awx.yaml cp /tmp/awx-kustomization.yaml /opt/awx/base/kustomization.yaml kubectl apply -k /opt/awx/base/ " info "AWX instance deploying — this takes 5-10 minutes..." info "Waiting for AWX pods to start..." # Wait for AWX deployment to exist and become available local elapsed=0 local timeout=600 while ! vm_exec kubectl -n "$AWX_NAMESPACE" get deployment awx-web &>/dev/null 2>&1; do sleep 15 elapsed=$((elapsed + 15)) if [[ $elapsed -ge $timeout ]]; then error "AWX web deployment not created after ${timeout}s" warn "Check pod status: incus exec $(vm_ref) -- kubectl -n awx get pods" return 1 fi [[ "$VERBOSE" == true ]] && detail "Waiting for AWX web deployment... (${elapsed}s)" done vm_exec kubectl -n "$AWX_NAMESPACE" wait \ --for=condition=Available \ deployment/awx-web \ --timeout=300s || { warn "AWX web deployment not fully available yet — checking pods" vm_exec kubectl -n "$AWX_NAMESPACE" get pods } success "AWX instance deployed" } phase_verify() { step "Phase 6: Verify AWX" wait_for_awx_web 300 local admin_pw admin_pw=$(get_awx_admin_password) success "Admin password retrieved" # Test API ping (NodePort 30080) local ping_result 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 success "AWX API responding" else warn "AWX API returned unexpected response" fi } # --------------------------------------------------------------------------- # Status — health checks # --------------------------------------------------------------------------- action_status() { step "AWX Status for $(vm_ref)" echo # VM existence and state if ! vm_exists; then error "AWX VM does not exist — use --deploy to create" return 1 fi if ! vm_running; then error "AWX VM exists but is not running" return 1 fi success "VM: running" # K3s if vm_exec kubectl get nodes &>/dev/null 2>&1; then success "K3s: healthy" if [[ "$VERBOSE" == true ]]; then vm_exec kubectl get nodes -o wide fi else error "K3s: not responding" return 1 fi # AWX pods echo info "AWX pods:" vm_exec kubectl -n "$AWX_NAMESPACE" get pods -o wide 2>/dev/null || warn "Cannot list pods" # Pod health summary local total running total=$(vm_exec kubectl -n "$AWX_NAMESPACE" get pods --no-headers 2>/dev/null | wc -l || echo 0) running=$(vm_exec kubectl -n "$AWX_NAMESPACE" get pods --no-headers 2>/dev/null | grep -c 'Running' || echo 0) echo if [[ "$total" -gt 0 && "$running" -eq "$total" ]]; then success "All ${total} pods running" else warn "Pods: ${running}/${total} running" fi # AWX web (NodePort 30080) local http_code 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 success "AWX API: responding at $(awx_url) (HTTP ${http_code})" else error "AWX API: not responding (HTTP ${http_code})" fi # Admin password local admin_pw admin_pw=$(get_awx_admin_password 2>/dev/null || echo "") if [[ -n "$admin_pw" ]]; then success "Admin password: ${admin_pw}" else warn "Cannot retrieve admin password" fi # AWX version local version 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}" # Aether connectivity echo local aether_code aether_code=$(curl -sk -o /dev/null -w '%{http_code}' "${AETHER_URL}/api/health" 2>/dev/null || echo "000") if [[ "$aether_code" == "200" || "$aether_code" == "401" || "$aether_code" == "302" ]]; then success "Aether: reachable at ${AETHER_URL}" else warn "Aether: not reachable (HTTP ${aether_code})" fi } # --------------------------------------------------------------------------- # Heal — fix common issues # --------------------------------------------------------------------------- action_heal() { step "Healing AWX on $(vm_ref)" echo if ! vm_exists || ! vm_running; then error "AWX VM is not running" return 1 fi # 1. K3s service info "Checking K3s service..." if vm_exec systemctl is-active k3s &>/dev/null; then success "K3s service: active" else warn "K3s service not active — restarting" if dry_run_guard "systemctl restart k3s"; then vm_exec systemctl restart k3s sleep 10 fi fi # 2. Unhealthy pods info "Checking for unhealthy pods..." local unhealthy unhealthy=$(vm_exec kubectl -n "$AWX_NAMESPACE" get pods --no-headers 2>/dev/null \ | grep -vE 'Running|Completed' | awk '{print $1}' || true) if [[ -n "$unhealthy" ]]; then warn "Unhealthy pods found — deleting for operator to recreate" for pod in $unhealthy; do info " Deleting pod: $pod" if dry_run_guard "kubectl delete pod $pod"; then vm_exec kubectl -n "$AWX_NAMESPACE" delete pod "$pod" --grace-period=30 fi done info "Waiting 30s for pods to restart..." sleep 30 else success "All pods healthy" fi # 3. AWX web responsiveness (NodePort 30080) info "Checking AWX web..." local http_code 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 success "AWX web: responding" else warn "AWX web not responding — restarting deployment" if dry_run_guard "kubectl rollout restart deployment/awx-web"; then vm_exec kubectl -n "$AWX_NAMESPACE" rollout restart deployment/awx-web 2>/dev/null || true vm_exec kubectl -n "$AWX_NAMESPACE" rollout restart deployment/awx-task 2>/dev/null || true wait_for_awx_web 300 fi fi # 4. Project sync info "Triggering project sync..." local project_id project_id=$(awx_api_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) if [[ -n "$project_id" ]]; then if dry_run_guard "POST /api/v2/projects/${project_id}/update/"; then awx_api_auth POST "/api/v2/projects/${project_id}/update/" 2>/dev/null || true success "Project sync triggered (ID: ${project_id})" fi else info "No project configured yet — use --configure" fi echo success "Heal complete" } # --------------------------------------------------------------------------- # Configure — set up AWX project, inventory, templates # --------------------------------------------------------------------------- action_configure() { step "Configuring AWX" echo if ! vm_running; then error "AWX VM is not running" return 1 fi wait_for_awx_web 60 local admin_pw admin_pw=$(get_awx_admin_password) detail "Admin password: ${admin_pw}" # Helper for authenticated API calls with password awx_auth() { local method="$1" path="$2" shift 2 curl -sk -X "$method" "$(awx_url)${path}" \ --user "admin:${admin_pw}" \ -H "Content-Type: application/json" \ "$@" } # 1. Push Incus client cert/key to AWX project directory # Playbooks use Incus REST API (not SSH) — cert is required. # AWX EE mounts project dir at /runner/project/ during job execution. step "Pushing Incus client certificate to AWX" local incus_cert="${HOME}/.config/incus/client.crt" local incus_key="${HOME}/.config/incus/client.key" if [[ -f "$incus_cert" && -f "$incus_key" ]]; then 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 warn "Cannot find AWX task pod — push cert manually later" 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 # 2. Create manual project (local_path) # AWX EE containers cannot reach the private git repo, so we use a # manual project with files pushed directly to the task pod. step "Creating project" local project_id 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) if [[ -z "$project_id" ]]; then project_id=$(awx_auth POST "/api/v2/projects/" \ -d '{"name":"incus-contrib","organization":1,"scm_type":"","local_path":"incus-contrib"}' 2>/dev/null \ | 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}, local_path)" || warn "Failed to create project" else success "Project exists (ID: ${project_id})" fi # Push playbooks to AWX task pod project directory if [[ -n "$project_id" ]]; then 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}/playbooks" for playbook in post-deploy.yml decommission.yml; do local pb_path="${SCRIPT_DIR}/../ansible/playbooks/${playbook}" if [[ -f "$pb_path" ]]; then local pb_b64 pb_b64=$(base64 -w0 "$pb_path") 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 done success "Playbooks pushed to AWX project directory" else warn "Cannot find AWX task pod — push playbooks manually" fi fi # 3. Create inventory step "Creating inventory" local inventory_id inventory_id=$(awx_auth GET "/api/v2/inventories/?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) if [[ -z "$inventory_id" ]]; then inventory_id=$(awx_auth POST "/api/v2/inventories/" \ -d '{"name":"incus-instances","organization":1}' 2>/dev/null \ | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) [[ -n "$inventory_id" ]] && success "Inventory created (ID: ${inventory_id})" || warn "Failed to create inventory" else success "Inventory exists (ID: ${inventory_id})" fi # 4. Create machine credential (required by AWX templates, though # playbooks use Incus API not SSH) step "Creating machine credential" local machine_cred_id 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) if [[ -z "$machine_cred_id" ]]; then machine_cred_id=$(awx_auth POST "/api/v2/credentials/" \ -d '{"name":"incus-instances","organization":1,"credential_type":1,"inputs":{"username":"root"}}' 2>/dev/null \ | 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" else success "Machine credential exists (ID: ${machine_cred_id})" fi # 5. Create job templates step "Creating job templates" for tmpl_name in post-deploy decommission; do local tmpl_id tmpl_id=$(awx_auth GET "/api/v2/job_templates/?name=${tmpl_name}" 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) if [[ -z "$tmpl_id" ]]; then local tmpl_payload tmpl_payload=$(python3 -c " import json d = { 'name': '${tmpl_name}', 'organization': 1, 'job_type': 'run', 'ask_variables_on_launch': True, 'playbook': '${PLAYBOOK_DIR}/${tmpl_name}.yml' } pid = '${project_id}' iid = '${inventory_id}' cid = '${machine_cred_id}' if pid: d['project'] = int(pid) if iid: d['inventory'] = int(iid) print(json.dumps(d))") tmpl_id=$(awx_auth POST "/api/v2/job_templates/" -d "$tmpl_payload" 2>/dev/null \ | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) # Associate credential separately (templates use credential list) if [[ -n "$tmpl_id" && -n "$machine_cred_id" ]]; then awx_auth POST "/api/v2/job_templates/${tmpl_id}/credentials/" \ -d "{\"id\":${machine_cred_id}}" 2>/dev/null || true fi [[ -n "$tmpl_id" ]] && success "Template '${tmpl_name}' created (ID: ${tmpl_id})" || warn "Failed to create template '${tmpl_name}'" else success "Template '${tmpl_name}' exists (ID: ${tmpl_id})" fi done echo success "AWX configuration complete" echo info "Template IDs (for Aether binding):" for tmpl_name in post-deploy decommission; do local tid tid=$(awx_auth GET "/api/v2/job_templates/?name=${tmpl_name}" 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) info " ${tmpl_name}: ${tid:-unknown}" done echo info "Next: ${SCRIPT_NAME} --join-aether" } # --------------------------------------------------------------------------- # Join Aether — register AWX endpoint + bind to cluster # --------------------------------------------------------------------------- action_join_aether() { step "Registering AWX with Aether" echo if ! vm_running; then error "AWX VM is not running" return 1 fi # 1. Create Personal Access Token step "Creating AWX Personal Access Token" local admin_pw admin_pw=$(get_awx_admin_password) local pat_response pat_response=$(curl -sk -X POST "$(awx_url)/api/v2/users/1/personal_tokens/" \ --user "admin:${admin_pw}" \ -H "Content-Type: application/json" \ -d '{"description": "Aether integration", "scope": "write"}' 2>/dev/null) local awx_token awx_token=$(echo "$pat_response" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null || true) if [[ -z "$awx_token" ]]; then error "Failed to create PAT — check AWX admin credentials" detail "Response: ${pat_response}" return 1 fi success "PAT created" # 2. Get template IDs local post_deploy_id decommission_id post_deploy_id=$(curl -sk "$(awx_url)/api/v2/job_templates/?name=post-deploy" \ --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) decommission_id=$(curl -sk "$(awx_url)/api/v2/job_templates/?name=decommission" \ --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) info "Post-deploy template ID: ${post_deploy_id:-not found}" info "Decommission template ID: ${decommission_id:-not found}" echo info "To complete Aether integration:" echo info "1. Log into Aether at ${AETHER_URL}" info "2. Navigate to Settings → Ansible Automation (/awx-endpoints)" info "3. Add Endpoint:" info " Name: lab-awx" info " URL: $(awx_url)" info " Token: ${awx_token}" info " Verify SSL: unchecked" info "4. Configure cluster AWX binding:" info " Post-deploy template ID: ${post_deploy_id:-}" info " Decommission template ID: ${decommission_id:-}" info " Job timeout: 600 seconds" echo # Print API commands for scripted registration if [[ "$VERBOSE" == true ]]; then detail "API commands for scripted registration:" detail "" detail "# Register endpoint:" detail "curl -sSk -b cookies.txt -H 'X-CSRF-Token: \$CSRF' \\" detail " -H 'Referer: ${AETHER_URL}/' \\" detail " -X POST ${AETHER_URL}/api/awx/endpoints \\" detail " -H 'Content-Type: application/json' \\" detail " -d '{\"name\":\"lab-awx\",\"url\":\"http://${AWX_IP}:30080\",\"token\":\"${awx_token}\",\"verify_ssl\":false}'" detail "" detail "# Bind cluster (replace ENDPOINT_ID):" detail "curl -sSk -b cookies.txt -H 'X-CSRF-Token: \$CSRF' \\" detail " -H 'Referer: ${AETHER_URL}/' \\" detail " -X PUT ${AETHER_URL}/api/clusters/${AETHER_CLUSTER_ID}/awx-config \\" detail " -H 'Content-Type: application/json' \\" detail " -d '{\"cluster_id\":${AETHER_CLUSTER_ID},\"awx_endpoint_id\":ENDPOINT_ID,\"awx_post_deploy_template_id\":${post_deploy_id:-0},\"awx_decommission_template_id\":${decommission_id:-0},\"awx_job_timeout_seconds\":600}'" fi } # --------------------------------------------------------------------------- # Cleanup — destroy AWX VM # --------------------------------------------------------------------------- action_cleanup() { step "Cleaning up AWX VM" if ! vm_exists; then info "AWX VM does not exist — nothing to clean up" return 0 fi echo warn "This will permanently destroy the AWX VM '$(vm_ref)'" warn "All AWX data (jobs, templates, credentials) will be lost." echo if [[ "$DRY_RUN" == true ]]; then info "(dry-run) Would destroy $(vm_ref)" return 0 fi read -r -p "Type 'yes' to confirm: " confirm if [[ "$confirm" != "yes" ]]; then info "Aborted" return 0 fi if vm_running; then info "Stopping VM..." incus stop "$(vm_ref)" --force 2>/dev/null || true sleep 3 fi info "Deleting VM..." incus delete "$(vm_ref)" --force 2>/dev/null || true success "AWX VM destroyed" } # --------------------------------------------------------------------------- # Argument parsing # --------------------------------------------------------------------------- parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --deploy) ACTION="deploy";; --status) ACTION="status";; --heal) ACTION="heal";; --configure) ACTION="configure";; --join-aether) ACTION="join-aether";; --cleanup) ACTION="cleanup";; --doctor) ACTION="doctor";; -c|--config) CONFIG_FILE="$2"; shift;; -n|--dry-run) DRY_RUN=true;; -v|--verbose) VERBOSE=true;; -q|--quiet) QUIET=true;; -h|--help) usage; exit 0;; *) error "Unknown option: $1" echo "Run '${SCRIPT_NAME} --help' for usage." exit 1 ;; esac shift done if [[ -z "$ACTION" ]]; then error "No action specified" echo "Run '${SCRIPT_NAME} --help' for usage." exit 1 fi } # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- main() { setup_colors parse_args "$@" # Load config file if provided if [[ -n "$CONFIG_FILE" ]]; then parse_config "$CONFIG_FILE" fi echo case "$ACTION" in deploy) action_deploy;; status) action_status;; heal) action_heal;; configure) action_configure;; join-aether) action_join_aether;; cleanup) action_cleanup;; doctor) action_doctor;; esac } main "$@"