614 lines
25 KiB
Bash
Executable File
614 lines
25 KiB
Bash
Executable File
#!/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 - <n>')"
|
|
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 ""
|
|
|