#!/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|baremetal|mixed) default: proxmox" echo " --proxmox-host Proxmox host IP or hostname (required for proxmox/mixed)" echo " --proxmox-token-id Proxmox API token ID (e.g. ansible@pve!token-name)" echo " --proxmox-token-secret Proxmox API token secret" echo " --bootstrap-proxmox Create Proxmox role/user/token via API (prompts for root password)" echo " --proxmox-role Proxmox role name to create (default: AnsibleMSP)" echo " --proxmox-user Proxmox user to create (default: ansible@pve)" echo " --proxmox-token-name Token name to create (default: ansible-token)" echo " --xo-url XCP-NG/XO API URL (default: \$XO_URL from env)" echo " --xo-token XCP-NG/XO API token (default: \$XO_TOKEN from env)" 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 " --dry-run Print actions without executing" echo "" echo "Example:" echo " $0 -i ACME-001 -n 'Acme Corp' -s acme_corp \\" echo " -H proxmox --proxmox-host 192.168.1.10 --bootstrap-proxmox" exit 1 } # ─── Defaults ──────────────────────────────────────────────────────────────── BILLING="hybrid" ESTIMATE="2700" VPN="ipsec" HYPERVISOR="proxmox" 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="" BOOTSTRAP_PROXMOX=false PROXMOX_ROLE="AnsibleMSP" PROXMOX_USER="ansible@pve" PROXMOX_TOKEN_NAME="ansible-token" XO_URL_ARG="" XO_TOKEN_ARG="" PROJECT_NAME_OVERRIDE="" 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 ;; -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 ;; --bootstrap-proxmox) BOOTSTRAP_PROXMOX=true; shift ;; --proxmox-role) PROXMOX_ROLE="$2"; shift 2 ;; --proxmox-user) PROXMOX_USER="$2"; shift 2 ;; --proxmox-token-name) PROXMOX_TOKEN_NAME="$2"; shift 2 ;; --xo-url) XO_URL_ARG="$2"; shift 2 ;; --xo-token) XO_TOKEN_ARG="$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 ;; *) 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 # Load env file if present if [[ -f "/root/.semaphore_env" ]]; then source /root/.semaphore_env fi # Resolve webhook — arg > env WEBHOOK_URL="${WEBHOOK_URL:-${N8N_WEBHOOK_URL:-}}" if [[ -z "${WEBHOOK_URL:-}" ]]; then echo "ERROR: --webhook is required (or set N8N_WEBHOOK_URL in /root/.semaphore_env)" usage fi # Resolve XO vars — arg > env RESOLVED_XO_URL="${XO_URL_ARG:-${XO_URL:-}}" RESOLVED_XO_TOKEN="${XO_TOKEN_ARG:-${XO_TOKEN:-}}" # Validate XO vars for xcpng/mixed if [[ "$HYPERVISOR" == "xcpng" || "$HYPERVISOR" == "mixed" ]]; then if [[ -z "$RESOLVED_XO_URL" || -z "$RESOLVED_XO_TOKEN" ]]; then echo "ERROR: --xo-url and --xo-token are required for hypervisor type '$HYPERVISOR'" echo " (or set XO_URL and XO_TOKEN in /root/.semaphore_env)" exit 1 fi fi # Validate proxmox host for bootstrap or proxmox hypervisor if [[ "$BOOTSTRAP_PROXMOX" == true || "$HYPERVISOR" == "proxmox" || "$HYPERVISOR" == "mixed" ]]; then if [[ -z "$PROXMOX_HOST" ]]; then echo "ERROR: --proxmox-host is required for hypervisor type '$HYPERVISOR'" exit 1 fi fi PROJECT_NAME="${PROJECT_NAME_OVERRIDE:-Client - ${CLIENT_NAME}}" KEY_FILE="/root/.ssh/client_${CLIENT_SLUG}" 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)" [[ "$DRY_RUN" == true ]] && echo " ⚠ DRY RUN — no changes will be made" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" # ─── Step 0: Bootstrap Proxmox (optional) ──────────────────────────────────── if [[ "$BOOTSTRAP_PROXMOX" == true ]]; then echo "" echo "[ 0 ] Bootstrapping Proxmox user/role/token on $PROXMOX_HOST..." echo "" if [[ "$DRY_RUN" == true ]]; then echo " [dry-run] Would authenticate as root@pam and create:" echo " Role: $PROXMOX_ROLE" echo " User: $PROXMOX_USER" echo " Token: $PROXMOX_USER!$PROXMOX_TOKEN_NAME" echo " Permission: / (propagate=1)" else # Prompt for root password echo -n " Proxmox root password for $PROXMOX_HOST: " read -rs PVE_ROOT_PASS echo "" PVE_API="https://${PROXMOX_HOST}:8006/api2/json" # Get auth ticket echo " Authenticating as root@pam..." TICKET_RESPONSE=$(curl -sk -X POST "$PVE_API/access/ticket" \ -d "username=root@pam" \ --data-urlencode "password=$PVE_ROOT_PASS") PVE_TICKET=$(echo "$TICKET_RESPONSE" | jq -r '.data.ticket // empty') PVE_CSRF=$(echo "$TICKET_RESPONSE" | jq -r '.data.CSRFPreventionToken // empty') if [[ -z "$PVE_TICKET" ]]; then echo " ✗ Authentication failed — check root password" echo " Response: $TICKET_RESPONSE" exit 1 fi echo " ✓ Authenticated" # Helper functions — use ticket auth for all subsequent calls pve_post() { local endpoint="$1"; shift curl -sk -X POST "$PVE_API/$endpoint" \ -H "CSRFPreventionToken: $PVE_CSRF" \ -b "PVEAuthCookie=$PVE_TICKET" \ "$@" } pve_put() { local endpoint="$1"; shift curl -sk -X PUT "$PVE_API/$endpoint" \ -H "CSRFPreventionToken: $PVE_CSRF" \ -b "PVEAuthCookie=$PVE_TICKET" \ "$@" } # Create role echo " Creating role: $PROXMOX_ROLE..." pve_post "access/roles" \ -d "roleid=$PROXMOX_ROLE" \ -d "privs=VM.Snapshot,VM.Snapshot.Rollback,VM.Audit,VM.PowerMgmt,Datastore.AllocateSpace" \ > /dev/null echo " ✓ Role created (or already exists): $PROXMOX_ROLE" # Create user PVE_USERID_ENCODED=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$PROXMOX_USER'))") echo " Creating user: $PROXMOX_USER..." USER_RESPONSE=$(pve_post "access/users" \ -d "userid=$PROXMOX_USER" \ -d "password=ChangeMe123!" \ -d "comment=Ansible MSP automation user — managed by onboard_client.sh") USER_DATA=$(echo "$USER_RESPONSE" | jq -r '.data // empty') USER_ERR=$(echo "$USER_RESPONSE" | jq -r '.errors // empty') if [[ -n "$USER_ERR" ]]; then if echo "$USER_RESPONSE" | jq -r '.errors | to_entries[].value' 2>/dev/null | grep -qi "already exist"; then echo " ⚠ User already exists — continuing" else echo " ✗ Failed to create user" echo " Response: $USER_RESPONSE" exit 1 fi else echo " ✓ User created: $PROXMOX_USER" echo " ⚠ Temporary password set: ChangeMe123! — rotate after token creation" fi # Create API token echo " Creating API token: $PROXMOX_TOKEN_NAME..." TOKEN_RESPONSE=$(pve_post "access/users/${PVE_USERID_ENCODED}/token/$PROXMOX_TOKEN_NAME" \ -d "privsep=0" \ -d "comment=Ansible MSP snapshot token — managed by onboard_client.sh") TOKEN_VALUE=$(echo "$TOKEN_RESPONSE" | jq -r '.data.value // empty') if [[ -z "$TOKEN_VALUE" ]]; then echo " ✗ Failed to create token (may already exist — token secrets are shown only once)" echo " Response: $TOKEN_RESPONSE" echo "" echo " If the token already exists, delete it in Proxmox UI and re-run --bootstrap-proxmox" echo " Or pass --proxmox-token-id and --proxmox-token-secret directly to skip bootstrap" exit 1 fi PROXMOX_TOKEN_ID="${PROXMOX_USER}!${PROXMOX_TOKEN_NAME}" PROXMOX_TOKEN_SECRET="$TOKEN_VALUE" echo " ✓ Token created" echo "" echo " ┌─ SAVE THIS TOKEN SECRET — shown only once ─────────────────────" echo " │" echo " │ Token ID: $PROXMOX_TOKEN_ID" echo " │ Token Secret: $PROXMOX_TOKEN_SECRET" echo " │" echo " └────────────────────────────────────────────────────────────────" echo "" # Assign permission: user + role to path / echo " Assigning permission: $PROXMOX_USER + $PROXMOX_ROLE → / (propagate)..." pve_put "access/acl" \ -d "path=/" \ -d "users=$PROXMOX_USER" \ -d "roles=$PROXMOX_ROLE" \ -d "propagate=1" \ > /dev/null echo " ✓ Permission assigned" echo "" echo " ✓ Proxmox bootstrap complete" fi fi # ─── Validate token args (post-bootstrap) ──────────────────────────────────── if [[ "$BOOTSTRAP_PROXMOX" != true ]]; then if [[ "$HYPERVISOR" == "proxmox" || "$HYPERVISOR" == "mixed" ]]; then if [[ -z "$PROXMOX_TOKEN_ID" || -z "$PROXMOX_TOKEN_SECRET" ]]; then echo "ERROR: --proxmox-token-id and --proxmox-token-secret are required" echo " (or use --bootstrap-proxmox to create them automatically)" exit 1 fi fi fi # ─── Step 1: Generate SSH key ───────────────────────────────────────────────── echo "" echo "[ 1/6 ] Generating SSH key..." if [[ "$DRY_RUN" == true ]]; then echo " [dry-run] Would generate SSH key at $KEY_FILE" elif [[ -f "$KEY_FILE" ]]; then echo " ⚠ 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" fi if [[ "$DRY_RUN" != true ]]; then echo "" echo " ┌─ Deploy this public key to all client hosts ───────────────────────" echo " │" sed 's/^/ │ /' "$KEY_FILE.pub" echo " │" echo " └────────────────────────────────────────────────────────────────────" fi # ─── Step 2: Create inventory from template ─────────────────────────────────── echo "" echo "[ 2/6 ] Creating inventory..." if [[ "$DRY_RUN" == true ]]; then echo " [dry-run] Would create inventory at $INVENTORY_DIR" elif [[ -d "$INVENTORY_DIR" ]]; then echo " ⚠ Inventory directory already exists — skipping" else cp -r "$REPO_DIR/inventories/client_template" "$INVENTORY_DIR" mkdir -p "$INVENTORY_DIR/group_vars" XCPNG_HOSTS_BLOCK="" if [[ "$HYPERVISOR" == "xcpng" || "$HYPERVISOR" == "mixed" ]]; then XCPNG_HOSTS_BLOCK=" xcpng_hosts: hosts: {} vars: ansible_connection: local" fi 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: ansible-msp-agent ansible_become: true ansible_become_method: sudo windows_hosts: hosts: {} vars: ansible_user: Administrator ansible_connection: winrm ansible_winrm_transport: ntlm ansible_winrm_server_cert_validation: validate ansible_port: 5986 ${XCPNG_HOSTS_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 VARSEOF echo " ✓ Inventory created at $INVENTORY_DIR" fi # ─── Step 3: Commit and push to Gitea ──────────────────────────────────────── echo "" echo "[ 3/6 ] Committing to Gitea..." if [[ "$DRY_RUN" == true ]]; then echo " [dry-run] Would commit and push inventory to Gitea" else 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" fi # ─── Step 4: Create Semaphore project via API ───────────────────────────────── echo "" echo "[ 4/6 ] Creating Semaphore project via API..." if [[ "$DRY_RUN" == true ]]; then echo " [dry-run] Would create Semaphore project: $PROJECT_NAME" echo " [dry-run] Would create keys, repo, environment, inventory, and templates" echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " [dry-run] Onboarding simulation complete: $CLIENT_NAME" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" exit 0 fi if [[ -z "${SEMAPHORE_TOKEN:-}" ]]; then echo " ✗ No semaphore token available." echo " Set SEMAPHORE_TOKEN in /root/.semaphore_env or pass --semaphore-token" exit 1 fi # 4a. Create 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}") PROJECT_ID=$(echo "$PROJECT_RESPONSE" | jq -r '.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 GITEA_KEY_RESPONSE=$(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)" # 4c. Add client SSH key CLIENT_KEY_RESPONSE=$(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)" # 4d. Add None key NONE_KEY_RESPONSE=$(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)" # 4e. Add repository REPO_RESPONSE=$(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)" # 4f. Build variable group JSON if [[ "$HYPERVISOR" == "xcpng" || "$HYPERVISOR" == "mixed" ]]; then VARS_JSON=$(jq -n \ --arg webhook "$WEBHOOK_URL" \ --arg cid "$CLIENT_ID" \ --arg cname "$CLIENT_NAME" \ --arg billing "$BILLING" \ --arg estimate "$ESTIMATE" \ --arg xo_url "$RESOLVED_XO_URL" \ --arg xo_token "$RESOLVED_XO_TOKEN" \ '{ N8N_WEBHOOK_URL: $webhook, CLIENT_ID: $cid, CLIENT_NAME: $cname, BILLING_MODEL: $billing, HUMAN_ESTIMATE_SECONDS: $estimate, XO_URL: $xo_url, XO_TOKEN: $xo_token }') else VARS_JSON=$(jq -n \ --arg webhook "$WEBHOOK_URL" \ --arg cid "$CLIENT_ID" \ --arg cname "$CLIENT_NAME" \ --arg billing "$BILLING" \ --arg estimate "$ESTIMATE" \ --arg phost "$PROXMOX_HOST" \ --arg ptid "$PROXMOX_TOKEN_ID" \ --arg ptsecret "$PROXMOX_TOKEN_SECRET" \ '{ 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 }') fi ENV_RESPONSE=$(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)" # 4g. Add inventory INVENTORY_RESPONSE=$(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)" # ─── Step 5: Create task templates ─────────────────────────────────────────── echo "" echo "[ 5/6 ] Creating task templates..." create_template() { local TNAME="$1" local PLAYBOOK="$2" local DESC="$3" local EXTRA_ARGS="${4:-[]}" RESPONSE=$(curl -s -X POST "$SEMAPHORE_URL/api/project/$PROJECT_ID/templates" \ -H "Authorization: Bearer $SEMAPHORE_TOKEN" \ -H "Content-Type: application/json" \ -d "{ \"project_id\": $PROJECT_ID, \"inventory_id\": $INVENTORY_ID, \"repository_id\": $REPO_ID, \"environment_id\": $ENV_ID, \"name\": \"$TNAME\", \"playbook\": \"$PLAYBOOK\", \"arguments\": \"$EXTRA_ARGS\", \"allow_override_args_in_task\": true, \"description\": \"$DESC\", \"app\": \"ansible\" }") echo " ✓ $(echo "$RESPONSE" | jq -r '"Template: \(.name) (ID: \(.id))"')" } create_template "Preflight Check" "playbooks/site_preflight.yml" "Run 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" create_template "Scheduled Reboot" "playbooks/linux_reboot.yml" "Reboot hosts that require it — pass -e force_reboot=true to reboot all" if [[ "$HYPERVISOR" == "proxmox" || "$HYPERVISOR" == "mixed" ]]; then create_template "Snapshot (Proxmox)" "playbooks/snapshot_pre.yml" "Pre-patch snapshot via Proxmox API" fi if [[ "$HYPERVISOR" == "xcpng" || "$HYPERVISOR" == "mixed" ]]; then create_template "Snapshot (XCP-NG)" "playbooks/snapshot_pre.yml" "Pre-patch snapshot via XO REST API" create_template "XCP-NG Pool Update" "playbooks/xcpng_pool_update.yml" "Update XCP-NG host pool patches" fi # ─── Step 6: Summary ───────────────────────────────────────────────────────── echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " ✓ Client onboarding complete: $CLIENT_NAME" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" echo " Semaphore project ID: $PROJECT_ID" echo " Inventory file: $INVENTORY_DIR/hosts.yml" if [[ "$BOOTSTRAP_PROXMOX" == true && "$DRY_RUN" != true ]]; then echo "" echo " Proxmox credentials (also stored in Semaphore variable group):" echo " Token ID: $PROXMOX_TOKEN_ID" echo " Token Secret: $PROXMOX_TOKEN_SECRET" echo " ⚠ Rotate the $PROXMOX_USER password in Proxmox UI when convenient" fi echo "" echo " Next steps:" echo " 1. Add hosts to: $INVENTORY_DIR/hosts.yml" echo " 2. Run deploy_agent.sh to bootstrap ansible-msp-agent on Linux hosts" echo " 3. git add . && git commit -m 'Add hosts for ${CLIENT_NAME}' && git push origin main" echo " 4. Semaphore → $PROJECT_NAME → Preflight Check → ▶ Run" echo " 5. Semaphore → $PROJECT_NAME → Full Maintenance → ▶ Run" echo "" echo " To reboot hosts after a deferred patch run:" echo " Semaphore → $PROJECT_NAME → Scheduled Reboot → ▶ Run" echo " (add extra var -e force_reboot=true to reboot all hosts regardless)" echo "" echo " Public key to deploy to client hosts:" sed 's/^/ /' "$KEY_FILE.pub" echo ""