From a42bf14665111d307630cc971a2d75f3b6d7186a Mon Sep 17 00:00:00 2001 From: Semaphore Date: Thu, 12 Mar 2026 11:15:43 -0700 Subject: [PATCH] Add XCP-NG integration, deploy_agent.sh, overhaul onboard_client.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- scripts/deploy_agent.sh | 469 ++++++++++++++++++++++++++++++++++++ scripts/onboard_client.sh | 484 +++++++++++++++++++++++--------------- 2 files changed, 757 insertions(+), 196 deletions(-) create mode 100755 scripts/deploy_agent.sh diff --git a/scripts/deploy_agent.sh b/scripts/deploy_agent.sh new file mode 100755 index 0000000..0ec9738 --- /dev/null +++ b/scripts/deploy_agent.sh @@ -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 + diff --git a/scripts/onboard_client.sh b/scripts/onboard_client.sh index dc2cc66..58a42de 100755 --- a/scripts/onboard_client.sh +++ b/scripts/onboard_client.sh @@ -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 - ')" - 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,39 +44,87 @@ 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 case $1 in - -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 ;; - --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 ;; - --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 ;; + -i|--id) CLIENT_ID="$2"; shift 2 ;; + -n|--name) CLIENT_NAME="$2"; shift 2 ;; + -s|--slug) CLIENT_SLUG="$2"; shift 2 ;; + -b|--billing) BILLING="$2"; shift 2 ;; + -e|--estimate) ESTIMATE="$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 ;; + --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 - ssh-keygen -t ed25519 -C "ansible-${CLIENT_SLUG}" -f "$KEY_FILE" -N "" - echo " ✓ Key generated: $KEY_FILE" + 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 "" + 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 - cp -r "$REPO_DIR/inventories/client_template" "$INVENTORY_DIR" + if ! dry "cp -r $REPO_DIR/inventories/client_template $INVENTORY_DIR"; then + cp -r "$REPO_DIR/inventories/client_template" "$INVENTORY_DIR" - cat > "$INVENTORY_DIR/hosts.yml" << HOSTSEOF + # 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: + # xcpng_vm_uuid: (if hypervisor is xcpng or mixed) + # proxmox_vmid: (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: + # xcpng_vm_uuid: (if hypervisor is xcpng or mixed) +${XCPNG_BLOCK} HOSTSEOF - cat > "$INVENTORY_DIR/group_vars/all.yml" << VARSEOF + 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_NAME → Linux Patch → ▶ Run" +[[ "$HYPERVISOR" != "baremetal" ]] && \ + echo " 4. Semaphore → $PROJECT_NAME → XCP-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 "" +