diff --git a/playbooks/linux_reboot.yml b/playbooks/linux_reboot.yml new file mode 100644 index 0000000..4ed64a8 --- /dev/null +++ b/playbooks/linux_reboot.yml @@ -0,0 +1,63 @@ +--- +- name: Bootstrap — ensure Python is available + hosts: linux_hosts + gather_facts: false + roles: + - role: preflight + tasks_from: bootstrap + +- name: Reboot Linux hosts if required (or forced) + hosts: linux_hosts + gather_facts: true + vars: + force_reboot: false # set to true in Semaphore extra vars to reboot regardless + tasks: + - name: Check if reboot is required (Debian/Ubuntu) + ansible.builtin.stat: + path: /var/run/reboot-required + register: reboot_required_file + when: ansible_os_family == "Debian" + + - name: Set reboot_needed fact (Debian/Ubuntu) + ansible.builtin.set_fact: + reboot_needed: "{{ reboot_required_file.stat.exists | default(false) }}" + when: ansible_os_family == "Debian" + + - name: Check if reboot is required (Alpine) + ansible.builtin.shell: | + apk version -l = 2>/dev/null | grep -q kernel && echo "yes" || echo "no" + register: alpine_reboot_check + changed_when: false + when: ansible_os_family == "Alpine" + + - name: Set reboot_needed fact (Alpine) + ansible.builtin.set_fact: + reboot_needed: "{{ alpine_reboot_check.stdout | trim == 'yes' }}" + when: ansible_os_family == "Alpine" + + - name: Set reboot_needed fallback (RHEL or unknown) + ansible.builtin.set_fact: + reboot_needed: false + when: reboot_needed is not defined + + - name: Report reboot status + ansible.builtin.debug: + msg: >- + {{ inventory_hostname }}: + reboot_needed={{ reboot_needed }}, + force_reboot={{ force_reboot }} + — {{ 'WILL reboot' if (reboot_needed or force_reboot) else 'Skipping reboot' }} + + - name: Reboot host + ansible.builtin.reboot: + reboot_timeout: 300 + pre_reboot_delay: 10 + post_reboot_delay: 30 + msg: "Scheduled reboot — initiated by Ansible" + when: reboot_needed | bool or force_reboot | bool + + - name: Reboot complete + ansible.builtin.debug: + msg: "{{ inventory_hostname }} is back online and responding" + when: reboot_needed | bool or force_reboot | bool + diff --git a/scripts/onboard_client.sh b/scripts/onboard_client.sh index 58a42de..df1066f 100755 --- a/scripts/onboard_client.sh +++ b/scripts/onboard_client.sh @@ -1,42 +1,41 @@ #!/bin/bash set -e -# ============================================================================= -# 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 -# ============================================================================= +# ─── 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 (optional)" + echo " --proxmox-token-id Proxmox API token ID (e.g. ansible@pve!token-name)" + echo " --proxmox-token-secret Proxmox API token secret" + 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 -w https://n8n.voice1.me/webhook/xxx \\" + echo " -H xcpng --xo-url https://xoa.voice1.me --xo-token " + exit 1 +} # ─── Defaults ──────────────────────────────────────────────────────────────── BILLING="hybrid" ESTIMATE="2700" -HYPERVISOR="xcpng" +VPN="ipsec" +HYPERVISOR="proxmox" SEMAPHORE_URL="http://localhost:3000" REPO_DIR="/opt/ansible-msp-automations" GITEA_DEPLOY_KEY="/root/.ssh/gitea_ansible" @@ -44,87 +43,65 @@ 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="" +XO_URL_ARG="" +XO_TOKEN_ARG="" 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 ;; - -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 ;; + -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 ;; + --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 ────────────────────────────────────────────────── -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[*]}" +if [[ -z "${CLIENT_ID:-}" || -z "${CLIENT_NAME:-}" || -z "${CLIENT_SLUG:-}" ]]; then + echo "ERROR: --id, --name, and --slug are required" usage fi -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 +# Load env file if present +if [[ -f "/root/.semaphore_env" ]]; then + source /root/.semaphore_env 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 — 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 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 +# 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 PROJECT_NAME="${PROJECT_NAME_OVERRIDE:-Client - ${CLIENT_NAME}}" @@ -133,78 +110,56 @@ INVENTORY_DIR="$REPO_DIR/inventories/client_${CLIENT_SLUG}" INVENTORY_REPO_PATH="inventories/client_${CLIENT_SLUG}/hosts.yml" 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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " Onboarding client: $CLIENT_NAME ($CLIENT_ID)" +[[ "$DRY_RUN" == true ]] && echo " ⚠ DRY RUN — no changes will be made" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" # ─── Step 1: Generate SSH key ───────────────────────────────────────────────── -log_section "1/6 — SSH key" - -if [[ -f "$KEY_FILE" ]]; then - log_warn "Key already exists at $KEY_FILE — skipping generation" +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 - 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 + ssh-keygen -t ed25519 -C "ansible-${CLIENT_SLUG}" -f "$KEY_FILE" -N "" + echo " ✓ Key generated: $KEY_FILE" fi -echo "" -echo " ┌─ Public key to deploy to all client hosts ──────────────────────────" -echo " │" -sed 's/^/ │ /' "$KEY_FILE.pub" 2>/dev/null || echo " │ (dry run — key not yet generated)" -echo " │" -echo " └─────────────────────────────────────────────────────────────────────" +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 ─────────────────────────────────── -log_section "2/6 — Inventory scaffold" +echo "" +echo "[ 2/6 ] Creating inventory..." -if [[ -d "$INVENTORY_DIR" ]]; then - log_warn "Inventory already exists at $INVENTORY_DIR — skipping" +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 - if ! dry "cp -r $REPO_DIR/inventories/client_template $INVENTORY_DIR"; then - cp -r "$REPO_DIR/inventories/client_template" "$INVENTORY_DIR" + cp -r "$REPO_DIR/inventories/client_template" "$INVENTORY_DIR" + mkdir -p "$INVENTORY_DIR/group_vars" - # 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) + # Build xcpng_hosts group if applicable + XCPNG_HOSTS_BLOCK="" + if [[ "$HYPERVISOR" == "xcpng" || "$HYPERVISOR" == "mixed" ]]; then + XCPNG_HOSTS_BLOCK=" xcpng_hosts: hosts: {} vars: - ansible_connection: local -XCPNGEOF -) - fi + ansible_connection: local" + fi - cat > "$INVENTORY_DIR/hosts.yml" << HOSTSEOF + 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}" @@ -215,6 +170,7 @@ all: maintenance_window_tz: "UTC" change_freeze: false hypervisor_type: "${HYPERVISOR}" + vpn_type: "${VPN}" auto_reboot: false human_estimate_seconds: ${ESTIMATE} @@ -225,10 +181,6 @@ all: 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: {} @@ -238,149 +190,210 @@ 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} +${XCPNG_HOSTS_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} -# 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. +# Add client-specific overrides below VARSEOF - log_ok "Inventory created at $INVENTORY_DIR" - fi + echo " ✓ Inventory created at $INVENTORY_DIR" fi -# ─── Step 3: Commit and push ────────────────────────────────────────────────── -log_section "3/6 — Git commit" - -if ! dry "git add . && git commit && git push"; then +# ─── 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 diff --cached --quiet && log_warn "Nothing to commit" || \ - git commit -m "Onboard client: ${CLIENT_NAME} (${CLIENT_ID}) — inventory scaffold" + git commit -m "Onboard client: ${CLIENT_NAME} (${CLIENT_ID}) — inventory scaffold" \ + || echo " ⚠ Nothing to commit" git push origin main - log_ok "Pushed to Gitea" + echo " ✓ Pushed to Gitea" fi -# ─── Step 4: Semaphore project ──────────────────────────────────────────────── -log_section "4/6 — Semaphore project" +# ─── Step 4: Create Semaphore project via API ───────────────────────────────── +echo "" +echo "[ 4/6 ] Creating Semaphore project via API..." -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 +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 -# 4a. Project +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}") + -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)" -# 4b. Gitea deploy key -GITEA_KEY_ID=$(curl -s -X POST "$SEMAPHORE_URL/api/project/$PROJECT_ID/keys" \ +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")}}" \ - | jq -r '.id') -log_ok "gitea-deploy key (ID: $GITEA_KEY_ID)" + -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. Client SSH key -CLIENT_KEY_ID=$(curl -s -X POST "$SEMAPHORE_URL/api/project/$PROJECT_ID/keys" \ +# 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")}}" \ - | jq -r '.id') -log_ok "Client SSH key (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") + } + }") +CLIENT_KEY_ID=$(echo "$CLIENT_KEY_RESPONSE" | jq -r '.id') +echo " ✓ Client SSH key added (ID: $CLIENT_KEY_ID)" -# 4d. None key -NONE_KEY_ID=$(curl -s -X POST "$SEMAPHORE_URL/api/project/$PROJECT_ID/keys" \ +# 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}" \ - | jq -r '.id') -log_ok "None key (ID: $NONE_KEY_ID)" + -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. Repository -REPO_ID=$(curl -s -X POST "$SEMAPHORE_URL/api/project/$PROJECT_ID/repositories" \ +# 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}" \ - | jq -r '.id') -log_ok "Repository (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 + }") +REPO_ID=$(echo "$REPO_RESPONSE" | jq -r '.id') +echo " ✓ Repository linked (ID: $REPO_ID)" -# 4f. Variable group — only include hypervisor vars that are set -VARS_JSON=$(jq -n \ - --arg webhook "$EFFECTIVE_WEBHOOK" \ - --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" \ - --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 - } - | 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 - ') +# 4f. Build variable group JSON — include XO vars for xcpng/mixed +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_ID=$(curl -s -X POST "$SEMAPHORE_URL/api/project/$PROJECT_ID/environment" \ +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\":\"{}\"}" \ - | jq -r '.id') -log_ok "Variable group (ID: $ENV_ID)" + -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. Inventory -INVENTORY_ID=$(curl -s -X POST "$SEMAPHORE_URL/api/project/$PROJECT_ID/inventory" \ +# 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}" \ - | jq -r '.id') -log_ok "Inventory (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 + }") +INVENTORY_ID=$(echo "$INVENTORY_RESPONSE" | jq -r '.id') +echo " ✓ Inventory created (ID: $INVENTORY_ID)" -fi # end dry run block - -# ─── Step 5: Task templates ─────────────────────────────────────────────────── -log_section "5/6 — Task templates" +# ─── Step 5: Create task templates ─────────────────────────────────────────── +echo "" +echo "[ 5/6 ] Creating task templates..." create_template() { local TNAME="$1" local PLAYBOOK="$2" local DESC="$3" - if dry "Template: $TNAME → $PLAYBOOK"; then return; fi - RESULT=$(curl -s -X POST "$SEMAPHORE_URL/api/project/$PROJECT_ID/templates" \ + 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 "{ @@ -390,56 +403,48 @@ create_template() { \"environment_id\": $ENV_ID, \"name\": \"$TNAME\", \"playbook\": \"$PLAYBOOK\", - \"arguments\": \"[]\", - \"allow_override_args_in_task\": false, + \"arguments\": \"$EXTRA_ARGS\", + \"allow_override_args_in_task\": true, \"description\": \"$DESC\", \"app\": \"ansible\" }") - log_ok "$(echo "$RESULT" | jq -r '"Template: \(.name) (ID: \(.id))"')" + echo " ✓ $(echo "$RESPONSE" | jq -r '"Template: \(.name) (ID: \(.id))"')" } -# 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" +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" -# Proxmox -case "$HYPERVISOR" in proxmox|mixed) - create_template "Snapshot (Proxmox)" "playbooks/snapshot_pre.yml" "Pre-patch VM snapshots via Proxmox API" -;; esac +# Hypervisor-specific templates +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 -# 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" +# ─── Step 6: Summary ───────────────────────────────────────────────────────── echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo " ✓ ${CLIENT_NAME} (${CLIENT_ID}) onboarded" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " ✓ Client onboarding complete: $CLIENT_NAME" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" -[[ "$DRY_RUN" != "true" ]] && echo " Semaphore project ID : $PROJECT_ID" -echo " Inventory : $INVENTORY_DIR/hosts.yml" -echo " Hypervisor : $HYPERVISOR" +echo " Semaphore project ID: $PROJECT_ID" +echo " Inventory file: $INVENTORY_DIR/hosts.yml" echo "" echo " Next steps:" -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" -[[ "$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 " 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 "" -if [[ "$DRY_RUN" != "true" && -f "$KEY_FILE.pub" ]]; then - echo " Client public key:" - sed 's/^/ /' "$KEY_FILE.pub" -fi +echo " To reboot hosts after a deferred patch run:" +echo " Semaphore → $PROJECT_NAME → Scheduled Reboot → ▶ Run" +echo " (add extra var force_reboot=true to reboot all hosts regardless)" +echo "" +echo " Public key to deploy to client hosts:" +sed 's/^/ /' "$KEY_FILE.pub" echo "" -