Files
ansible-msp-automations/scripts/onboard_client.sh

555 lines
23 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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