incus-contrib/bin/deploy-haproxy

1243 lines
39 KiB
Bash
Executable File

#!/usr/bin/env bash
# deploy-haproxy - Deploy and manage HAProxy load balancing on an Incus cluster
#
# Deploys test backends (nginx), builds a HAProxy image via Aether,
# deploys HA infrastructure (2x HAProxy + OVN LB), and creates an
# HTTP service with health checks.
#
# Uses Aether session-based authentication (not JWT) for HAProxy
# management endpoints that are not in the public API.
#
# Usage: deploy-haproxy [OPTIONS]
# Run 'deploy-haproxy --help' for full usage information.
set -euo pipefail
readonly VERSION="0.1.0"
readonly SCRIPT_NAME="deploy-haproxy"
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ---------------------------------------------------------------------------
# Defaults (overridden by config file or CLI flags)
# ---------------------------------------------------------------------------
CLUSTER_REMOTE=""
CLUSTER_ID=""
OVN_NETWORK="net-prod"
VIP=""
HAPROXY_01_IP=""
HAPROXY_02_IP=""
HAPROXY_CPU="2"
HAPROXY_MEMORY="1GB"
BACKEND_IPS=""
BACKEND_PORT="80"
SERVICE_NAME="web-test"
SERVICE_MODE="http"
SERVICE_BALANCE="roundrobin"
IMAGE_VERSION="1.0.0"
AETHER_URL=""
AETHER_USER="admin"
AETHER_PASSWORD=""
# Runtime flags
DRY_RUN=false
VERBOSE=false
QUIET=false
CONFIG_FILE=""
ACTION=""
SKIP_BACKENDS=false
SKIP_IMAGE=false
# Session state
COOKIE_JAR=""
CSRF_TOKEN=""
# ---------------------------------------------------------------------------
# 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 <<EOF
${BOLD}${SCRIPT_NAME}${RESET} v${VERSION} - Deploy HAProxy load balancing on an Incus cluster
${BOLD}USAGE${RESET}
${SCRIPT_NAME} [OPTIONS]
${BOLD}ACTIONS${RESET}
--deploy Full deploy: backends, image, infrastructure, service
--status Check HAProxy health, backends, service stats
--cleanup Remove HAProxy infrastructure and test backends
--doctor Check prerequisites (incus CLI, curl, Aether access)
${BOLD}OPTIONS${RESET}
-c, --config FILE Configuration file
-p, --password PASS Aether admin password (default: from env file)
--skip-backends Skip backend deployment (use existing)
--skip-image Skip image build (use existing)
-n, --dry-run Preview actions without executing
-v, --verbose Show detailed output
-q, --quiet Suppress informational output
-h, --help Show this help message
${BOLD}CONFIGURATION${RESET}
The script uses built-in defaults for the lab environment. Override with
a config file or CLI flags. Password is read from the 'env' file
(AETHER_ADMIN_PASSWORD) or passed via --password.
Defaults:
Cluster remote: ${CLUSTER_REMOTE}
Cluster ID: ${CLUSTER_ID}
OVN network: ${OVN_NETWORK}
VIP: ${VIP}
HAProxy IPs: ${HAPROXY_01_IP}, ${HAPROXY_02_IP}
Backend IPs: ${BACKEND_IPS}
Service: ${SERVICE_NAME} (${SERVICE_MODE}, ${SERVICE_BALANCE})
Image version: ${IMAGE_VERSION}
Aether: ${AETHER_URL}
${BOLD}EXAMPLES${RESET}
# Check prerequisites
${SCRIPT_NAME} --doctor
# Full deployment with defaults
${SCRIPT_NAME} --deploy
# Preview deployment
${SCRIPT_NAME} --deploy --dry-run
# Deploy without rebuilding image
${SCRIPT_NAME} --deploy --skip-image
# Deploy without creating new backends
${SCRIPT_NAME} --deploy --skip-backends
# Check health
${SCRIPT_NAME} --status
# Clean up everything
${SCRIPT_NAME} --cleanup
EOF
}
# ---------------------------------------------------------------------------
# Config parsing
# ---------------------------------------------------------------------------
parse_config() {
local file="$1"
if [[ ! -f "$file" ]]; then
error "Config file not found: $file"
exit 1
fi
local val
val=$(grep -A50 '^haproxy:' "$file" | grep '^ *cluster_remote:' | head -1 | sed 's/.*: *//' | tr -d '"' || true)
[[ -n "$val" ]] && CLUSTER_REMOTE="$val"
val=$(grep -A50 '^haproxy:' "$file" | grep '^ *cluster_id:' | head -1 | sed 's/.*: *//' | tr -d '"' || true)
[[ -n "$val" ]] && CLUSTER_ID="$val"
val=$(grep -A50 '^haproxy:' "$file" | grep '^ *ovn_network:' | head -1 | sed 's/.*: *//' | tr -d '"' || true)
[[ -n "$val" ]] && OVN_NETWORK="$val"
val=$(grep -A50 '^haproxy:' "$file" | grep '^ *vip:' | head -1 | sed 's/.*: *//' | tr -d '"' || true)
[[ -n "$val" ]] && VIP="$val"
val=$(grep -A50 '^haproxy:' "$file" | grep '^ *haproxy_01_ip:' | head -1 | sed 's/.*: *//' | tr -d '"' || true)
[[ -n "$val" ]] && HAPROXY_01_IP="$val"
val=$(grep -A50 '^haproxy:' "$file" | grep '^ *haproxy_02_ip:' | head -1 | sed 's/.*: *//' | tr -d '"' || true)
[[ -n "$val" ]] && HAPROXY_02_IP="$val"
val=$(grep -A50 '^haproxy:' "$file" | grep '^ *backend_ips:' | head -1 | sed 's/.*: *//' | tr -d '"' || true)
[[ -n "$val" ]] && BACKEND_IPS="$val"
val=$(grep -A50 '^haproxy:' "$file" | grep '^ *service_name:' | head -1 | sed 's/.*: *//' | tr -d '"' || true)
[[ -n "$val" ]] && SERVICE_NAME="$val"
val=$(grep -A50 '^haproxy:' "$file" | grep '^ *image_version:' | head -1 | sed 's/.*: *//' | tr -d '"' || true)
[[ -n "$val" ]] && IMAGE_VERSION="$val"
val=$(grep -A50 '^haproxy:' "$file" | grep '^ *aether_url:' | head -1 | sed 's/^ *aether_url: *//' | tr -d '"' || true)
[[ -n "$val" ]] && AETHER_URL="$val"
}
# ---------------------------------------------------------------------------
# Load password from env file
# ---------------------------------------------------------------------------
load_password() {
if [[ -n "$AETHER_PASSWORD" ]]; then
return 0
fi
# Check if already exported (user sourced envs/*/env)
if [[ -n "${AETHER_ADMIN_PASSWORD:-}" ]]; then
AETHER_PASSWORD="$AETHER_ADMIN_PASSWORD"
return 0
fi
# Search for env file: walk up from script dir
local dir="$SCRIPT_DIR"
while [[ "$dir" != "/" ]]; do
if [[ -f "${dir}/env" ]]; then
local val
val=$(grep 'AETHER_ADMIN_PASSWORD=' "${dir}/env" | head -1 | sed 's/^.*AETHER_ADMIN_PASSWORD=//' | tr -d '"' | tr -d "'" || true)
if [[ -n "$val" ]]; then
AETHER_PASSWORD="$val"
detail "Password loaded from ${dir}/env"
return 0
fi
fi
dir="$(dirname "$dir")"
done
error "Aether password not found"
error "Source envs/<env>/env or use --password"
return 1
}
# ---------------------------------------------------------------------------
# Aether session authentication
# ---------------------------------------------------------------------------
aether_login() {
step "Authenticating with Aether"
COOKIE_JAR=$(mktemp /tmp/aether-haproxy-cookies.XXXXXX)
detail "Cookie jar: ${COOKIE_JAR}"
# Step 1: Get login page and extract CSRF token from hidden form field
# Aether serves the login form at / (root), not /login
# The form POSTs to /login
local login_page
login_page=$(curl -sSk -c "$COOKIE_JAR" "${AETHER_URL}/" 2>/dev/null)
CSRF_TOKEN=$(echo "$login_page" \
| grep -o 'name="csrf_token" value="[^"]*"' \
| head -1 \
| sed 's/.*value="//;s/"//' || true)
if [[ -z "$CSRF_TOKEN" ]]; then
# Try alternate pattern (some versions use different attribute order)
CSRF_TOKEN=$(echo "$login_page" \
| grep -o 'value="[^"]*" name="csrf_token"' \
| head -1 \
| sed 's/value="//;s/" name=.*//' || true)
fi
if [[ -z "$CSRF_TOKEN" ]]; then
error "Failed to extract CSRF token from login page"
error "Check that Aether is accessible at ${AETHER_URL}"
return 1
fi
detail "CSRF token: ${CSRF_TOKEN:0:20}..."
# Step 2: POST login (form-urlencoded, NOT JSON)
local login_code
login_code=$(curl -sSk -b "$COOKIE_JAR" -c "$COOKIE_JAR" \
-X POST "${AETHER_URL}/login" \
-d "csrf_token=${CSRF_TOKEN}&username=${AETHER_USER}&password=${AETHER_PASSWORD}" \
-o /dev/null -w '%{http_code}' \
-L 2>/dev/null)
detail "Login response: HTTP ${login_code}"
# Step 3: Refresh CSRF token from an authenticated page
local haproxy_page
haproxy_page=$(curl -sSk -b "$COOKIE_JAR" "${AETHER_URL}/haproxy" 2>/dev/null)
CSRF_TOKEN=$(echo "$haproxy_page" \
| grep -o 'name="csrf_token" value="[^"]*"' \
| head -1 \
| sed 's/.*value="//;s/"//' || true)
if [[ -z "$CSRF_TOKEN" ]]; then
error "Failed to get CSRF token after login — authentication may have failed"
error "Verify password is correct (AETHER_ADMIN_PASSWORD in env file)"
return 1
fi
success "Authenticated with Aether (session + CSRF)"
detail "CSRF token: ${CSRF_TOKEN:0:20}..."
}
aether_api() {
local method="$1" path="$2"
shift 2
curl -sSk -b "$COOKIE_JAR" \
-H "X-CSRF-Token: ${CSRF_TOKEN}" \
-H "Referer: ${AETHER_URL}/haproxy" \
-X "$method" "${AETHER_URL}${path}" \
"$@"
}
aether_cleanup() {
if [[ -n "${COOKIE_JAR:-}" && -f "${COOKIE_JAR:-}" ]]; then
rm -f "$COOKIE_JAR"
fi
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# Split BACKEND_IPS into array
get_backend_ips() {
echo "$BACKEND_IPS" | tr ',' ' '
}
get_backend_count() {
local ips
ips=$(get_backend_ips)
echo "$ips" | wc -w
}
backend_name() {
local index="$1"
printf "nginx-lb-%02d" "$index"
}
haproxy_container_01() {
echo "ffsdn-haproxy-${CLUSTER_ID}-01"
}
haproxy_container_02() {
echo "ffsdn-haproxy-${CLUSTER_ID}-02"
}
container_exists() {
local name="$1"
incus info "${CLUSTER_REMOTE}:${name}" &>/dev/null 2>&1
}
container_running() {
local name="$1"
local state
state=$(incus info "${CLUSTER_REMOTE}:${name}" 2>/dev/null | grep -i '^status:' | awk '{print $2}')
[[ "$state" == "RUNNING" || "$state" == "Running" ]]
}
# ---------------------------------------------------------------------------
# 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
# Aether password
if load_password 2>/dev/null; then
success "Aether password: configured"
else
error "Aether password: not found (set AETHER_ADMIN_PASSWORD in env file)"
ok=false
fi
# Cluster remote
if incus remote list -f csv 2>/dev/null | grep -q "^${CLUSTER_REMOTE},"; then
success "Incus remote '${CLUSTER_REMOTE}': reachable"
else
warn "Incus remote '${CLUSTER_REMOTE}' not found (needed for deployment)"
fi
# Aether accessibility (login page is at root /)
local aether_code
aether_code=$(curl -sk -o /dev/null -w '%{http_code}' "${AETHER_URL}/" 2>/dev/null || echo "000")
if [[ "$aether_code" == "200" || "$aether_code" == "302" ]]; then
success "Aether: reachable at ${AETHER_URL} (HTTP ${aether_code})"
else
error "Aether: not reachable at ${AETHER_URL} (HTTP ${aether_code})"
ok=false
fi
# Existing HAProxy containers
if container_exists "$(haproxy_container_01)"; then
info "HAProxy infrastructure exists on cluster ${CLUSTER_ID}"
if container_running "$(haproxy_container_01)"; then
success " $(haproxy_container_01): running"
else
warn " $(haproxy_container_01): not running"
fi
if container_running "$(haproxy_container_02)"; then
success " $(haproxy_container_02): running"
else
warn " $(haproxy_container_02): not running"
fi
else
info "No HAProxy infrastructure on cluster ${CLUSTER_ID} (--deploy to create)"
fi
# Existing backends
local idx=1
for ip in $(get_backend_ips); do
local name
name=$(backend_name "$idx")
if container_exists "$name"; then
success " Backend ${name} (${ip}): exists"
else
info " Backend ${name} (${ip}): not deployed"
fi
idx=$((idx + 1))
done
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 HAProxy load balancing on cluster ${CLUSTER_ID}"
echo
info " Cluster: ${CLUSTER_REMOTE} (ID: ${CLUSTER_ID})"
info " OVN network: ${OVN_NETWORK}"
info " VIP: ${VIP}"
info " HAProxy: ${HAPROXY_01_IP}, ${HAPROXY_02_IP}"
info " Backends: ${BACKEND_IPS}"
info " Service: ${SERVICE_NAME} (${SERVICE_MODE}, ${SERVICE_BALANCE})"
echo
load_password
# Check existing infrastructure
if container_exists "$(haproxy_container_01)"; then
error "HAProxy infrastructure already exists ($(haproxy_container_01))"
error "Use --status to check health, or --cleanup first."
return 1
fi
if [[ "$SKIP_BACKENDS" != true ]]; then
phase_deploy_backends
else
info "Skipping backend deployment (--skip-backends)"
fi
# Authenticate with Aether for remaining phases
if [[ "$DRY_RUN" != true ]]; then
aether_login
trap aether_cleanup EXIT
fi
if [[ "$SKIP_IMAGE" != true ]]; then
phase_build_image
else
info "Skipping image build (--skip-image)"
fi
phase_deploy_infrastructure
phase_create_service
phase_verify
echo
success "HAProxy deployment complete!"
info " VIP: http://${VIP}/"
info " Service: ${SERVICE_NAME}"
info " Backends: $(get_backend_count)"
echo
info "Next steps:"
info " curl http://${VIP}/ # test load balancing"
info " ${SCRIPT_NAME} --status # check health"
}
# ---------------------------------------------------------------------------
# Phase 1: Deploy test backends
# ---------------------------------------------------------------------------
phase_deploy_backends() {
step "Phase 1: Deploy test backends"
local idx=1
for ip in $(get_backend_ips); do
local name
name=$(backend_name "$idx")
if container_exists "$name"; then
if container_running "$name"; then
success "Backend ${name} already running"
idx=$((idx + 1))
continue
else
warn "Backend ${name} exists but not running — starting"
if dry_run_guard "incus start ${CLUSTER_REMOTE}:${name}"; then
incus start "${CLUSTER_REMOTE}:${name}"
fi
idx=$((idx + 1))
continue
fi
fi
if ! dry_run_guard "Launch ${name} on ${OVN_NETWORK} at ${ip}"; then
idx=$((idx + 1))
continue
fi
info "Launching ${name} (${ip})..."
incus launch images:debian/12 "${CLUSTER_REMOTE}:${name}" \
-n "$OVN_NETWORK"
# Wait for container agent
local elapsed=0
while ! incus exec "${CLUSTER_REMOTE}:${name}" -- true &>/dev/null 2>&1; do
sleep 2
elapsed=$((elapsed + 2))
if [[ $elapsed -ge 60 ]]; then
error "Container agent not available after 60s"
return 1
fi
done
# Configure backend
incus exec "${CLUSTER_REMOTE}:${name}" -- bash -c "
# Wait for initial network
sleep 3
# Install nginx + curl (curl needed for verification)
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq nginx curl 2>&1 | tail -1
# Create unique index page
cat > /var/www/html/index.html << HTML
<html><body>
<h1>Backend: ${name}</h1>
<p>IP: ${ip}</p>
</body></html>
HTML
systemctl enable nginx
systemctl start nginx
"
# Set static IP
incus exec "${CLUSTER_REMOTE}:${name}" -- bash -c "
cat > /etc/systemd/network/10-static.network << NETCFG
[Match]
Name=eth0
[Network]
Address=${ip}/24
Gateway=10.10.10.1
DNS=10.10.10.1
NETCFG
systemctl restart systemd-networkd
"
success "Backend ${name} deployed at ${ip}"
idx=$((idx + 1))
done
# Verify backends
info "Waiting for backends to stabilize..."
sleep 5
local all_ok=true
idx=1
for ip in $(get_backend_ips); do
local name
name=$(backend_name "$idx")
if container_running "$name"; then
detail "Backend ${name}: running"
else
warn "Backend ${name}: not running"
all_ok=false
fi
idx=$((idx + 1))
done
if [[ "$all_ok" == true ]]; then
success "All $(get_backend_count) backends deployed"
else
warn "Some backends may not be ready"
fi
}
# ---------------------------------------------------------------------------
# Phase 2: Build HAProxy image
# ---------------------------------------------------------------------------
phase_build_image() {
step "Phase 2: Build HAProxy image"
if ! dry_run_guard "Build HAProxy image v${IMAGE_VERSION} on cluster ${CLUSTER_ID}"; then
return 0
fi
# Check if image already exists
local images_response
images_response=$(aether_api GET "/haproxy/images" 2>/dev/null || echo "[]")
local existing_version
existing_version=$(echo "$images_response" \
| python3 -c "
import sys, json
try:
imgs = json.load(sys.stdin)
if isinstance(imgs, list):
for img in imgs:
if img.get('version') == '${IMAGE_VERSION}':
print(img.get('id', ''))
break
except: pass
" 2>/dev/null || true)
if [[ -n "$existing_version" ]]; then
success "Image v${IMAGE_VERSION} already exists (ID: ${existing_version})"
info "Ensuring image is pushed to cluster and set as current..."
aether_api POST "/haproxy/image/push" \
-H "Content-Type: application/json" \
-d "{\"image_id\": ${existing_version}, \"cluster_id\": ${CLUSTER_ID}}" \
2>/dev/null || true
aether_api POST "/haproxy/image/set-current" \
-H "Content-Type: application/json" \
-d "{\"image_id\": ${existing_version}}" \
2>/dev/null || true
success "Image v${IMAGE_VERSION} ready on cluster ${CLUSTER_ID}"
return 0
fi
# Build new image
info "Building HAProxy image v${IMAGE_VERSION} on cluster ${CLUSTER_ID}..."
info "This takes 5-10 minutes..."
local build_response
build_response=$(aether_api POST "/haproxy/image/build" \
-H "Content-Type: application/json" \
-d "{\"cluster_id\": ${CLUSTER_ID}, \"version\": \"${IMAGE_VERSION}\"}" \
2>/dev/null)
detail "Build response: ${build_response}"
# Poll build status
local elapsed=0
local timeout=600
local status=""
while true; do
status=$(aether_api GET "/haproxy/image/build-status" 2>/dev/null \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('status','unknown'))" \
2>/dev/null || echo "unknown")
detail "Build status: ${status} (${elapsed}s)"
if [[ "$status" == "completed" || "$status" == "success" ]]; then
success "Image build completed"
break
elif [[ "$status" == "failed" || "$status" == "error" ]]; then
error "Image build failed"
local build_error
build_error=$(aether_api GET "/haproxy/image/build-status" 2>/dev/null \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('error','unknown'))" \
2>/dev/null || true)
[[ -n "$build_error" ]] && error "Error: ${build_error}"
return 1
fi
sleep 15
elapsed=$((elapsed + 15))
if [[ $elapsed -ge $timeout ]]; then
error "Image build timed out after ${timeout}s"
return 1
fi
done
# Get new image ID
local image_id
image_id=$(aether_api GET "/haproxy/images" 2>/dev/null \
| python3 -c "
import sys, json
try:
imgs = json.load(sys.stdin)
if isinstance(imgs, list):
for img in imgs:
if img.get('version') == '${IMAGE_VERSION}':
print(img['id'])
break
except: pass
" 2>/dev/null || true)
if [[ -z "$image_id" ]]; then
error "Cannot find built image — check Aether UI"
return 1
fi
# Push to cluster
info "Pushing image to cluster ${CLUSTER_ID}..."
aether_api POST "/haproxy/image/push" \
-H "Content-Type: application/json" \
-d "{\"image_id\": ${image_id}, \"cluster_id\": ${CLUSTER_ID}}" \
2>/dev/null || true
# Set as current
info "Setting as current version..."
aether_api POST "/haproxy/image/set-current" \
-H "Content-Type: application/json" \
-d "{\"image_id\": ${image_id}}" \
2>/dev/null || true
success "Image v${IMAGE_VERSION} built and pushed (ID: ${image_id})"
}
# ---------------------------------------------------------------------------
# Phase 3: Deploy HAProxy infrastructure
# ---------------------------------------------------------------------------
phase_deploy_infrastructure() {
step "Phase 3: Deploy HAProxy infrastructure"
if ! dry_run_guard "Deploy infrastructure: VIP=${VIP}, HAProxy=${HAPROXY_01_IP}/${HAPROXY_02_IP}"; then
return 0
fi
# Check existing infrastructure
local infra_response
infra_response=$(aether_api GET "/haproxy/infrastructure/${CLUSTER_ID}" 2>/dev/null || echo "{}")
local infra_exists
infra_exists=$(echo "$infra_response" \
| python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
if d.get('deployed') or d.get('haproxy_01_ip'):
print('yes')
else:
print('no')
except: print('no')
" 2>/dev/null || echo "no")
if [[ "$infra_exists" == "yes" ]]; then
success "Infrastructure already deployed on cluster ${CLUSTER_ID}"
return 0
fi
info "Deploying infrastructure..."
info " OVN network: ${OVN_NETWORK}"
info " VIP: ${VIP}"
info " HAProxy 01: ${HAPROXY_01_IP}"
info " HAProxy 02: ${HAPROXY_02_IP}"
info " CPU: ${HAPROXY_CPU}"
info " Memory: ${HAPROXY_MEMORY}"
local deploy_response
deploy_response=$(aether_api POST "/haproxy/infrastructure/deploy" \
-H "Content-Type: application/json" \
-d "{
\"cluster_id\": ${CLUSTER_ID},
\"ovn_network\": \"${OVN_NETWORK}\",
\"vip\": \"${VIP}\",
\"haproxy_01_ip\": \"${HAPROXY_01_IP}\",
\"haproxy_02_ip\": \"${HAPROXY_02_IP}\",
\"cpu_limit\": \"${HAPROXY_CPU}\",
\"memory_limit\": \"${HAPROXY_MEMORY}\"
}" 2>/dev/null)
detail "Deploy response: ${deploy_response}"
# Verify containers appeared
info "Waiting for HAProxy containers to start..."
local elapsed=0
local timeout=120
while true; do
if container_exists "$(haproxy_container_01)" && container_exists "$(haproxy_container_02)"; then
break
fi
sleep 5
elapsed=$((elapsed + 5))
if [[ $elapsed -ge $timeout ]]; then
error "HAProxy containers not found after ${timeout}s"
error "Check Aether UI for deployment errors"
return 1
fi
detail "Waiting for containers... (${elapsed}s)"
done
# Wait for running state
elapsed=0
while true; do
if container_running "$(haproxy_container_01)" && container_running "$(haproxy_container_02)"; then
break
fi
sleep 5
elapsed=$((elapsed + 5))
if [[ $elapsed -ge $timeout ]]; then
warn "HAProxy containers exist but not all running after ${timeout}s"
break
fi
done
success "Infrastructure deployed"
success " $(haproxy_container_01): $(container_running "$(haproxy_container_01)" && echo "running" || echo "not running")"
success " $(haproxy_container_02): $(container_running "$(haproxy_container_02)" && echo "running" || echo "not running")"
}
# ---------------------------------------------------------------------------
# Phase 4: Create HTTP service
# ---------------------------------------------------------------------------
phase_create_service() {
step "Phase 4: Create HTTP service"
if ! dry_run_guard "Create service: ${SERVICE_NAME} on ${VIP}:${BACKEND_PORT}"; then
return 0
fi
# Build backends JSON
local backends_json="["
local idx=1
local first=true
for ip in $(get_backend_ips); do
local name
name=$(backend_name "$idx")
if [[ "$first" == true ]]; then
first=false
else
backends_json+=","
fi
backends_json+="{\"name\":\"${name}\",\"ip\":\"${ip}\",\"port\":${BACKEND_PORT},\"weight\":100}"
idx=$((idx + 1))
done
backends_json+="]"
detail "Backends JSON: ${backends_json}"
info "Creating service '${SERVICE_NAME}'..."
info " VIP: ${VIP}:${BACKEND_PORT}"
info " Mode: ${SERVICE_MODE}"
info " Balance: ${SERVICE_BALANCE}"
info " Health: enabled"
info " Backends: $(get_backend_count)"
local service_response
service_response=$(aether_api POST "/haproxy/service" \
-H "Content-Type: application/json" \
-d "{
\"cluster_id\": ${CLUSTER_ID},
\"name\": \"${SERVICE_NAME}\",
\"description\": \"HTTP load balancer for nginx test backends\",
\"vip\": \"${VIP}\",
\"listen_port\": ${BACKEND_PORT},
\"mode\": \"${SERVICE_MODE}\",
\"balance\": \"${SERVICE_BALANCE}\",
\"health_check\": true,
\"sticky_sessions\": false,
\"backends\": ${backends_json}
}" 2>/dev/null)
detail "Service response: ${service_response}"
success "Service '${SERVICE_NAME}' created"
}
# ---------------------------------------------------------------------------
# Phase 5: Verify
# ---------------------------------------------------------------------------
phase_verify() {
step "Phase 5: Verify deployment"
info "Waiting for HAProxy to configure backends..."
sleep 10
# Test VIP
info "Testing VIP at http://${VIP}/..."
local response
response=$(curl -sk --connect-timeout 5 --max-time 10 "http://${VIP}/" 2>/dev/null || true)
if [[ -n "$response" ]]; then
local backend_hit
backend_hit=$(echo "$response" | grep -o 'Backend: [^<]*' || echo "unknown")
success "VIP responding: ${backend_hit}"
else
warn "VIP not responding yet — HAProxy may still be configuring"
warn "Try: curl http://${VIP}/"
fi
# Test distribution
if [[ -n "$response" ]]; then
info "Testing traffic distribution (6 requests)..."
local hits=""
for i in $(seq 1 6); do
local hit
hit=$(curl -sk --connect-timeout 5 --max-time 10 "http://${VIP}/" 2>/dev/null \
| grep -o 'Backend: [^<]*' || echo "no response")
detail " Request ${i}: ${hit}"
hits="${hits}${hit}\n"
done
local unique_backends
unique_backends=$(echo -e "$hits" | sort -u | grep -c 'Backend:' || echo 0)
if [[ "$unique_backends" -gt 1 ]]; then
success "Traffic distributed across ${unique_backends} backends"
elif [[ "$unique_backends" -eq 1 ]]; then
info "Traffic going to 1 backend (health checks may not have completed yet)"
else
warn "No successful responses — check HAProxy and backend health"
fi
fi
}
# ---------------------------------------------------------------------------
# Status — health checks
# ---------------------------------------------------------------------------
action_status() {
step "HAProxy Status for cluster ${CLUSTER_ID}"
echo
# HAProxy containers
if container_exists "$(haproxy_container_01)"; then
if container_running "$(haproxy_container_01)"; then
success "$(haproxy_container_01): running"
else
error "$(haproxy_container_01): not running"
fi
else
error "$(haproxy_container_01): not found (no infrastructure deployed)"
return 1
fi
if container_exists "$(haproxy_container_02)"; then
if container_running "$(haproxy_container_02)"; then
success "$(haproxy_container_02): running"
else
warn "$(haproxy_container_02): not running"
fi
else
warn "$(haproxy_container_02): not found"
fi
# Backend containers
echo
info "Backends:"
local idx=1
for ip in $(get_backend_ips); do
local name
name=$(backend_name "$idx")
if container_exists "$name"; then
if container_running "$name"; then
success " ${name} (${ip}): running"
else
warn " ${name} (${ip}): not running"
fi
else
info " ${name} (${ip}): not deployed"
fi
idx=$((idx + 1))
done
# VIP connectivity
echo
info "VIP connectivity:"
local vip_code
vip_code=$(curl -sk -o /dev/null -w '%{http_code}' --connect-timeout 5 \
"http://${VIP}/" 2>/dev/null || echo "000")
if [[ "$vip_code" == "200" ]]; then
success "VIP http://${VIP}/ responding (HTTP ${vip_code})"
else
warn "VIP http://${VIP}/ not responding (HTTP ${vip_code})"
fi
# Aether health stats (requires session auth)
echo
if load_password 2>/dev/null; then
aether_login 2>/dev/null
trap aether_cleanup EXIT
local health_response
health_response=$(aether_api GET \
"/haproxy/service/health-stats?cluster_id=${CLUSTER_ID}&service_name=${SERVICE_NAME}" \
2>/dev/null || echo "{}")
local has_stats
has_stats=$(echo "$health_response" \
| python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
if d and not d.get('error'):
print('yes')
else:
print('no')
except: print('no')
" 2>/dev/null || echo "no")
if [[ "$has_stats" == "yes" ]]; then
info "Service health (from Aether):"
echo "$health_response" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
# Print backend stats if available
backends = d.get('backends', d.get('backend_stats', []))
if isinstance(backends, list):
for b in backends:
name = b.get('name', b.get('server', 'unknown'))
status = b.get('status', 'unknown')
icon = 'UP' if status.upper() == 'UP' else 'DOWN'
print(f' {name}: {icon}')
# Print frontend stats if available
frontend = d.get('frontend', d.get('frontend_stats', {}))
if isinstance(frontend, dict) and frontend:
sessions = frontend.get('scur', frontend.get('current_sessions', '?'))
print(f' Frontend sessions: {sessions}')
except Exception as e:
print(f' (could not parse stats: {e})')
" 2>/dev/null || warn " Could not parse health stats"
else
info "No health stats available (service may not exist yet)"
fi
else
info "Skipping Aether health stats (no password configured)"
fi
}
# ---------------------------------------------------------------------------
# Cleanup — remove everything
# ---------------------------------------------------------------------------
action_cleanup() {
step "Cleaning up HAProxy deployment"
echo
local has_infra=false
local has_backends=false
if container_exists "$(haproxy_container_01)"; then
has_infra=true
fi
local idx=1
for ip in $(get_backend_ips); do
if container_exists "$(backend_name "$idx")"; then
has_backends=true
break
fi
idx=$((idx + 1))
done
if [[ "$has_infra" == false && "$has_backends" == false ]]; then
info "Nothing to clean up"
return 0
fi
echo
if [[ "$has_infra" == true ]]; then
warn "This will delete HAProxy infrastructure ($(haproxy_container_01), $(haproxy_container_02))"
warn "All services and VIPs will be removed."
fi
if [[ "$has_backends" == true ]]; then
warn "This will delete test backend containers."
fi
echo
if [[ "$DRY_RUN" == true ]]; then
info "(dry-run) Would delete infrastructure and backends"
return 0
fi
read -r -p "Type 'yes' to confirm: " confirm
if [[ "$confirm" != "yes" ]]; then
info "Aborted"
return 0
fi
# Delete infrastructure via Aether API (removes containers, VIPs, services)
if [[ "$has_infra" == true ]]; then
step "Removing HAProxy infrastructure via Aether"
if load_password 2>/dev/null; then
aether_login
trap aether_cleanup EXIT
local delete_response
delete_response=$(aether_api DELETE "/haproxy/infrastructure/${CLUSTER_ID}" \
-H "Content-Type: application/json" \
2>/dev/null || echo "")
detail "Delete response: ${delete_response}"
# Wait for containers to be removed
local elapsed=0
while container_exists "$(haproxy_container_01)" && [[ $elapsed -lt 60 ]]; do
sleep 5
elapsed=$((elapsed + 5))
detail "Waiting for cleanup... (${elapsed}s)"
done
if ! container_exists "$(haproxy_container_01)"; then
success "HAProxy infrastructure removed"
else
warn "Infrastructure may not be fully removed — check Aether UI"
fi
else
warn "Cannot authenticate with Aether — deleting containers directly"
if container_running "$(haproxy_container_01)"; then
incus stop "${CLUSTER_REMOTE}:$(haproxy_container_01)" --force 2>/dev/null || true
fi
incus delete "${CLUSTER_REMOTE}:$(haproxy_container_01)" --force 2>/dev/null || true
if container_running "$(haproxy_container_02)"; then
incus stop "${CLUSTER_REMOTE}:$(haproxy_container_02)" --force 2>/dev/null || true
fi
incus delete "${CLUSTER_REMOTE}:$(haproxy_container_02)" --force 2>/dev/null || true
success "HAProxy containers deleted directly"
fi
fi
# Delete backends
if [[ "$has_backends" == true ]]; then
step "Removing test backends"
idx=1
for ip in $(get_backend_ips); do
local name
name=$(backend_name "$idx")
if container_exists "$name"; then
info "Deleting ${name}..."
incus delete "${CLUSTER_REMOTE}:${name}" --force 2>/dev/null || true
success "Deleted ${name}"
fi
idx=$((idx + 1))
done
fi
echo
success "Cleanup complete"
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--deploy) ACTION="deploy";;
--status) ACTION="status";;
--cleanup) ACTION="cleanup";;
--doctor) ACTION="doctor";;
--skip-backends) SKIP_BACKENDS=true;;
--skip-image) SKIP_IMAGE=true;;
-c|--config) CONFIG_FILE="$2"; shift;;
-p|--password) AETHER_PASSWORD="$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
# ---------------------------------------------------------------------------
require_config() {
local missing=()
[[ -z "$CLUSTER_REMOTE" ]] && missing+=("cluster_remote")
[[ -z "$CLUSTER_ID" ]] && missing+=("cluster_id")
[[ -z "$VIP" ]] && missing+=("vip")
[[ -z "$HAPROXY_01_IP" ]] && missing+=("haproxy_01_ip")
[[ -z "$HAPROXY_02_IP" ]] && missing+=("haproxy_02_ip")
[[ -z "$BACKEND_IPS" ]] && missing+=("backend_ips")
[[ -z "$AETHER_URL" ]] && missing+=("aether_url")
if [[ ${#missing[@]} -gt 0 ]]; then
error "Required config missing: ${missing[*]}"
error "Use --config FILE to load environment settings (see envs/)"
exit 1
fi
}
main() {
setup_colors
parse_args "$@"
# Load config file if provided
if [[ -n "$CONFIG_FILE" ]]; then
parse_config "$CONFIG_FILE"
fi
# Validate required fields (must come from --config or CLI flags)
if [[ "$ACTION" != "doctor" ]]; then
require_config
fi
echo
case "$ACTION" in
deploy) action_deploy;;
status) action_status;;
cleanup) action_cleanup;;
doctor) action_doctor;;
esac
}
main "$@"