#!/bin/bash set -e # ============================================================================= # onboard_client.sh # MSP client onboarding script — creates inventory, Semaphore project, # SSH keys, and task templates. # # Required: --id, --name, --slug # Everything else is optional and can be added later via --update # # Client types (--type): # basic — preflight + linux patch only (no hypervisor) # linux — same as basic (alias) # proxmox — basic + proxmox upgrade/backup templates # xcpng — basic + xcp-ng pool upgrade/snapshot templates # firewall — preflight only (for firewall-only clients) # custom — no templates created, add manually # # Idempotency: re-running against an existing client slug will detect the # existing project and skip creation steps. Use --update to add optional # config (e.g. add Proxmox to a previously basic client). # ============================================================================= usage() { cat << EOF Usage: $0 [options] Required: -i, --id Client ID (e.g. RP-001) -n, --name Client name (e.g. 'Royal Pizza') -s, --slug Short slug for filenames (e.g. royal_pizza) Client type (controls which templates are created): -t, --type Client type: basic|linux|proxmox|xcpng|firewall|custom default: basic Optional: -w, --webhook n8n webhook URL -b, --billing Billing model (hybrid|per-host|time-saved|tiered) default: hybrid -e, --estimate Human time estimate in seconds default: 2700 -v, --vpn VPN type (ipsec|openvpn|none) default: none Hypervisor options (proxmox type): --proxmox-host Proxmox API host IP --proxmox-token-id Proxmox API token ID (e.g. ansible@pve!token-name) --proxmox-token-secret Proxmox API token secret Hypervisor options (xcpng type): --xo-url XenOrchestra URL (e.g. https://xoa.example.com) --xo-token XenOrchestra API token Semaphore/Gitea (usually not needed — read from defaults): --semaphore-url Semaphore base URL (default: http://localhost:3000) --semaphore-token Semaphore API token --gitea-url Gitea repo SSH URL --project-name Override Semaphore project name Modes: --update Update an existing client project (add config, add templates) --dry-run Show what would be done without making changes Examples: # Minimal — firewall-only client $0 -i RP-001 -n 'Royal Pizza' -s royal_pizza --type firewall # Basic Linux client with webhook $0 -i RP-001 -n 'Royal Pizza' -s royal_pizza -w https://n8n.voice1.me/webhook/xxx # Full Proxmox client $0 -i RP-001 -n 'Royal Pizza' -s royal_pizza --type proxmox \\ --proxmox-host 10.1.2.3 --proxmox-token-id ansible@pve!token \\ --proxmox-token-secret abc123 -w https://n8n.voice1.me/webhook/xxx # Add Proxmox to an existing basic client $0 -i RP-001 -n 'Royal Pizza' -s royal_pizza --type proxmox --update \\ --proxmox-host 10.1.2.3 --proxmox-token-id ansible@pve!token \\ --proxmox-token-secret abc123 EOF exit 1 } # ─── Defaults ──────────────────────────────────────────────────────────────── CLIENT_TYPE="basic" BILLING="hybrid" ESTIMATE="2700" VPN="none" 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="" XO_URL="" XO_TOKEN="" WEBHOOK_URL="" PROJECT_NAME_OVERRIDE="" UPDATE_MODE=false 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 ;; -t|--type) CLIENT_TYPE="$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 ;; --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="$2"; shift 2 ;; --xo-token) XO_TOKEN="$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 ;; --update) UPDATE_MODE=true; shift ;; --dry-run) DRY_RUN=true; shift ;; -h|--help) usage ;; *) echo "Unknown option: $1"; 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 # Validate client type case "$CLIENT_TYPE" in basic|linux|proxmox|xcpng|firewall|custom) ;; *) echo "ERROR: Invalid --type '$CLIENT_TYPE'. Must be: basic|linux|proxmox|xcpng|firewall|custom"; exit 1 ;; esac # Normalize 'linux' as alias for 'basic' [[ "$CLIENT_TYPE" == "linux" ]] && CLIENT_TYPE="basic" # Warn if proxmox type but no credentials provided if [[ "$CLIENT_TYPE" == "proxmox" && -z "$PROXMOX_HOST" ]]; then echo " ⚠ --type proxmox specified but --proxmox-host not set." echo " Proxmox templates will be created but credentials will be empty." echo " Re-run with --update --proxmox-host ... to add credentials later." fi # Load SEMAPHORE_TOKEN from env file if not passed if [[ -z "${SEMAPHORE_TOKEN:-}" && -f "/root/.semaphore_env" ]]; then source /root/.semaphore_env fi if [[ -z "${SEMAPHORE_TOKEN:-}" ]]; then echo "ERROR: No Semaphore token. Set SEMAPHORE_TOKEN in /root/.semaphore_env or pass --semaphore-token" exit 1 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" # Determine hypervisor type for inventory case "$CLIENT_TYPE" in proxmox) HYPERVISOR="proxmox" ;; xcpng) HYPERVISOR="xcpng" ;; *) HYPERVISOR="none" ;; esac # ─── Dry run header ────────────────────────────────────────────────────────── if $DRY_RUN; then echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " DRY RUN — no changes will be made" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" fi echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" if $UPDATE_MODE; then echo " Updating client: $CLIENT_NAME ($CLIENT_ID)" else echo " Onboarding client: $CLIENT_NAME ($CLIENT_ID)" fi echo " Type: $CLIENT_TYPE | VPN: $VPN | Billing: $BILLING" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" # ─── Helper: semaphore API call ─────────────────────────────────────────────── semaphore_api() { local METHOD="$1" local PATH="$2" local DATA="${3:-}" if [[ -n "$DATA" ]]; then curl -s -X "$METHOD" "$SEMAPHORE_URL/api$PATH" \ -H "Authorization: Bearer $SEMAPHORE_TOKEN" \ -H "Content-Type: application/json" \ -d "$DATA" else curl -s -X "$METHOD" "$SEMAPHORE_URL/api$PATH" \ -H "Authorization: Bearer $SEMAPHORE_TOKEN" fi } # ─── Step 1: SSH key ───────────────────────────────────────────────────────── echo "" echo "[ 1 ] SSH key..." if [[ -f "$KEY_FILE" ]]; then echo " ⚠ Key already exists at $KEY_FILE — skipping generation" elif $DRY_RUN; then echo " [dry-run] Would generate SSH key at $KEY_FILE" else ssh-keygen -t ed25519 -C "ansible-${CLIENT_SLUG}" -f "$KEY_FILE" -N "" echo " ✓ Key generated: $KEY_FILE" fi echo "" echo " ┌─ Deploy this public key to all client hosts ───────────────────────" echo " │" [[ -f "$KEY_FILE.pub" ]] && sed 's/^/ │ /' "$KEY_FILE.pub" || echo " │ (key not yet generated)" echo " │" echo " └────────────────────────────────────────────────────────────────────" # ─── Step 2: Inventory ─────────────────────────────────────────────────────── echo "" echo "[ 2 ] Inventory..." if [[ -d "$INVENTORY_DIR" && ! $UPDATE_MODE ]]; then echo " ⚠ Inventory already exists at $INVENTORY_DIR — skipping" elif $DRY_RUN; then echo " [dry-run] Would create inventory at $INVENTORY_DIR" else mkdir -p "$INVENTORY_DIR/group_vars" 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: root os_family: "debian" windows_hosts: hosts: {} vars: ansible_user: Administrator ansible_connection: winrm ansible_winrm_transport: ntlm ansible_winrm_server_cert_validation: validate ansible_port: 5986 HOSTSEOF cat > "$INVENTORY_DIR/group_vars/all.yml" << VARSEOF --- # Client: ${CLIENT_NAME} (${CLIENT_ID}) # Onboarded: $(date +%Y-%m-%d) # Type: ${CLIENT_TYPE} # VPN: ${VPN} # Billing: ${BILLING} # Add client-specific overrides below VARSEOF echo " ✓ Inventory created at $INVENTORY_DIR" fi # ─── Step 3: Commit to Gitea ───────────────────────────────────────────────── echo "" echo "[ 3 ] Gitea..." if $DRY_RUN; then echo " [dry-run] Would commit and push to Gitea" else cd "$REPO_DIR" git add . git diff --cached --quiet && echo " ⚠ Nothing to commit" || { git commit -m "Onboard client: ${CLIENT_NAME} (${CLIENT_ID}) — ${CLIENT_TYPE} inventory scaffold" git push origin main echo " ✓ Pushed to Gitea" } fi # ─── Step 4: Semaphore project ─────────────────────────────────────────────── echo "" echo "[ 4 ] Semaphore project..." # Check if project already exists EXISTING_PROJECT=$(semaphore_api GET "/projects" | jq -r ".[] | select(.name == \"$PROJECT_NAME\") | .id") if [[ -n "$EXISTING_PROJECT" && ! $UPDATE_MODE ]]; then echo " ⚠ Project '$PROJECT_NAME' already exists (ID: $EXISTING_PROJECT) — skipping creation" echo " Run with --update to add config to the existing project" PROJECT_ID="$EXISTING_PROJECT" elif $DRY_RUN; then echo " [dry-run] Would create Semaphore project: $PROJECT_NAME" PROJECT_ID="DRY_RUN" else if [[ -n "$EXISTING_PROJECT" ]]; then echo " ℹ Update mode — using existing project (ID: $EXISTING_PROJECT)" PROJECT_ID="$EXISTING_PROJECT" else PROJECT_RESPONSE=$(semaphore_api POST "/projects" \ "{\"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: $PROJECT_RESPONSE" exit 1 fi echo " ✓ Project created: $PROJECT_NAME (ID: $PROJECT_ID)" fi # Keys — check if they exist first to avoid duplicates on --update EXISTING_KEYS=$(semaphore_api GET "/project/$PROJECT_ID/keys") HAS_GITEA=$(echo "$EXISTING_KEYS" | jq -r '.[] | select(.name == "gitea-deploy") | .id') if [[ -z "$HAS_GITEA" ]]; then GITEA_KEY_RESPONSE=$(semaphore_api POST "/project/$PROJECT_ID/keys" \ "{\"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)" else GITEA_KEY_ID="$HAS_GITEA" echo " ⚠ gitea-deploy key already exists (ID: $GITEA_KEY_ID)" fi HAS_CLIENT_KEY=$(echo "$EXISTING_KEYS" | jq -r ".[] | select(.name == \"client-${CLIENT_SLUG}-ssh\") | .id") if [[ -z "$HAS_CLIENT_KEY" ]]; then CLIENT_KEY_RESPONSE=$(semaphore_api POST "/project/$PROJECT_ID/keys" \ "{\"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)" else CLIENT_KEY_ID="$HAS_CLIENT_KEY" echo " ⚠ Client SSH key already exists (ID: $CLIENT_KEY_ID)" fi HAS_NONE=$(echo "$EXISTING_KEYS" | jq -r '.[] | select(.name == "None") | .id') if [[ -z "$HAS_NONE" ]]; then NONE_KEY_RESPONSE=$(semaphore_api POST "/project/$PROJECT_ID/keys" \ "{\"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)" else NONE_KEY_ID="$HAS_NONE" echo " ⚠ None key already exists (ID: $NONE_KEY_ID)" fi # Repository HAS_REPO=$(semaphore_api GET "/project/$PROJECT_ID/repositories" | jq -r '.[] | select(.name == "ansible-msp-automations") | .id') if [[ -z "$HAS_REPO" ]]; then REPO_RESPONSE=$(semaphore_api POST "/project/$PROJECT_ID/repositories" \ "{\"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)" else REPO_ID="$HAS_REPO" echo " ⚠ Repository already linked (ID: $REPO_ID)" fi # Environment / variable group # Build vars JSON — only include non-empty values VARS_JSON=$(jq -n \ --arg webhook "$WEBHOOK_URL" \ --arg cid "$CLIENT_ID" \ --arg cname "$CLIENT_NAME" \ --arg billing "$BILLING" \ --arg estimate "$ESTIMATE" \ '{ CLIENT_ID: $cid, CLIENT_NAME: $cname, BILLING_MODEL: $billing, HUMAN_ESTIMATE_SECONDS: $estimate }') # Add webhook only if provided [[ -n "$WEBHOOK_URL" ]] && VARS_JSON=$(echo "$VARS_JSON" | jq --arg v "$WEBHOOK_URL" '. + {N8N_WEBHOOK_URL: $v}') # Add Proxmox vars only if provided [[ -n "$PROXMOX_HOST" ]] && VARS_JSON=$(echo "$VARS_JSON" | jq --arg v "$PROXMOX_HOST" '. + {PROXMOX_HOST: $v}') [[ -n "$PROXMOX_TOKEN_ID" ]] && VARS_JSON=$(echo "$VARS_JSON" | jq --arg v "$PROXMOX_TOKEN_ID" '. + {PROXMOX_TOKEN_ID: $v}') [[ -n "$PROXMOX_TOKEN_SECRET" ]] && VARS_JSON=$(echo "$VARS_JSON" | jq --arg v "$PROXMOX_TOKEN_SECRET" '. + {PROXMOX_TOKEN_SECRET: $v}') # Add XO vars only if provided [[ -n "$XO_URL" ]] && VARS_JSON=$(echo "$VARS_JSON" | jq --arg v "$XO_URL" '. + {XO_URL: $v}') [[ -n "$XO_TOKEN" ]] && VARS_JSON=$(echo "$VARS_JSON" | jq --arg v "$XO_TOKEN" '. + {XO_TOKEN: $v}') HAS_ENV=$(semaphore_api GET "/project/$PROJECT_ID/environment" | jq -r ".[] | select(.name == \"${CLIENT_SLUG}-vars\") | .id") if [[ -z "$HAS_ENV" ]]; then ENV_RESPONSE=$(semaphore_api POST "/project/$PROJECT_ID/environment" \ "{\"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)" else ENV_ID="$HAS_ENV" echo " ⚠ Variable group already exists (ID: $ENV_ID) — updating..." semaphore_api PUT "/project/$PROJECT_ID/environment/$ENV_ID" \ "{\"id\": $ENV_ID, \"name\": \"${CLIENT_SLUG}-vars\", \"project_id\": $PROJECT_ID, \"json\": $(echo "$VARS_JSON" | jq -Rs .), \"env\": \"{}\"}" > /dev/null echo " ✓ Variable group updated" fi # Inventory HAS_INVENTORY=$(semaphore_api GET "/project/$PROJECT_ID/inventory" | jq -r ".[] | select(.name == \"client-${CLIENT_SLUG}\") | .id") if [[ -z "$HAS_INVENTORY" ]]; then INVENTORY_RESPONSE=$(semaphore_api POST "/project/$PROJECT_ID/inventory" \ "{\"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)" else INVENTORY_ID="$HAS_INVENTORY" echo " ⚠ Inventory already exists (ID: $INVENTORY_ID)" fi fi # ─── Step 5: Task templates ────────────────────────────────────────────────── echo "" echo "[ 5 ] Task templates..." create_template() { local TNAME="$1" local PLAYBOOK="$2" local DESC="$3" local EXTRA_ARGS="${4:-}" $DRY_RUN && { echo " [dry-run] Would create template: $TNAME"; return; } [[ "$PROJECT_ID" == "DRY_RUN" ]] && return # Skip if template already exists EXISTING=$(semaphore_api GET "/project/$PROJECT_ID/templates" | jq -r ".[] | select(.name == \"$TNAME\") | .id") if [[ -n "$EXISTING" ]]; then echo " ⚠ Template '$TNAME' already exists (ID: $EXISTING) — skipping" return fi ARGS_JSON="[]" [[ -n "$EXTRA_ARGS" ]] && ARGS_JSON="[\"$EXTRA_ARGS\"]" RESPONSE=$(semaphore_api POST "/project/$PROJECT_ID/templates" \ "{\"project_id\": $PROJECT_ID, \"inventory_id\": $INVENTORY_ID, \"repository_id\": $REPO_ID, \"environment_id\": $ENV_ID, \"name\": \"$TNAME\", \"playbook\": \"$PLAYBOOK\", \"arguments\": \"$ARGS_JSON\", \"allow_override_args_in_task\": false, \"description\": \"$DESC\", \"app\": \"ansible\"}") echo " ✓ $(echo "$RESPONSE" | jq -r '"Template: \(.name) (ID: \(.id))"')" } # Templates common to all client types except 'custom' if [[ "$CLIENT_TYPE" != "custom" ]]; then create_template "Preflight Check" \ "playbooks/site_preflight.yml" \ "Run safety checks on all hosts before maintenance" fi # Templates for clients with Linux hosts if [[ "$CLIENT_TYPE" == "basic" || "$CLIENT_TYPE" == "proxmox" || "$CLIENT_TYPE" == "xcpng" ]]; then create_template "Linux Patch" \ "playbooks/linux_patch.yml" \ "Full Linux patch run with version tracking" create_template "Scheduled Reboot" \ "playbooks/linux_reboot.yml" \ "Reboot hosts that require it after kernel updates" create_template "Full Maintenance" \ "playbooks/site_maintenance.yml" \ "Full maintenance: preflight, patch, reboot" fi # Proxmox-specific templates if [[ "$CLIENT_TYPE" == "proxmox" ]]; then create_template "Proxmox Status" \ "playbooks/proxmox_status.yml" \ "Cluster health report — nodes, VMs, CEPH, HA" create_template "Proxmox Config Backup" \ "playbooks/proxmox_config_backup.yml" \ "Backup Proxmox node configs" create_template "Proxmox Rolling Upgrade" \ "playbooks/proxmox_upgrade.yml" \ "Rolling upgrade of all Proxmox cluster nodes" create_template "Proxmox Migrate VMs" \ "playbooks/proxmox_migrate_vms.yml" \ "Migrate VMs between nodes — drain/rebalance/restore/targeted" create_template "Proxmox Snapshot" \ "playbooks/proxmox_snapshot.yml" \ "Pre/post maintenance VM snapshots" fi # XCP-NG-specific templates if [[ "$CLIENT_TYPE" == "xcpng" ]]; then create_template "XCP-NG Pool Upgrade" \ "playbooks/xcp_pool_upgrade.yml" \ "Rolling XCP-NG pool upgrade" create_template "Snapshot (XCP-NG)" \ "playbooks/xcp_xo_vm_snapshot.yml" \ "Pre-maintenance VM snapshots via XenOrchestra" create_template "Snapshot Cleanup" \ "playbooks/xcp_xo_snapshot_cleanup.yml" \ "Clean up old XCP-NG snapshots" create_template "Migrate VMs (XCP-NG)" \ "playbooks/xcp_xo_vm_migrate.yml" \ "VM migration via XenOrchestra" fi # ─── Step 6: Summary ───────────────────────────────────────────────────────── echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" if $DRY_RUN; then echo " [dry-run complete] No changes made." elif $UPDATE_MODE; then echo " ✓ Client update complete: $CLIENT_NAME" else echo " ✓ Client onboarding complete: $CLIENT_NAME" fi echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" [[ "$PROJECT_ID" != "DRY_RUN" ]] && echo " Semaphore project ID: $PROJECT_ID" echo " Inventory: $INVENTORY_DIR/hosts.yml" echo " Client type: $CLIENT_TYPE" 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 " 3. Run 'Preflight Check' in Semaphore to verify connectivity" echo "" if [[ -f "$KEY_FILE.pub" ]]; then echo " Public key to deploy to client hosts:" sed 's/^/ /' "$KEY_FILE.pub" echo "" fi