Add XCP-NG integration, deploy_agent.sh, overhaul onboard_client.sh

- roles/xcpng_update: new role — patches XCP-NG pools via XO REST API
  - non-HA pools: pool-level install_patches + restart_hosts
  - HA clusters: rolling pool update via JSON-RPC pool.rollingUpdate
  - dry run support, patch verification after update
- roles/snapshot: add xcpng_xo hypervisor_type support via XO REST API
- playbooks/xcpng_pool_update.yml: new playbook for XCP-NG pool patching
- inventories/client_template/hosts.yml: add xcpng_hosts group
- scripts/onboard_client.sh: major overhaul
  - add --hypervisor proxmox|xcpng|baremetal|mixed
  - add --xo-url / --xo-token (falls back to global env)
  - webhook no longer required (falls back to N8N_WEBHOOK_URL in env)
  - ansible_user changed to ansible-msp-agent with sudo
  - xcpng_hosts group in inventory scaffold for xcpng/mixed clients
  - hypervisor-appropriate task templates created automatically
  - add --dry-run support
- scripts/deploy_agent.sh: new script — bootstrap ansible-msp-agent
  - reads hosts.yml to get Linux/Windows hosts
  - SSHes as native account, su - to root
  - creates ansible-msp-agent user + sudo-nopasswd group
  - deploys client key + MSP backup key to agent user and root
  - adjusts sshd_config, reloads sshd
  - verifies key-based login after bootstrap
  - Windows stub with skip + warning
  - continues on failure, prints summary
This commit is contained in:
Semaphore
2026-03-12 11:15:43 -07:00
parent 5b846654ba
commit a42bf14665
2 changed files with 757 additions and 196 deletions

469
scripts/deploy_agent.sh Executable file
View File

@@ -0,0 +1,469 @@
#!/bin/bash
# =============================================================================
# deploy_agent.sh — MSP Agent Bootstrap Script
# =============================================================================
# Connects to Linux hosts defined in a client hosts.yml, creates the
# ansible-msp-agent service account, deploys SSH keys, configures sudoers,
# and hardens sshd_config.
#
# Usage:
# ./deploy_agent.sh --inventory /path/to/client_xxx/hosts.yml [options]
#
# Options:
# --inventory Path to client hosts.yml (required)
# --native-user Username to SSH in with (default: localcontrol)
# --native-pass Password for native user (will prompt if not provided)
# --root-pass Root password for su - (will prompt if not provided)
# --agent-user Service account to create (default: ansible-msp-agent)
# --client-key Path to client public key (default: auto-derived from inventory path)
# --msp-key Path to MSP backup public key file (default: /root/.ssh/ansible-msp-agent.pub)
# --key-repo-dir If set, look for public keys in this git repo dir instead
# --dry-run Show what would be done without making changes
# --skip-sshd Skip sshd_config modifications
# --help Show this help
#
# Dependencies: sshpass, python3, python3-yaml, ssh, ssh-keyscan
# =============================================================================
set -euo pipefail
# ─── Defaults ────────────────────────────────────────────────────────────────
NATIVE_USER="localcontrol"
NATIVE_PASS=""
ROOT_PASS=""
AGENT_USER="ansible-msp-agent"
CLIENT_KEY_PATH="" # auto-derived if empty
MSP_KEY_PATH="/root/.ssh/ansible-msp-agent.pub"
KEY_REPO_DIR="" # future: point to keys/ dir in git repo
DRY_RUN=false
SKIP_SSHD=false
INVENTORY_PATH=""
REPO_DIR="/opt/ansible-msp-automations"
# Load MSP backup key from environment if file not present
# Set MSP_BACKUP_PUBKEY in /root/.semaphore_env to avoid needing the file
if [[ -f /root/.semaphore_env ]]; then
source /root/.semaphore_env
fi
# ─── Counters ────────────────────────────────────────────────────────────────
HOSTS_TOTAL=0
HOSTS_OK=0
HOSTS_FAILED=0
HOSTS_SKIPPED=0
FAILED_HOSTS=()
# ─── Colors ──────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log_info() { echo -e "${BLUE} $*${NC}"; }
log_ok() { echo -e "${GREEN}$*${NC}"; }
log_warn() { echo -e "${YELLOW}$*${NC}"; }
log_error() { echo -e "${RED}$*${NC}"; }
log_section() { echo -e "\n${BLUE}━━━ $* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"; }
# ─── Parse args ──────────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case $1 in
--inventory) INVENTORY_PATH="$2"; shift 2 ;;
--native-user) NATIVE_USER="$2"; shift 2 ;;
--native-pass) NATIVE_PASS="$2"; shift 2 ;;
--root-pass) ROOT_PASS="$2"; shift 2 ;;
--agent-user) AGENT_USER="$2"; shift 2 ;;
--client-key) CLIENT_KEY_PATH="$2"; shift 2 ;;
--msp-key) MSP_KEY_PATH="$2"; shift 2 ;;
--key-repo-dir) KEY_REPO_DIR="$2"; shift 2 ;;
--dry-run) DRY_RUN=true; shift ;;
--skip-sshd) SKIP_SSHD=true; shift ;;
--help)
head -30 "$0" | grep "^#" | sed 's/^# \?//'
exit 0
;;
*)
log_error "Unknown option: $1"
exit 1
;;
esac
done
# ─── Validate ────────────────────────────────────────────────────────────────
if [[ -z "$INVENTORY_PATH" ]]; then
log_error "--inventory is required"
exit 1
fi
if [[ ! -f "$INVENTORY_PATH" ]]; then
log_error "Inventory file not found: $INVENTORY_PATH"
exit 1
fi
# Check dependencies
for dep in sshpass python3 ssh ssh-keyscan; do
if ! command -v "$dep" &>/dev/null; then
log_error "Missing dependency: $dep"
echo " Install with: apt install sshpass python3 python3-yaml openssh-client"
exit 1
fi
done
python3 -c "import yaml" 2>/dev/null || {
log_error "python3-yaml not installed: apt install python3-yaml"
exit 1
}
# ─── Derive client key path from inventory path if not set ───────────────────
if [[ -z "$CLIENT_KEY_PATH" ]]; then
# Extract slug from inventory path: .../inventories/client_foo/hosts.yml -> client_foo
INVENTORY_DIR=$(dirname "$INVENTORY_PATH")
SLUG=$(basename "$INVENTORY_DIR")
# Remove client_ prefix for key name
KEY_SLUG="${SLUG#client_}"
# Check key repo dir first if set
if [[ -n "$KEY_REPO_DIR" && -f "$KEY_REPO_DIR/keys/client_${KEY_SLUG}.pub" ]]; then
CLIENT_KEY_PATH="$KEY_REPO_DIR/keys/client_${KEY_SLUG}.pub"
else
CLIENT_KEY_PATH="/root/.ssh/client_${KEY_SLUG}.pub"
fi
fi
# Check key repo dir for MSP key if set
if [[ -n "$KEY_REPO_DIR" && -f "$KEY_REPO_DIR/keys/ansible-msp-agent.pub" ]]; then
MSP_KEY_PATH="$KEY_REPO_DIR/keys/ansible-msp-agent.pub"
fi
if [[ ! -f "$CLIENT_KEY_PATH" ]]; then
log_error "Client public key not found: $CLIENT_KEY_PATH"
log_error "Generate it with: ssh-keygen -t ed25519 -f ${CLIENT_KEY_PATH%.pub}"
exit 1
fi
# Resolve MSP public key — file takes priority, fall back to env var
if [[ -f "$MSP_KEY_PATH" ]]; then
MSP_PUBKEY=$(cat "$MSP_KEY_PATH")
elif [[ -n "${MSP_BACKUP_PUBKEY:-}" ]]; then
MSP_PUBKEY="$MSP_BACKUP_PUBKEY"
log_info "MSP key loaded from environment (MSP_BACKUP_PUBKEY)"
else
log_error "MSP backup public key not found: $MSP_KEY_PATH"
log_error "Set MSP_BACKUP_PUBKEY in /root/.semaphore_env or pass --msp-key"
exit 1
fi
CLIENT_PUBKEY=$(cat "$CLIENT_KEY_PATH")
# ─── Prompt for passwords if not provided ────────────────────────────────────
if [[ -z "$NATIVE_PASS" ]]; then
echo -n "Password for ${NATIVE_USER}: "
read -rs NATIVE_PASS
echo
fi
if [[ -z "$ROOT_PASS" ]]; then
echo -n "Root password (for su -): "
read -rs ROOT_PASS
echo
fi
# ─── Parse inventory for hosts ───────────────────────────────────────────────
log_section "Parsing inventory"
log_info "Inventory: $INVENTORY_PATH"
# Extract Linux and Windows hosts using Python
HOST_DATA=$(python3 << PYEOF
import yaml, json, sys
with open('$INVENTORY_PATH') as f:
inv = yaml.safe_load(f)
linux_hosts = []
windows_hosts = []
def extract_hosts(group, target_list):
if not group:
return
hosts = group.get('hosts') or {}
group_vars = group.get('vars') or {}
for hostname, hvars in (hosts or {}).items():
hvars = hvars or {}
merged = {**group_vars, **hvars}
ip = merged.get('ansible_host', hostname)
target_list.append({'name': hostname, 'ip': ip})
children = (inv.get('all') or {}).get('children') or {}
extract_hosts(children.get('linux_hosts'), linux_hosts)
extract_hosts(children.get('windows_hosts'), windows_hosts)
print(json.dumps({'linux': linux_hosts, 'windows': windows_hosts}))
PYEOF
)
LINUX_HOSTS=$(echo "$HOST_DATA" | python3 -c "import json,sys; d=json.load(sys.stdin); [print(h['name']+'|'+h['ip']) for h in d['linux']]")
WINDOWS_HOSTS=$(echo "$HOST_DATA" | python3 -c "import json,sys; d=json.load(sys.stdin); [print(h['name']+'|'+h['ip']) for h in d['windows']]")
LINUX_COUNT=$(echo "$LINUX_HOSTS" | grep -c '.' || true)
WINDOWS_COUNT=$(echo "$WINDOWS_HOSTS" | grep -c '.' || true)
log_info "Linux hosts found: $LINUX_COUNT"
log_info "Windows hosts found: $WINDOWS_COUNT (skipped — WinRM setup not yet implemented)"
if [[ "$DRY_RUN" == "true" ]]; then
log_warn "DRY RUN MODE — no changes will be made"
fi
# ─── Windows stub ────────────────────────────────────────────────────────────
if [[ -n "$WINDOWS_HOSTS" ]]; then
log_section "Windows hosts (stub)"
while IFS='|' read -r hostname ip; do
[[ -z "$hostname" ]] && continue
log_warn "SKIP $hostname ($ip) — Windows host, WinRM/SSH setup not yet implemented"
((HOSTS_SKIPPED++)) || true
done <<< "$WINDOWS_HOSTS"
fi
# ─── Remote setup script ─────────────────────────────────────────────────────
# This heredoc is the script executed on each remote host as root
build_remote_script() {
local HOST_CLIENT_PUBKEY="$1"
local HOST_MSP_PUBKEY="$2"
local HOST_AGENT_USER="$3"
local HOST_SKIP_SSHD="$4"
cat << REMOTESCRIPT
#!/bin/bash
set -e
AGENT_USER="${HOST_AGENT_USER}"
CLIENT_PUBKEY='${HOST_CLIENT_PUBKEY}'
MSP_PUBKEY='${HOST_MSP_PUBKEY}'
SKIP_SSHD="${HOST_SKIP_SSHD}"
echo "[remote] Starting agent bootstrap on \$(hostname)"
# ── Create sudo-nopasswd group if missing ──
if ! getent group sudo-nopasswd > /dev/null 2>&1; then
groupadd sudo-nopasswd
echo "[remote] Created group: sudo-nopasswd"
else
echo "[remote] Group sudo-nopasswd already exists"
fi
# ── Create agent user if missing ──
if ! id "\$AGENT_USER" > /dev/null 2>&1; then
useradd -m -s /bin/bash -G sudo,sudo-nopasswd "\$AGENT_USER"
echo "[remote] Created user: \$AGENT_USER"
else
echo "[remote] User \$AGENT_USER already exists — ensuring group membership"
usermod -aG sudo,sudo-nopasswd "\$AGENT_USER" || true
fi
# ── Sudoers ──
SUDOERS_FILE="/etc/sudoers.d/99-ansible-nopasswd"
cat > "\$SUDOERS_FILE" << SUDOEOF
# Managed by ansible-msp deploy_agent.sh
# Members of sudo-nopasswd group can run all commands without password
%sudo-nopasswd ALL=(ALL) NOPASSWD:ALL
SUDOEOF
chmod 440 "\$SUDOERS_FILE"
visudo -cf "\$SUDOERS_FILE" && echo "[remote] Sudoers file validated OK" || {
echo "[remote] ERROR: sudoers file invalid — removing"
rm -f "\$SUDOERS_FILE"
exit 1
}
# ── Deploy SSH keys to agent user ──
AGENT_SSH_DIR="/home/\$AGENT_USER/.ssh"
mkdir -p "\$AGENT_SSH_DIR"
chmod 700 "\$AGENT_SSH_DIR"
AUTH_KEYS="\$AGENT_SSH_DIR/authorized_keys"
touch "\$AUTH_KEYS"
# Add client key if not present
if ! grep -qF "\$CLIENT_PUBKEY" "\$AUTH_KEYS" 2>/dev/null; then
echo "\$CLIENT_PUBKEY" >> "\$AUTH_KEYS"
echo "[remote] Client key added to \$AGENT_USER"
else
echo "[remote] Client key already present for \$AGENT_USER"
fi
# Add MSP backup key if not present
if ! grep -qF "\$MSP_PUBKEY" "\$AUTH_KEYS" 2>/dev/null; then
echo "\$MSP_PUBKEY" >> "\$AUTH_KEYS"
echo "[remote] MSP backup key added to \$AGENT_USER"
else
echo "[remote] MSP backup key already present for \$AGENT_USER"
fi
chmod 600 "\$AUTH_KEYS"
chown -R "\$AGENT_USER:\$AGENT_USER" "\$AGENT_SSH_DIR"
# ── Deploy SSH keys to root ──
ROOT_SSH_DIR="/root/.ssh"
mkdir -p "\$ROOT_SSH_DIR"
chmod 700 "\$ROOT_SSH_DIR"
ROOT_AUTH_KEYS="\$ROOT_SSH_DIR/authorized_keys"
touch "\$ROOT_AUTH_KEYS"
if ! grep -qF "\$CLIENT_PUBKEY" "\$ROOT_AUTH_KEYS" 2>/dev/null; then
echo "\$CLIENT_PUBKEY" >> "\$ROOT_AUTH_KEYS"
echo "[remote] Client key added to root"
else
echo "[remote] Client key already present for root"
fi
if ! grep -qF "\$MSP_PUBKEY" "\$ROOT_AUTH_KEYS" 2>/dev/null; then
echo "\$MSP_PUBKEY" >> "\$ROOT_AUTH_KEYS"
echo "[remote] MSP backup key added to root"
else
echo "[remote] MSP backup key already present for root"
fi
chmod 600 "\$ROOT_AUTH_KEYS"
# ── Adjust sshd_config ──
if [[ "\$SKIP_SSHD" != "true" ]]; then
SSHD_CONFIG="/etc/ssh/sshd_config"
set_sshd_option() {
local KEY="\$1"
local VALUE="\$2"
if grep -qE "^#?\s*\${KEY}\s" "\$SSHD_CONFIG"; then
sed -i "s|^#\?\s*\${KEY}\s.*|\${KEY} \${VALUE}|" "\$SSHD_CONFIG"
else
echo "\${KEY} \${VALUE}" >> "\$SSHD_CONFIG"
fi
echo "[remote] sshd_config: \${KEY} = \${VALUE}"
}
set_sshd_option "PubkeyAuthentication" "yes"
set_sshd_option "PermitRootLogin" "prohibit-password"
set_sshd_option "AuthorizedKeysFile" ".ssh/authorized_keys"
# Reload sshd
if command -v systemctl &>/dev/null; then
systemctl reload sshd 2>/dev/null || systemctl reload ssh 2>/dev/null || true
else
service sshd reload 2>/dev/null || service ssh reload 2>/dev/null || true
fi
echo "[remote] sshd reloaded"
fi
echo "[remote] Bootstrap complete on \$(hostname)"
REMOTESCRIPT
}
# ─── Process Linux hosts ─────────────────────────────────────────────────────
if [[ -z "$LINUX_HOSTS" ]]; then
log_warn "No Linux hosts found in inventory"
exit 0
fi
log_section "Processing Linux hosts"
while IFS='|' read -r HOSTNAME HOST_IP; do
[[ -z "$HOSTNAME" ]] && continue
((HOSTS_TOTAL++)) || true
echo ""
log_section "Host: $HOSTNAME ($HOST_IP)"
if [[ "$DRY_RUN" == "true" ]]; then
log_info "DRY RUN: Would bootstrap $HOSTNAME ($HOST_IP) as $NATIVE_USER → root → create $AGENT_USER"
((HOSTS_OK++)) || true
continue
fi
# Add host to known_hosts
log_info "Scanning host key..."
ssh-keyscan -T 10 "$HOST_IP" >> /root/.ssh/known_hosts 2>/dev/null || true
# Test native user SSH access
log_info "Testing SSH as $NATIVE_USER..."
if ! sshpass -p "$NATIVE_PASS" ssh -o StrictHostKeyChecking=no \
-o ConnectTimeout=10 \
-o PasswordAuthentication=yes \
"$NATIVE_USER@$HOST_IP" "echo connected" &>/dev/null; then
log_error "Cannot SSH to $HOSTNAME ($HOST_IP) as $NATIVE_USER — skipping"
FAILED_HOSTS+=("$HOSTNAME ($HOST_IP) — SSH connection failed")
((HOSTS_FAILED++)) || true
continue
fi
log_ok "SSH connection successful"
# Build remote script
REMOTE_SCRIPT=$(build_remote_script \
"$CLIENT_PUBKEY" \
"$MSP_PUBKEY" \
"$AGENT_USER" \
"$SKIP_SSHD")
# Execute via su - on remote host
log_info "Executing bootstrap via su - root..."
BOOTSTRAP_OUTPUT=$(sshpass -p "$NATIVE_PASS" ssh \
-o StrictHostKeyChecking=no \
-o ConnectTimeout=10 \
-o PasswordAuthentication=yes \
"$NATIVE_USER@$HOST_IP" \
"echo '$ROOT_PASS' | su - root -c 'bash -s'" <<< "$REMOTE_SCRIPT" 2>&1) || {
log_error "Bootstrap script failed on $HOSTNAME"
echo "$BOOTSTRAP_OUTPUT" | sed 's/^/ /'
FAILED_HOSTS+=("$HOSTNAME ($HOST_IP) — bootstrap script failed")
((HOSTS_FAILED++)) || true
continue
}
# Show remote output
echo "$BOOTSTRAP_OUTPUT" | grep "\[remote\]" | sed 's/^/ /'
# Verify key-based login works for agent user
log_info "Verifying key-based login for $AGENT_USER..."
CLIENT_PRIVKEY="${CLIENT_KEY_PATH%.pub}"
if [[ -f "$CLIENT_PRIVKEY" ]]; then
if ssh -i "$CLIENT_PRIVKEY" \
-o StrictHostKeyChecking=no \
-o ConnectTimeout=10 \
-o PasswordAuthentication=no \
"$AGENT_USER@$HOST_IP" "echo key-auth-ok" &>/dev/null; then
log_ok "Key-based login verified for $AGENT_USER"
else
log_warn "Key-based login test failed for $AGENT_USER — check manually"
fi
else
log_warn "Private key not found at $CLIENT_PRIVKEY — skipping login verification"
fi
log_ok "Bootstrap complete: $HOSTNAME"
((HOSTS_OK++)) || true
done <<< "$LINUX_HOSTS"
# ─── Summary ─────────────────────────────────────────────────────────────────
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Bootstrap Summary"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Total Linux hosts: $HOSTS_TOTAL"
echo -e " ${GREEN}Succeeded: $HOSTS_OK${NC}"
if [[ $HOSTS_FAILED -gt 0 ]]; then
echo -e " ${RED}Failed: $HOSTS_FAILED${NC}"
echo ""
echo " Failed hosts:"
for h in "${FAILED_HOSTS[@]}"; do
echo -e " ${RED}$h${NC}"
done
fi
if [[ $HOSTS_SKIPPED -gt 0 ]]; then
echo -e " ${YELLOW}Skipped (Windows): $HOSTS_SKIPPED${NC}"
fi
echo ""
if [[ $HOSTS_FAILED -gt 0 ]]; then
exit 1
fi

View File

@@ -1,39 +1,42 @@
#!/bin/bash
set -e
# ─── Usage ───────────────────────────────────────────────────────────────────
usage() {
echo "Usage: $0 [options]"
echo ""
echo "Options:"
echo " -i, --id Client ID (e.g. DFA-001)"
echo " -n, --name Client name (e.g. 'DFA Tech Colo')"
echo " -s, --slug Short slug for filenames (e.g. dfa_tech)"
echo " -w, --webhook n8n webhook URL"
echo " -b, --billing Billing model (hybrid|per-host|time-saved|tiered) default: hybrid"
echo " -e, --estimate Human time estimate seconds (default: 2700)"
echo " -v, --vpn VPN type (ipsec|openvpn) default: ipsec"
echo " -H, --hypervisor Hypervisor type (proxmox|xcpng) default: proxmox"
echo " --proxmox-host Proxmox host IP (optional)"
echo " --proxmox-token-id Proxmox API token ID (e.g. ansible@pve!token-name)"
echo " --proxmox-token-secret Proxmox API token secret"
echo " --semaphore-url Semaphore base URL (default: http://localhost:3000)"
echo " --semaphore-token Semaphore API token"
echo " --gitea-url Gitea repo SSH URL"
echo " --project-name Semaphore project name (default: 'Client - <n>')"
echo ""
echo "Example:"
echo " $0 -i ACME-001 -n 'Acme Corp' -s acme_corp -w https://n8n.voice1.me/webhook/xxx \\"
echo " --proxmox-host 10.1.2.3 --proxmox-token-id ansible@pve!ansible-token \\"
echo " --proxmox-token-secret abc123"
exit 1
}
# =============================================================================
# scripts/onboard_client.sh — MSP Client Onboarding
# =============================================================================
# Creates a new client project in Semaphore, generates SSH keys, scaffolds
# the inventory, and creates all task templates.
#
# Usage:
# ./onboard_client.sh -i CLIENT-001 -n "Client Name" -s client_slug [options]
#
# Options:
# -i, --id Client ID (e.g. SRH-001) [required]
# -n, --name Client name (e.g. 'Sanrufo Homes') [required]
# -s, --slug Inventory slug (e.g. sanrufo_homes) [required]
# -b, --billing Billing model (default: hybrid)
# -e, --estimate Human time estimate seconds (default: 2700)
# -H, --hypervisor Hypervisor type: proxmox|xcpng|baremetal|mixed
# (default: xcpng)
# Use 'mixed' when a client has multiple hypervisor types
# Use 'baremetal' when no snapshots are possible
# -w, --webhook n8n webhook URL override (default: global from env)
# --proxmox-host Proxmox host IP
# --proxmox-token-id Proxmox API token ID
# --proxmox-token-secret Proxmox API token secret
# --xo-url XO URL override (default: global XO_URL from env)
# --xo-token XO token override (default: global XO_TOKEN from env)
# --semaphore-url Semaphore base URL (default: http://localhost:3000)
# --semaphore-token Semaphore API token (default: from /root/.semaphore_env)
# --gitea-url Gitea repo SSH URL
# --project-name Override Semaphore project name
# --dry-run Show what would be done without making changes
# =============================================================================
# ─── Defaults ────────────────────────────────────────────────────────────────
BILLING="hybrid"
ESTIMATE="2700"
VPN="ipsec"
HYPERVISOR="proxmox"
HYPERVISOR="xcpng"
SEMAPHORE_URL="http://localhost:3000"
REPO_DIR="/opt/ansible-msp-automations"
GITEA_DEPLOY_KEY="/root/.ssh/gitea_ansible"
@@ -41,7 +44,32 @@ 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_OVERRIDE=""
XO_TOKEN_OVERRIDE=""
WEBHOOK_URL_OVERRIDE=""
PROJECT_NAME_OVERRIDE=""
DRY_RUN=false
# Load global defaults from env file
if [[ -f /root/.semaphore_env ]]; then
source /root/.semaphore_env
fi
# ─── Colors / logging ────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; NC='\033[0m'
log_info() { echo -e "${BLUE} $*${NC}"; }
log_ok() { echo -e "${GREEN}$*${NC}"; }
log_warn() { echo -e "${YELLOW}$*${NC}"; }
log_error() { echo -e "${RED}$*${NC}"; }
log_section() { echo -e "\n${BLUE}[ $* ]${NC}"; }
dry() { [[ "$DRY_RUN" == "true" ]] && echo -e "${YELLOW} DRY-RUN: $*${NC}" && return 0 || return 1; }
# ─── Usage ───────────────────────────────────────────────────────────────────
usage() {
grep "^#" "$0" | head -40 | sed 's/^# \?//'
exit 1
}
# ─── Parse args ──────────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
@@ -49,31 +77,54 @@ while [[ $# -gt 0 ]]; do
-i|--id) CLIENT_ID="$2"; shift 2 ;;
-n|--name) CLIENT_NAME="$2"; shift 2 ;;
-s|--slug) CLIENT_SLUG="$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 ;;
-H|--hypervisor) HYPERVISOR="$2"; shift 2 ;;
-w|--webhook) WEBHOOK_URL_OVERRIDE="$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_OVERRIDE="$2"; shift 2 ;;
--xo-token) XO_TOKEN_OVERRIDE="$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 ;;
*) usage ;;
--project-name) PROJECT_NAME_OVERRIDE="$2";shift 2 ;;
--dry-run) DRY_RUN=true; shift ;;
-h|--help) usage ;;
*) log_error "Unknown option: $1"; usage ;;
esac
done
# ─── Validate required args ──────────────────────────────────────────────────
if [[ -z "${CLIENT_ID:-}" || -z "${CLIENT_NAME:-}" || -z "${CLIENT_SLUG:-}" || -z "${WEBHOOK_URL:-}" ]]; then
echo "ERROR: --id, --name, --slug, and --webhook are required"
MISSING=()
[[ -z "${CLIENT_ID:-}" ]] && MISSING+=("--id")
[[ -z "${CLIENT_NAME:-}" ]] && MISSING+=("--name")
[[ -z "${CLIENT_SLUG:-}" ]] && MISSING+=("--slug")
if [[ ${#MISSING[@]} -gt 0 ]]; then
log_error "Missing required arguments: ${MISSING[*]}"
usage
fi
# Load SEMAPHORE_TOKEN from env file if not passed as arg
if [[ -z "${SEMAPHORE_TOKEN:-}" && -f "/root/.semaphore_env" ]]; then
source /root/.semaphore_env
case "$HYPERVISOR" in
proxmox|xcpng|baremetal|mixed) ;;
*) log_error "Invalid hypervisor type: $HYPERVISOR (use: proxmox|xcpng|baremetal|mixed)"; exit 1 ;;
esac
if [[ -z "${SEMAPHORE_TOKEN:-}" ]]; then
log_error "No SEMAPHORE_TOKEN available. Set in /root/.semaphore_env or pass --semaphore-token"
exit 1
fi
# Resolve XO vars — per-client override takes priority, then global env
EFFECTIVE_XO_URL="${XO_URL_OVERRIDE:-${XO_URL:-}}"
EFFECTIVE_XO_TOKEN="${XO_TOKEN_OVERRIDE:-${XO_TOKEN:-}}"
# Resolve webhook — per-client override takes priority, then global env
EFFECTIVE_WEBHOOK="${WEBHOOK_URL_OVERRIDE:-${N8N_WEBHOOK_URL:-}}"
if [[ -z "$EFFECTIVE_WEBHOOK" ]]; then
log_error "No webhook URL. Set N8N_WEBHOOK_URL in /root/.semaphore_env or pass --webhook"
exit 1
fi
PROJECT_NAME="${PROJECT_NAME_OVERRIDE:-Client - ${CLIENT_NAME}}"
@@ -82,37 +133,78 @@ INVENTORY_DIR="$REPO_DIR/inventories/client_${CLIENT_SLUG}"
INVENTORY_REPO_PATH="inventories/client_${CLIENT_SLUG}/hosts.yml"
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Onboarding client: $CLIENT_NAME ($CLIENT_ID)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Onboarding: $CLIENT_NAME ($CLIENT_ID)"
echo " Slug: client_${CLIENT_SLUG}"
echo " Hypervisor: $HYPERVISOR"
echo " Billing: $BILLING"
[[ "$DRY_RUN" == "true" ]] && echo -e " ${YELLOW}DRY RUN MODE — no changes will be made${NC}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# ─── Step 1: Generate SSH key ─────────────────────────────────────────────────
echo ""
echo "[ 1/6 ] Generating SSH key..."
log_section "1/6 — SSH key"
if [[ -f "$KEY_FILE" ]]; then
echo "Key already exists at $KEY_FILE — skipping generation"
log_warn "Key already exists at $KEY_FILE — skipping generation"
else
if ! dry "ssh-keygen -t ed25519 -C ansible-${CLIENT_SLUG} -f $KEY_FILE -N ''"; then
ssh-keygen -t ed25519 -C "ansible-${CLIENT_SLUG}" -f "$KEY_FILE" -N ""
echo "Key generated: $KEY_FILE"
log_ok "Key generated: $KEY_FILE"
fi
fi
echo ""
echo " ┌─ Deploy this public key to all client hosts ───────────────────────"
echo " ┌─ Public key to deploy to all client hosts ──────────────────────────"
echo " │"
sed 's/^/ │ /' "$KEY_FILE.pub"
sed 's/^/ │ /' "$KEY_FILE.pub" 2>/dev/null || echo " │ (dry run — key not yet generated)"
echo " │"
echo " └────────────────────────────────────────────────────────────────────"
echo " └────────────────────────────────────────────────────────────────────"
# ─── Step 2: Create inventory from template ───────────────────────────────────
echo ""
echo "[ 2/6 ] Creating inventory from template..."
log_section "2/6 — Inventory scaffold"
if [[ -d "$INVENTORY_DIR" ]]; then
echo " ⚠ Inventory directory already exists — skipping"
log_warn "Inventory already exists at $INVENTORY_DIR — skipping"
else
if ! dry "cp -r $REPO_DIR/inventories/client_template $INVENTORY_DIR"; then
cp -r "$REPO_DIR/inventories/client_template" "$INVENTORY_DIR"
# Determine which hypervisor groups to include
INCLUDE_XCPNG=false
INCLUDE_PROXMOX=false
case "$HYPERVISOR" in
xcpng) INCLUDE_XCPNG=true ;;
proxmox) INCLUDE_PROXMOX=true ;;
mixed) INCLUDE_XCPNG=true; INCLUDE_PROXMOX=true ;;
baremetal) ;;
esac
XCPNG_BLOCK=""
if [[ "$INCLUDE_XCPNG" == "true" ]]; then
XCPNG_BLOCK=$(cat << 'XCPNGEOF'
# XCP-NG pool entries — one entry per pool (not per hypervisor host)
# Each entry triggers xcpng_pool_update.yml against that pool via XO REST API
# Required per host: xo_pool_uuid
# XO_URL and XO_TOKEN come from Semaphore variable group (override here if needed)
xcpng_hosts:
hosts: {}
vars:
ansible_connection: local
XCPNGEOF
)
fi
cat > "$INVENTORY_DIR/hosts.yml" << HOSTSEOF
---
# Client: ${CLIENT_NAME} (${CLIENT_ID})
# Onboarded: $(date +%Y-%m-%d)
# Hypervisor: ${HYPERVISOR}
# Billing: ${BILLING}
#
# ansible_user: ansible-msp-agent (deployed by scripts/deploy_agent.sh)
# Do NOT use root as ansible_user for day-to-day operations.
all:
vars:
client_id: "${CLIENT_ID}"
@@ -123,7 +215,6 @@ all:
maintenance_window_tz: "UTC"
change_freeze: false
hypervisor_type: "${HYPERVISOR}"
vpn_type: "${VPN}"
auto_reboot: false
human_estimate_seconds: ${ESTIMATE}
@@ -131,8 +222,13 @@ all:
linux_hosts:
hosts: {}
vars:
ansible_user: root
os_family: "debian"
ansible_user: ansible-msp-agent
ansible_become: true
ansible_become_method: sudo
# Per-host vars to set:
# ansible_host: <ip>
# xcpng_vm_uuid: <uuid> (if hypervisor is xcpng or mixed)
# proxmox_vmid: <id> (if hypervisor is proxmox or mixed)
windows_hosts:
hosts: {}
@@ -142,119 +238,96 @@ all:
ansible_winrm_transport: ntlm
ansible_winrm_server_cert_validation: validate
ansible_port: 5986
# Windows patching not yet implemented — hosts listed for inventory completeness
# Per-host vars to set:
# ansible_host: <ip>
# xcpng_vm_uuid: <uuid> (if hypervisor is xcpng or mixed)
${XCPNG_BLOCK}
HOSTSEOF
cat > "$INVENTORY_DIR/group_vars/all.yml" << VARSEOF
---
# Client: ${CLIENT_NAME} (${CLIENT_ID})
# Onboarded: $(date +%Y-%m-%d)
# VPN: ${VPN}
# Hypervisor: ${HYPERVISOR}
# Billing: ${BILLING}
# Add client-specific overrides below
# Client-specific variable overrides go here.
# Global vars (XO_URL, XO_TOKEN, N8N_WEBHOOK_URL) come from Semaphore variable group.
# Override here only if this client uses a different XO instance or webhook.
VARSEOF
echo "Inventory created at $INVENTORY_DIR"
log_ok "Inventory created at $INVENTORY_DIR"
fi
fi
# ─── Step 3: Commit and push to Gitea ────────────────────────────────────────
echo ""
echo "[ 3/6 ] Committing to Gitea..."
cd "$REPO_DIR"
git add .
git commit -m "Onboard client: ${CLIENT_NAME} (${CLIENT_ID}) — inventory scaffold" \
|| echo " ⚠ Nothing to commit"
git push origin main
echo " ✓ Pushed to Gitea"
# ─── Step 3: Commit and push ──────────────────────────────────────────────────
log_section "3/6 — Git commit"
# ─── Step 4: Create Semaphore project via API ─────────────────────────────────
echo ""
echo "[ 4/6 ] Creating Semaphore project via API..."
if [[ -z "${SEMAPHORE_TOKEN:-}" ]]; then
echo " ✗ No semaphore token available."
echo " Set SEMAPHORE_TOKEN in /root/.semaphore_env or pass --semaphore-token"
exit 1
if ! dry "git add . && git commit && git push"; then
cd "$REPO_DIR"
git add .
git diff --cached --quiet && log_warn "Nothing to commit" || \
git commit -m "Onboard client: ${CLIENT_NAME} (${CLIENT_ID}) — inventory scaffold"
git push origin main
log_ok "Pushed to Gitea"
fi
# 4a. Create project
# ─── Step 4: Semaphore project ────────────────────────────────────────────────
log_section "4/6 — Semaphore project"
if dry "Create Semaphore project + keys + repo + env + inventory"; then
PROJECT_ID=0; GITEA_KEY_ID=0; CLIENT_KEY_ID=0
NONE_KEY_ID=0; REPO_ID=0; ENV_ID=0; INVENTORY_ID=0
else
# 4a. Project
PROJECT_RESPONSE=$(curl -s -X POST "$SEMAPHORE_URL/api/projects" \
-H "Authorization: Bearer $SEMAPHORE_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"name\": \"$PROJECT_NAME\", \"alert\": false, \"max_parallel_tasks\": 0}")
-d "{\"name\":\"$PROJECT_NAME\",\"alert\":false,\"max_parallel_tasks\":0}")
PROJECT_ID=$(echo "$PROJECT_RESPONSE" | jq -r '.id')
[[ "$PROJECT_ID" == "null" || -z "$PROJECT_ID" ]] && {
log_error "Failed to create project: $PROJECT_RESPONSE"; exit 1; }
log_ok "Project: $PROJECT_NAME (ID: $PROJECT_ID)"
if [[ "$PROJECT_ID" == "null" || -z "$PROJECT_ID" ]]; then
echo " ✗ Failed to create project"
echo " Response: $PROJECT_RESPONSE"
exit 1
fi
echo " ✓ Project created: $PROJECT_NAME (ID: $PROJECT_ID)"
# 4b. Add gitea-deploy key — dynamically read key file, jq for safe JSON encoding
GITEA_KEY_RESPONSE=$(curl -s -X POST "$SEMAPHORE_URL/api/project/$PROJECT_ID/keys" \
# 4b. Gitea deploy key
GITEA_KEY_ID=$(curl -s -X POST "$SEMAPHORE_URL/api/project/$PROJECT_ID/keys" \
-H "Authorization: Bearer $SEMAPHORE_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"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)"
-d "{\"name\":\"gitea-deploy\",\"type\":\"ssh\",\"project_id\":$PROJECT_ID,
\"ssh\":{\"login\":\"\",\"passphrase\":\"\",\"private_key\":$(jq -Rs . < "$GITEA_DEPLOY_KEY")}}" \
| jq -r '.id')
log_ok "gitea-deploy key (ID: $GITEA_KEY_ID)"
# 4c. Add client SSH key
CLIENT_KEY_RESPONSE=$(curl -s -X POST "$SEMAPHORE_URL/api/project/$PROJECT_ID/keys" \
# 4c. Client SSH key
CLIENT_KEY_ID=$(curl -s -X POST "$SEMAPHORE_URL/api/project/$PROJECT_ID/keys" \
-H "Authorization: Bearer $SEMAPHORE_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"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)"
-d "{\"name\":\"client-${CLIENT_SLUG}-ssh\",\"type\":\"ssh\",\"project_id\":$PROJECT_ID,
\"ssh\":{\"login\":\"\",\"passphrase\":\"\",\"private_key\":$(jq -Rs . < "$KEY_FILE")}}" \
| jq -r '.id')
log_ok "Client SSH key (ID: $CLIENT_KEY_ID)"
# 4d. Add None key (required for inventory become_key_id)
NONE_KEY_RESPONSE=$(curl -s -X POST "$SEMAPHORE_URL/api/project/$PROJECT_ID/keys" \
# 4d. None key
NONE_KEY_ID=$(curl -s -X POST "$SEMAPHORE_URL/api/project/$PROJECT_ID/keys" \
-H "Authorization: Bearer $SEMAPHORE_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"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)"
-d "{\"name\":\"None\",\"type\":\"none\",\"project_id\":$PROJECT_ID}" \
| jq -r '.id')
log_ok "None key (ID: $NONE_KEY_ID)"
# 4e. Add repository — uses dynamically obtained GITEA_KEY_ID (not hardcoded)
REPO_RESPONSE=$(curl -s -X POST "$SEMAPHORE_URL/api/project/$PROJECT_ID/repositories" \
# 4e. Repository
REPO_ID=$(curl -s -X POST "$SEMAPHORE_URL/api/project/$PROJECT_ID/repositories" \
-H "Authorization: Bearer $SEMAPHORE_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"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)"
-d "{\"name\":\"ansible-msp-automations\",\"project_id\":$PROJECT_ID,
\"git_url\":\"$GITEA_REPO_URL\",\"git_branch\":\"main\",
\"ssh_key_id\":$GITEA_KEY_ID}" \
| jq -r '.id')
log_ok "Repository (ID: $REPO_ID)"
# 4f. Add variable group with all vars including Proxmox
# 4f. Variable group — only include hypervisor vars that are set
VARS_JSON=$(jq -n \
--arg webhook "$WEBHOOK_URL" \
--arg webhook "$EFFECTIVE_WEBHOOK" \
--arg cid "$CLIENT_ID" \
--arg cname "$CLIENT_NAME" \
--arg billing "$BILLING" \
@@ -262,54 +335,52 @@ VARS_JSON=$(jq -n \
--arg phost "$PROXMOX_HOST" \
--arg ptid "$PROXMOX_TOKEN_ID" \
--arg ptsecret "$PROXMOX_TOKEN_SECRET" \
--arg xourl "$EFFECTIVE_XO_URL" \
--arg xotoken "$EFFECTIVE_XO_TOKEN" \
'{
N8N_WEBHOOK_URL: $webhook,
CLIENT_ID: $cid,
CLIENT_NAME: $cname,
BILLING_MODEL: $billing,
HUMAN_ESTIMATE_SECONDS: $estimate,
PROXMOX_HOST: $phost,
PROXMOX_TOKEN_ID: $ptid,
PROXMOX_TOKEN_SECRET: $ptsecret
}')
HUMAN_ESTIMATE_SECONDS: $estimate
}
| if $phost != "" then . + {PROXMOX_HOST: $phost} else . end
| if $ptid != "" then . + {PROXMOX_TOKEN_ID: $ptid} else . end
| if $ptsecret != "" then . + {PROXMOX_TOKEN_SECRET: $ptsecret} else . end
| if $xourl != "" then . + {XO_URL: $xourl} else . end
| if $xotoken != "" then . + {XO_TOKEN: $xotoken} else . end
')
ENV_RESPONSE=$(curl -s -X POST "$SEMAPHORE_URL/api/project/$PROJECT_ID/environment" \
ENV_ID=$(curl -s -X POST "$SEMAPHORE_URL/api/project/$PROJECT_ID/environment" \
-H "Authorization: Bearer $SEMAPHORE_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"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)"
-d "{\"name\":\"${CLIENT_SLUG}-vars\",\"project_id\":$PROJECT_ID,
\"json\":$(echo "$VARS_JSON" | jq -Rs .),\"env\":\"{}\"}" \
| jq -r '.id')
log_ok "Variable group (ID: $ENV_ID)"
# 4g. Add inventory (file type, pointing to Git repo path)
INVENTORY_RESPONSE=$(curl -s -X POST "$SEMAPHORE_URL/api/project/$PROJECT_ID/inventory" \
# 4g. Inventory
INVENTORY_ID=$(curl -s -X POST "$SEMAPHORE_URL/api/project/$PROJECT_ID/inventory" \
-H "Authorization: Bearer $SEMAPHORE_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"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)"
-d "{\"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}" \
| jq -r '.id')
log_ok "Inventory (ID: $INVENTORY_ID)"
# ─── Step 5: Create task templates ───────────────────────────────────────────
echo ""
echo "[ 5/6 ] Creating task templates..."
fi # end dry run block
# ─── Step 5: Task templates ───────────────────────────────────────────────────
log_section "5/6 — Task templates"
create_template() {
local TNAME="$1"
local PLAYBOOK="$2"
local DESC="$3"
RESPONSE=$(curl -s -X POST "$SEMAPHORE_URL/api/project/$PROJECT_ID/templates" \
if dry "Template: $TNAME$PLAYBOOK"; then return; fi
RESULT=$(curl -s -X POST "$SEMAPHORE_URL/api/project/$PROJECT_ID/templates" \
-H "Authorization: Bearer $SEMAPHORE_TOKEN" \
-H "Content-Type: application/json" \
-d "{
@@ -324,30 +395,51 @@ create_template() {
\"description\": \"$DESC\",
\"app\": \"ansible\"
}")
echo "$(echo "$RESPONSE" | jq -r '"Template: \(.name) (ID: \(.id))"')"
log_ok "$(echo "$RESULT" | jq -r '"Template: \(.name) (ID: \(.id))"')"
}
create_template "Preflight Check" "playbooks/site_preflight.yml" "Run safety checks on all hosts before maintenance"
create_template "Snapshot Test" "playbooks/snapshot_pre.yml" "Test pre-patch snapshot creation via Proxmox API"
# Always created
create_template "Preflight Check" "playbooks/site_preflight.yml" "Safety checks on all hosts before maintenance"
create_template "Linux Patch" "playbooks/linux_patch.yml" "Full Linux patch run with version tracking"
create_template "Full Maintenance" "playbooks/site_maintenance.yml" "Full maintenance: snapshot, preflight, patch"
# Proxmox
case "$HYPERVISOR" in proxmox|mixed)
create_template "Snapshot (Proxmox)" "playbooks/snapshot_pre.yml" "Pre-patch VM snapshots via Proxmox API"
;; esac
# XCP-NG
case "$HYPERVISOR" in xcpng|mixed)
create_template "XCP-NG Pool Update" "playbooks/xcpng_pool_update.yml" "Patch XCP-NG hypervisor pools via XO REST API"
create_template "Snapshot (XCP-NG)" "playbooks/snapshot_pre.yml" "Pre-patch VM snapshots via XO REST API"
;; esac
[[ "$HYPERVISOR" == "baremetal" ]] && \
log_warn "Baremetal — no snapshot templates created. Ensure change approval before patching."
# ─── Step 6: Summary ──────────────────────────────────────────────────────────
log_section "6/6 — Done"
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Client onboarding complete: $CLIENT_NAME"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "${CLIENT_NAME} (${CLIENT_ID}) onboarded"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo " Semaphore project ID: $PROJECT_ID"
echo " Inventory file: $INVENTORY_DIR/hosts.yml"
[[ "$DRY_RUN" != "true" ]] && echo " Semaphore project ID : $PROJECT_ID"
echo " Inventory : $INVENTORY_DIR/hosts.yml"
echo " Hypervisor : $HYPERVISOR"
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 " 1. Add hosts to inventory then git push"
echo " 2. bash scripts/deploy_agent.sh --inventory $INVENTORY_REPO_PATH"
echo " 3. Semaphore → $PROJECT_NAME → Preflight Check → ▶ Run"
echo " 4. Semaphore → $PROJECT_NAME → Snapshot Test → ▶ Run"
echo " 5. Semaphore → $PROJECT_NAMELinux Patch → ▶ Run"
[[ "$HYPERVISOR" != "baremetal" ]] && \
echo " 4. Semaphore → $PROJECT_NAMEXCP-NG Pool Update / Snapshot → ▶ Run"
[[ "$HYPERVISOR" == "baremetal" ]] && \
echo " NOTE: Baremetal — no snapshots. Get explicit change approval before patching."
echo ""
echo " Public key to deploy to client hosts:"
sed 's/^/ /' "$KEY_FILE.pub"
if [[ "$DRY_RUN" != "true" && -f "$KEY_FILE.pub" ]]; then
echo " Client public key:"
sed 's/^/ /' "$KEY_FILE.pub"
fi
echo ""