555 lines
23 KiB
Bash
Executable File
555 lines
23 KiB
Bash
Executable File
#!/bin/bash
|
||
set -e
|
||
|
||
# =============================================================================
|
||
# onboard_client.sh
|
||
# MSP client onboarding script — creates inventory, Semaphore project,
|
||
# SSH keys, and task templates.
|
||
#
|
||
# Required: --id, --name, --slug
|
||
# Everything else is optional and can be added later via --update
|
||
#
|
||
# Client types (--type):
|
||
# basic — preflight + linux patch only (no hypervisor)
|
||
# linux — same as basic (alias)
|
||
# proxmox — basic + proxmox upgrade/backup templates
|
||
# xcpng — basic + xcp-ng pool upgrade/snapshot templates
|
||
# firewall — preflight only (for firewall-only clients)
|
||
# custom — no templates created, add manually
|
||
#
|
||
# Idempotency: re-running against an existing client slug will detect the
|
||
# existing project and skip creation steps. Use --update to add optional
|
||
# config (e.g. add Proxmox to a previously basic client).
|
||
# =============================================================================
|
||
|
||
usage() {
|
||
cat << EOF
|
||
Usage: $0 [options]
|
||
|
||
Required:
|
||
-i, --id Client ID (e.g. RP-001)
|
||
-n, --name Client name (e.g. 'Royal Pizza')
|
||
-s, --slug Short slug for filenames (e.g. royal_pizza)
|
||
|
||
Client type (controls which templates are created):
|
||
-t, --type Client type: basic|linux|proxmox|xcpng|firewall|custom
|
||
default: basic
|
||
|
||
Optional:
|
||
-w, --webhook n8n webhook URL
|
||
-b, --billing Billing model (hybrid|per-host|time-saved|tiered) default: hybrid
|
||
-e, --estimate Human time estimate in seconds default: 2700
|
||
-v, --vpn VPN type (ipsec|openvpn|none) default: none
|
||
|
||
Hypervisor options (proxmox type):
|
||
--proxmox-host Proxmox API host IP
|
||
--proxmox-token-id Proxmox API token ID (e.g. ansible@pve!token-name)
|
||
--proxmox-token-secret Proxmox API token secret
|
||
|
||
Hypervisor options (xcpng type):
|
||
--xo-url XenOrchestra URL (e.g. https://xoa.example.com)
|
||
--xo-token XenOrchestra API token
|
||
|
||
Semaphore/Gitea (usually not needed — read from defaults):
|
||
--semaphore-url Semaphore base URL (default: http://localhost:3000)
|
||
--semaphore-token Semaphore API token
|
||
--gitea-url Gitea repo SSH URL
|
||
--project-name Override Semaphore project name
|
||
|
||
Modes:
|
||
--update Update an existing client project (add config, add templates)
|
||
--dry-run Show what would be done without making changes
|
||
|
||
Examples:
|
||
# Minimal — firewall-only client
|
||
$0 -i RP-001 -n 'Royal Pizza' -s royal_pizza --type firewall
|
||
|
||
# Basic Linux client with webhook
|
||
$0 -i RP-001 -n 'Royal Pizza' -s royal_pizza -w https://n8n.voice1.me/webhook/xxx
|
||
|
||
# Full Proxmox client
|
||
$0 -i RP-001 -n 'Royal Pizza' -s royal_pizza --type proxmox \\
|
||
--proxmox-host 10.1.2.3 --proxmox-token-id ansible@pve!token \\
|
||
--proxmox-token-secret abc123 -w https://n8n.voice1.me/webhook/xxx
|
||
|
||
# Add Proxmox to an existing basic client
|
||
$0 -i RP-001 -n 'Royal Pizza' -s royal_pizza --type proxmox --update \\
|
||
--proxmox-host 10.1.2.3 --proxmox-token-id ansible@pve!token \\
|
||
--proxmox-token-secret abc123
|
||
|
||
EOF
|
||
exit 1
|
||
}
|
||
|
||
# ─── Defaults ────────────────────────────────────────────────────────────────
|
||
CLIENT_TYPE="basic"
|
||
BILLING="hybrid"
|
||
ESTIMATE="2700"
|
||
VPN="none"
|
||
SEMAPHORE_URL="http://localhost:3000"
|
||
REPO_DIR="/opt/ansible-msp-automations"
|
||
GITEA_DEPLOY_KEY="/root/.ssh/gitea_ansible"
|
||
GITEA_REPO_URL="ssh://git@172.31.10.8:2222/VOICE1/ansible-msp-automations.git"
|
||
PROXMOX_HOST=""
|
||
PROXMOX_TOKEN_ID=""
|
||
PROXMOX_TOKEN_SECRET=""
|
||
XO_URL=""
|
||
XO_TOKEN=""
|
||
WEBHOOK_URL=""
|
||
PROJECT_NAME_OVERRIDE=""
|
||
UPDATE_MODE=false
|
||
DRY_RUN=false
|
||
|
||
# ─── Parse args ──────────────────────────────────────────────────────────────
|
||
while [[ $# -gt 0 ]]; do
|
||
case $1 in
|
||
-i|--id) CLIENT_ID="$2"; shift 2 ;;
|
||
-n|--name) CLIENT_NAME="$2"; shift 2 ;;
|
||
-s|--slug) CLIENT_SLUG="$2"; shift 2 ;;
|
||
-t|--type) CLIENT_TYPE="$2"; shift 2 ;;
|
||
-w|--webhook) WEBHOOK_URL="$2"; shift 2 ;;
|
||
-b|--billing) BILLING="$2"; shift 2 ;;
|
||
-e|--estimate) ESTIMATE="$2"; shift 2 ;;
|
||
-v|--vpn) VPN="$2"; shift 2 ;;
|
||
--proxmox-host) PROXMOX_HOST="$2"; shift 2 ;;
|
||
--proxmox-token-id) PROXMOX_TOKEN_ID="$2"; shift 2 ;;
|
||
--proxmox-token-secret) PROXMOX_TOKEN_SECRET="$2"; shift 2 ;;
|
||
--xo-url) XO_URL="$2"; shift 2 ;;
|
||
--xo-token) XO_TOKEN="$2"; shift 2 ;;
|
||
--semaphore-url) SEMAPHORE_URL="$2"; shift 2 ;;
|
||
--semaphore-token) SEMAPHORE_TOKEN="$2"; shift 2 ;;
|
||
--gitea-url) GITEA_REPO_URL="$2"; shift 2 ;;
|
||
--project-name) PROJECT_NAME_OVERRIDE="$2"; shift 2 ;;
|
||
--update) UPDATE_MODE=true; shift ;;
|
||
--dry-run) DRY_RUN=true; shift ;;
|
||
-h|--help) usage ;;
|
||
*) echo "Unknown option: $1"; usage ;;
|
||
esac
|
||
done
|
||
|
||
# ─── Validate required args ──────────────────────────────────────────────────
|
||
if [[ -z "${CLIENT_ID:-}" || -z "${CLIENT_NAME:-}" || -z "${CLIENT_SLUG:-}" ]]; then
|
||
echo "ERROR: --id, --name, and --slug are required."
|
||
usage
|
||
fi
|
||
|
||
# Validate client type
|
||
case "$CLIENT_TYPE" in
|
||
basic|linux|proxmox|xcpng|firewall|custom) ;;
|
||
*) echo "ERROR: Invalid --type '$CLIENT_TYPE'. Must be: basic|linux|proxmox|xcpng|firewall|custom"; exit 1 ;;
|
||
esac
|
||
|
||
# Normalize 'linux' as alias for 'basic'
|
||
[[ "$CLIENT_TYPE" == "linux" ]] && CLIENT_TYPE="basic"
|
||
|
||
# Warn if proxmox type but no credentials provided
|
||
if [[ "$CLIENT_TYPE" == "proxmox" && -z "$PROXMOX_HOST" ]]; then
|
||
echo " ⚠ --type proxmox specified but --proxmox-host not set."
|
||
echo " Proxmox templates will be created but credentials will be empty."
|
||
echo " Re-run with --update --proxmox-host ... to add credentials later."
|
||
fi
|
||
|
||
# Load SEMAPHORE_TOKEN from env file if not passed
|
||
if [[ -z "${SEMAPHORE_TOKEN:-}" && -f "/root/.semaphore_env" ]]; then
|
||
source /root/.semaphore_env
|
||
fi
|
||
|
||
if [[ -z "${SEMAPHORE_TOKEN:-}" ]]; then
|
||
echo "ERROR: No Semaphore token. Set SEMAPHORE_TOKEN in /root/.semaphore_env or pass --semaphore-token"
|
||
exit 1
|
||
fi
|
||
|
||
PROJECT_NAME="${PROJECT_NAME_OVERRIDE:-Client - ${CLIENT_NAME}}"
|
||
KEY_FILE="/root/.ssh/client_${CLIENT_SLUG}"
|
||
INVENTORY_DIR="$REPO_DIR/inventories/clients${CLIENT_SLUG}"
|
||
INVENTORY_REPO_PATH="inventories/clients/${CLIENT_SLUG}/hosts.yml"
|
||
|
||
# Determine hypervisor type for inventory
|
||
case "$CLIENT_TYPE" in
|
||
proxmox) HYPERVISOR="proxmox" ;;
|
||
xcpng) HYPERVISOR="xcpng" ;;
|
||
*) HYPERVISOR="none" ;;
|
||
esac
|
||
|
||
# ─── Dry run header ──────────────────────────────────────────────────────────
|
||
if $DRY_RUN; then
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo " DRY RUN — no changes will be made"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
fi
|
||
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
if $UPDATE_MODE; then
|
||
echo " Updating client: $CLIENT_NAME ($CLIENT_ID)"
|
||
else
|
||
echo " Onboarding client: $CLIENT_NAME ($CLIENT_ID)"
|
||
fi
|
||
echo " Type: $CLIENT_TYPE | VPN: $VPN | Billing: $BILLING"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
|
||
# ─── Helper: semaphore API call ───────────────────────────────────────────────
|
||
semaphore_api() {
|
||
local METHOD="$1"
|
||
local PATH="$2"
|
||
local DATA="${3:-}"
|
||
if [[ -n "$DATA" ]]; then
|
||
/usr/bin/curl -s -X "$METHOD" "$SEMAPHORE_URL/api$PATH" \
|
||
-H "Authorization: Bearer $SEMAPHORE_TOKEN" \
|
||
-H "Content-Type: application/json" \
|
||
-d "$DATA"
|
||
else
|
||
/usr/bin/curl -s -X "$METHOD" "$SEMAPHORE_URL/api$PATH" \
|
||
-H "Authorization: Bearer $SEMAPHORE_TOKEN"
|
||
fi
|
||
}
|
||
|
||
# ─── Step 1: SSH key ─────────────────────────────────────────────────────────
|
||
echo ""
|
||
echo "[ 1 ] SSH key..."
|
||
if [[ -f "$KEY_FILE" ]]; then
|
||
echo " ⚠ Key already exists at $KEY_FILE — skipping generation"
|
||
elif $DRY_RUN; then
|
||
echo " [dry-run] Would generate SSH key at $KEY_FILE"
|
||
else
|
||
ssh-keygen -t ed25519 -C "ansible-${CLIENT_SLUG}" -f "$KEY_FILE" -N ""
|
||
echo " ✓ Key generated: $KEY_FILE"
|
||
fi
|
||
|
||
echo ""
|
||
echo " ┌─ Deploy this public key to all client hosts ───────────────────────"
|
||
echo " │"
|
||
[[ -f "$KEY_FILE.pub" ]] && sed 's/^/ │ /' "$KEY_FILE.pub" || echo " │ (key not yet generated)"
|
||
echo " │"
|
||
echo " └────────────────────────────────────────────────────────────────────"
|
||
|
||
# ─── Step 2: Inventory ───────────────────────────────────────────────────────
|
||
echo ""
|
||
echo "[ 2 ] Inventory..."
|
||
|
||
if [[ -d "$INVENTORY_DIR" && ! $UPDATE_MODE ]]; then
|
||
echo " ⚠ Inventory already exists at $INVENTORY_DIR — skipping"
|
||
elif $DRY_RUN; then
|
||
echo " [dry-run] Would create inventory at $INVENTORY_DIR"
|
||
else
|
||
mkdir -p "$INVENTORY_DIR/group_vars"
|
||
|
||
cat > "$INVENTORY_DIR/hosts.yml" << HOSTSEOF
|
||
---
|
||
all:
|
||
vars:
|
||
client_id: "${CLIENT_ID}"
|
||
client_name: "${CLIENT_NAME}"
|
||
billing_model: "${BILLING}"
|
||
maintenance_window_start: "02:00"
|
||
maintenance_window_end: "05:00"
|
||
maintenance_window_tz: "UTC"
|
||
change_freeze: false
|
||
hypervisor_type: "${HYPERVISOR}"
|
||
vpn_type: "${VPN}"
|
||
auto_reboot: false
|
||
human_estimate_seconds: ${ESTIMATE}
|
||
|
||
children:
|
||
linux_hosts:
|
||
hosts: {}
|
||
vars:
|
||
ansible_user: root
|
||
os_family: "debian"
|
||
|
||
windows_hosts:
|
||
hosts: {}
|
||
vars:
|
||
ansible_user: Administrator
|
||
ansible_connection: winrm
|
||
ansible_winrm_transport: ntlm
|
||
ansible_winrm_server_cert_validation: validate
|
||
ansible_port: 5986
|
||
HOSTSEOF
|
||
|
||
cat > "$INVENTORY_DIR/group_vars/all.yml" << VARSEOF
|
||
---
|
||
# Client: ${CLIENT_NAME} (${CLIENT_ID})
|
||
# Onboarded: $(date +%Y-%m-%d)
|
||
# Type: ${CLIENT_TYPE}
|
||
# VPN: ${VPN}
|
||
# Billing: ${BILLING}
|
||
|
||
# Add client-specific overrides below
|
||
VARSEOF
|
||
|
||
echo " ✓ Inventory created at $INVENTORY_DIR"
|
||
fi
|
||
|
||
# ─── Step 3: Commit to Gitea ─────────────────────────────────────────────────
|
||
echo ""
|
||
echo "[ 3 ] Gitea..."
|
||
if $DRY_RUN; then
|
||
echo " [dry-run] Would commit and push to Gitea"
|
||
else
|
||
cd "$REPO_DIR"
|
||
git add .
|
||
git diff --cached --quiet && echo " ⚠ Nothing to commit" || {
|
||
git commit -m "Onboard client: ${CLIENT_NAME} (${CLIENT_ID}) — ${CLIENT_TYPE} inventory scaffold"
|
||
git push origin main
|
||
echo " ✓ Pushed to Gitea"
|
||
}
|
||
fi
|
||
|
||
# ─── Step 4: Semaphore project ───────────────────────────────────────────────
|
||
echo ""
|
||
echo "[ 4 ] Semaphore project..."
|
||
|
||
# Check if project already exists
|
||
EXISTING_PROJECT=$(semaphore_api GET "/projects" | jq -r ".[] | select(.name == \"$PROJECT_NAME\") | .id")
|
||
|
||
if [[ -n "$EXISTING_PROJECT" && ! $UPDATE_MODE ]]; then
|
||
echo " ⚠ Project '$PROJECT_NAME' already exists (ID: $EXISTING_PROJECT) — skipping creation"
|
||
echo " Run with --update to add config to the existing project"
|
||
PROJECT_ID="$EXISTING_PROJECT"
|
||
elif $DRY_RUN; then
|
||
echo " [dry-run] Would create Semaphore project: $PROJECT_NAME"
|
||
PROJECT_ID="DRY_RUN"
|
||
else
|
||
if [[ -n "$EXISTING_PROJECT" ]]; then
|
||
echo " ℹ Update mode — using existing project (ID: $EXISTING_PROJECT)"
|
||
PROJECT_ID="$EXISTING_PROJECT"
|
||
else
|
||
PROJECT_RESPONSE=$(semaphore_api POST "/projects" \
|
||
"{\"name\": \"$PROJECT_NAME\", \"alert\": false, \"max_parallel_tasks\": 0}")
|
||
PROJECT_ID=$(echo "$PROJECT_RESPONSE" | jq -r '.id')
|
||
if [[ "$PROJECT_ID" == "null" || -z "$PROJECT_ID" ]]; then
|
||
echo " ✗ Failed to create project: $PROJECT_RESPONSE"
|
||
exit 1
|
||
fi
|
||
echo " ✓ Project created: $PROJECT_NAME (ID: $PROJECT_ID)"
|
||
fi
|
||
|
||
# Keys — check if they exist first to avoid duplicates on --update
|
||
EXISTING_KEYS=$(semaphore_api GET "/project/$PROJECT_ID/keys")
|
||
|
||
HAS_GITEA=$(echo "$EXISTING_KEYS" | jq -r '.[] | select(.name == "gitea-deploy") | .id')
|
||
if [[ -z "$HAS_GITEA" ]]; then
|
||
GITEA_KEY_RESPONSE=$(semaphore_api POST "/project/$PROJECT_ID/keys" \
|
||
"{\"name\": \"gitea-deploy\", \"type\": \"ssh\", \"project_id\": $PROJECT_ID,
|
||
\"ssh\": {\"login\": \"\", \"passphrase\": \"\", \"private_key\": $(jq -Rs . < "$GITEA_DEPLOY_KEY")}}")
|
||
GITEA_KEY_ID=$(echo "$GITEA_KEY_RESPONSE" | jq -r '.id')
|
||
echo " ✓ gitea-deploy key added (ID: $GITEA_KEY_ID)"
|
||
else
|
||
GITEA_KEY_ID="$HAS_GITEA"
|
||
echo " ⚠ gitea-deploy key already exists (ID: $GITEA_KEY_ID)"
|
||
fi
|
||
|
||
HAS_CLIENT_KEY=$(echo "$EXISTING_KEYS" | jq -r ".[] | select(.name == \"client-${CLIENT_SLUG}-ssh\") | .id")
|
||
if [[ -z "$HAS_CLIENT_KEY" ]]; then
|
||
CLIENT_KEY_RESPONSE=$(semaphore_api POST "/project/$PROJECT_ID/keys" \
|
||
"{\"name\": \"client-${CLIENT_SLUG}-ssh\", \"type\": \"ssh\", \"project_id\": $PROJECT_ID,
|
||
\"ssh\": {\"login\": \"\", \"passphrase\": \"\", \"private_key\": $(jq -Rs . < "$KEY_FILE")}}")
|
||
CLIENT_KEY_ID=$(echo "$CLIENT_KEY_RESPONSE" | jq -r '.id')
|
||
echo " ✓ Client SSH key added (ID: $CLIENT_KEY_ID)"
|
||
else
|
||
CLIENT_KEY_ID="$HAS_CLIENT_KEY"
|
||
echo " ⚠ Client SSH key already exists (ID: $CLIENT_KEY_ID)"
|
||
fi
|
||
|
||
HAS_NONE=$(echo "$EXISTING_KEYS" | jq -r '.[] | select(.name == "None") | .id')
|
||
if [[ -z "$HAS_NONE" ]]; then
|
||
NONE_KEY_RESPONSE=$(semaphore_api POST "/project/$PROJECT_ID/keys" \
|
||
"{\"name\": \"None\", \"type\": \"none\", \"project_id\": $PROJECT_ID}")
|
||
NONE_KEY_ID=$(echo "$NONE_KEY_RESPONSE" | jq -r '.id')
|
||
echo " ✓ None key added (ID: $NONE_KEY_ID)"
|
||
else
|
||
NONE_KEY_ID="$HAS_NONE"
|
||
echo " ⚠ None key already exists (ID: $NONE_KEY_ID)"
|
||
fi
|
||
|
||
# Repository
|
||
HAS_REPO=$(semaphore_api GET "/project/$PROJECT_ID/repositories" | jq -r '.[] | select(.name == "ansible-msp-automations") | .id')
|
||
if [[ -z "$HAS_REPO" ]]; then
|
||
REPO_RESPONSE=$(semaphore_api POST "/project/$PROJECT_ID/repositories" \
|
||
"{\"name\": \"ansible-msp-automations\", \"project_id\": $PROJECT_ID,
|
||
\"git_url\": \"$GITEA_REPO_URL\", \"git_branch\": \"main\",
|
||
\"ssh_key_id\": $GITEA_KEY_ID}")
|
||
REPO_ID=$(echo "$REPO_RESPONSE" | jq -r '.id')
|
||
echo " ✓ Repository linked (ID: $REPO_ID)"
|
||
else
|
||
REPO_ID="$HAS_REPO"
|
||
echo " ⚠ Repository already linked (ID: $REPO_ID)"
|
||
fi
|
||
|
||
# Environment / variable group
|
||
# Build vars JSON — only include non-empty values
|
||
VARS_JSON=$(jq -n \
|
||
--arg webhook "$WEBHOOK_URL" \
|
||
--arg cid "$CLIENT_ID" \
|
||
--arg cname "$CLIENT_NAME" \
|
||
--arg billing "$BILLING" \
|
||
--arg estimate "$ESTIMATE" \
|
||
'{
|
||
CLIENT_ID: $cid,
|
||
CLIENT_NAME: $cname,
|
||
BILLING_MODEL: $billing,
|
||
HUMAN_ESTIMATE_SECONDS: $estimate
|
||
}')
|
||
|
||
# Add webhook only if provided
|
||
[[ -n "$WEBHOOK_URL" ]] && VARS_JSON=$(echo "$VARS_JSON" | jq --arg v "$WEBHOOK_URL" '. + {N8N_WEBHOOK_URL: $v}')
|
||
|
||
# Add Proxmox vars only if provided
|
||
[[ -n "$PROXMOX_HOST" ]] && VARS_JSON=$(echo "$VARS_JSON" | jq --arg v "$PROXMOX_HOST" '. + {PROXMOX_HOST: $v}')
|
||
[[ -n "$PROXMOX_TOKEN_ID" ]] && VARS_JSON=$(echo "$VARS_JSON" | jq --arg v "$PROXMOX_TOKEN_ID" '. + {PROXMOX_TOKEN_ID: $v}')
|
||
[[ -n "$PROXMOX_TOKEN_SECRET" ]] && VARS_JSON=$(echo "$VARS_JSON" | jq --arg v "$PROXMOX_TOKEN_SECRET" '. + {PROXMOX_TOKEN_SECRET: $v}')
|
||
|
||
# Add XO vars only if provided
|
||
[[ -n "$XO_URL" ]] && VARS_JSON=$(echo "$VARS_JSON" | jq --arg v "$XO_URL" '. + {XO_URL: $v}')
|
||
[[ -n "$XO_TOKEN" ]] && VARS_JSON=$(echo "$VARS_JSON" | jq --arg v "$XO_TOKEN" '. + {XO_TOKEN: $v}')
|
||
|
||
HAS_ENV=$(semaphore_api GET "/project/$PROJECT_ID/environment" | jq -r ".[] | select(.name == \"${CLIENT_SLUG}-vars\") | .id")
|
||
if [[ -z "$HAS_ENV" ]]; then
|
||
ENV_RESPONSE=$(semaphore_api POST "/project/$PROJECT_ID/environment" \
|
||
"{\"name\": \"${CLIENT_SLUG}-vars\", \"project_id\": $PROJECT_ID,
|
||
\"json\": $(echo "$VARS_JSON" | jq -Rs .), \"env\": \"{}\"}")
|
||
ENV_ID=$(echo "$ENV_RESPONSE" | jq -r '.id')
|
||
echo " ✓ Variable group created (ID: $ENV_ID)"
|
||
else
|
||
ENV_ID="$HAS_ENV"
|
||
echo " ⚠ Variable group already exists (ID: $ENV_ID) — updating..."
|
||
semaphore_api PUT "/project/$PROJECT_ID/environment/$ENV_ID" \
|
||
"{\"id\": $ENV_ID, \"name\": \"${CLIENT_SLUG}-vars\", \"project_id\": $PROJECT_ID,
|
||
\"json\": $(echo "$VARS_JSON" | jq -Rs .), \"env\": \"{}\"}" > /dev/null
|
||
echo " ✓ Variable group updated"
|
||
fi
|
||
|
||
# Inventory
|
||
HAS_INVENTORY=$(semaphore_api GET "/project/$PROJECT_ID/inventory" | jq -r ".[] | select(.name == \"client-${CLIENT_SLUG}\") | .id")
|
||
if [[ -z "$HAS_INVENTORY" ]]; then
|
||
INVENTORY_RESPONSE=$(semaphore_api POST "/project/$PROJECT_ID/inventory" \
|
||
"{\"name\": \"client-${CLIENT_SLUG}\", \"project_id\": $PROJECT_ID,
|
||
\"inventory\": \"$INVENTORY_REPO_PATH\",
|
||
\"ssh_key_id\": $CLIENT_KEY_ID,
|
||
\"become_key_id\": $NONE_KEY_ID,
|
||
\"type\": \"file\",
|
||
\"repository_id\": $REPO_ID}")
|
||
INVENTORY_ID=$(echo "$INVENTORY_RESPONSE" | jq -r '.id')
|
||
echo " ✓ Inventory created (ID: $INVENTORY_ID)"
|
||
else
|
||
INVENTORY_ID="$HAS_INVENTORY"
|
||
echo " ⚠ Inventory already exists (ID: $INVENTORY_ID)"
|
||
fi
|
||
fi
|
||
|
||
# ─── Step 5: Task templates ──────────────────────────────────────────────────
|
||
echo ""
|
||
echo "[ 5 ] Task templates..."
|
||
|
||
create_template() {
|
||
local TNAME="$1"
|
||
local PLAYBOOK="$2"
|
||
local DESC="$3"
|
||
local EXTRA_ARGS="${4:-}"
|
||
|
||
$DRY_RUN && { echo " [dry-run] Would create template: $TNAME"; return; }
|
||
[[ "$PROJECT_ID" == "DRY_RUN" ]] && return
|
||
|
||
# Skip if template already exists
|
||
EXISTING=$(semaphore_api GET "/project/$PROJECT_ID/templates" | jq -r ".[] | select(.name == \"$TNAME\") | .id")
|
||
if [[ -n "$EXISTING" ]]; then
|
||
echo " ⚠ Template '$TNAME' already exists (ID: $EXISTING) — skipping"
|
||
return
|
||
fi
|
||
|
||
ARGS_JSON="[]"
|
||
[[ -n "$EXTRA_ARGS" ]] && ARGS_JSON="[\"$EXTRA_ARGS\"]"
|
||
|
||
RESPONSE=$(semaphore_api POST "/project/$PROJECT_ID/templates" \
|
||
"{\"project_id\": $PROJECT_ID, \"inventory_id\": $INVENTORY_ID,
|
||
\"repository_id\": $REPO_ID, \"environment_id\": $ENV_ID,
|
||
\"name\": \"$TNAME\", \"playbook\": \"$PLAYBOOK\",
|
||
\"arguments\": \"$ARGS_JSON\",
|
||
\"allow_override_args_in_task\": false,
|
||
\"description\": \"$DESC\", \"app\": \"ansible\"}")
|
||
echo " ✓ $(echo "$RESPONSE" | jq -r '"Template: \(.name) (ID: \(.id))"')"
|
||
}
|
||
|
||
# Templates common to all client types except 'custom'
|
||
if [[ "$CLIENT_TYPE" != "custom" ]]; then
|
||
create_template "Preflight Check" \
|
||
"playbooks/site_preflight.yml" \
|
||
"Run safety checks on all hosts before maintenance"
|
||
fi
|
||
|
||
# Templates for clients with Linux hosts
|
||
if [[ "$CLIENT_TYPE" == "basic" || "$CLIENT_TYPE" == "proxmox" || "$CLIENT_TYPE" == "xcpng" ]]; then
|
||
create_template "Linux Patch" \
|
||
"playbooks/linux_patch.yml" \
|
||
"Full Linux patch run with version tracking"
|
||
create_template "Scheduled Reboot" \
|
||
"playbooks/linux_reboot.yml" \
|
||
"Reboot hosts that require it after kernel updates"
|
||
create_template "Full Maintenance" \
|
||
"playbooks/site_maintenance.yml" \
|
||
"Full maintenance: preflight, patch, reboot"
|
||
fi
|
||
|
||
# Proxmox-specific templates
|
||
if [[ "$CLIENT_TYPE" == "proxmox" ]]; then
|
||
create_template "Proxmox Status" \
|
||
"playbooks/proxmox_status.yml" \
|
||
"Cluster health report — nodes, VMs, CEPH, HA"
|
||
create_template "Proxmox Config Backup" \
|
||
"playbooks/proxmox_config_backup.yml" \
|
||
"Backup Proxmox node configs"
|
||
create_template "Proxmox Rolling Upgrade" \
|
||
"playbooks/proxmox_upgrade.yml" \
|
||
"Rolling upgrade of all Proxmox cluster nodes"
|
||
create_template "Proxmox Migrate VMs" \
|
||
"playbooks/proxmox_migrate_vms.yml" \
|
||
"Migrate VMs between nodes — drain/rebalance/restore/targeted"
|
||
create_template "Proxmox Snapshot" \
|
||
"playbooks/proxmox_snapshot.yml" \
|
||
"Pre/post maintenance VM snapshots"
|
||
fi
|
||
|
||
# XCP-NG-specific templates
|
||
if [[ "$CLIENT_TYPE" == "xcpng" ]]; then
|
||
create_template "XCP-NG Pool Upgrade" \
|
||
"playbooks/xcp_pool_upgrade.yml" \
|
||
"Rolling XCP-NG pool upgrade"
|
||
create_template "Snapshot (XCP-NG)" \
|
||
"playbooks/xcp_xo_vm_snapshot.yml" \
|
||
"Pre-maintenance VM snapshots via XenOrchestra"
|
||
create_template "Snapshot Cleanup" \
|
||
"playbooks/xcp_xo_snapshot_cleanup.yml" \
|
||
"Clean up old XCP-NG snapshots"
|
||
create_template "Migrate VMs (XCP-NG)" \
|
||
"playbooks/xcp_xo_vm_migrate.yml" \
|
||
"VM migration via XenOrchestra"
|
||
fi
|
||
|
||
# ─── Step 6: Summary ─────────────────────────────────────────────────────────
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
if $DRY_RUN; then
|
||
echo " [dry-run complete] No changes made."
|
||
elif $UPDATE_MODE; then
|
||
echo " ✓ Client update complete: $CLIENT_NAME"
|
||
else
|
||
echo " ✓ Client onboarding complete: $CLIENT_NAME"
|
||
fi
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
[[ "$PROJECT_ID" != "DRY_RUN" ]] && echo " Semaphore project ID: $PROJECT_ID"
|
||
echo " Inventory: $INVENTORY_DIR/hosts.yml"
|
||
echo " Client type: $CLIENT_TYPE"
|
||
echo ""
|
||
echo " Next steps:"
|
||
echo " 1. Add hosts to: $INVENTORY_DIR/hosts.yml"
|
||
echo " 2. git add . && git commit -m 'Add hosts for ${CLIENT_NAME}' && git push origin main"
|
||
echo " 3. Run 'Preflight Check' in Semaphore to verify connectivity"
|
||
echo ""
|
||
if [[ -f "$KEY_FILE.pub" ]]; then
|
||
echo " Public key to deploy to client hosts:"
|
||
sed 's/^/ /' "$KEY_FILE.pub"
|
||
echo ""
|
||
fi |