#!/bin/bash ################################################################################ # Script Name: caprover-smoke-tests.sh # Version: 1.01 # Description: Smoke test suite for CapRover PaaS — validates API health, # app deployment lifecycle, SSL certificates, Docker Swarm status, # and resource usage # # Author: Phil Connor # Contact: contact@mylinux.work # Website: https://mylinux.work # License: MIT # # Prerequisites: # - bash 4+ # - curl # - jq # - openssl (for SSL checks) # # Usage: # export CAPROVER_URL="https://captain.apps.example.com" # export CAPROVER_PASSWORD="your-password" # ./caprover-smoke-tests.sh # ./caprover-smoke-tests.sh --skip-app --skip-ssl # ./caprover-smoke-tests.sh --format tap # ./caprover-smoke-tests.sh --format junit --junit-file results.xml # ################################################################################ set -euo pipefail # --- Defaults --- CAPROVER_URL="${CAPROVER_URL:-}" CAPROVER_PASSWORD="${CAPROVER_PASSWORD:-}" CURL_TIMEOUT="${CURL_TIMEOUT:-10}" CURL_INSECURE="${CURL_INSECURE:-false}" SKIP_APP="${SKIP_APP_LIFECYCLE:-false}" SKIP_SSL="${SKIP_SSL:-false}" OUTPUT_FORMAT="${OUTPUT_FORMAT:-text}" JUNIT_FILE="${JUNIT_FILE:-smoke-results.xml}" VERBOSE=false USE_COLOR=true AUTH_TOKEN="" TEST_APP_NAME="" PASSED=0 FAILED=0 SKIPPED=0 START_TIME="" CURL_OPTS=() JUNIT_RESULTS=() TAP_RESULTS=() TEST_NUM=0 # --- Colors --- RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' usage() { cat <&2 exit 1 fi if [[ -z "$CAPROVER_PASSWORD" ]]; then echo "Error: CAPROVER_PASSWORD is required" >&2 exit 1 fi # Strip trailing slash CAPROVER_URL="${CAPROVER_URL%/}" # --- Helpers --- debug() { if [[ "$VERBOSE" == "true" ]]; then echo -e " ${CYAN}[debug]${NC} $*" >&2 fi } api_call() { local method="$1" endpoint="$2" shift 2 local url="${CAPROVER_URL}${endpoint}" debug "curl -s -X $method $url" curl -s -X "$method" \ --connect-timeout "$CURL_TIMEOUT" \ --max-time "$((CURL_TIMEOUT * 3))" \ -H "Content-Type: application/json" \ -H "x-captain-auth: ${AUTH_TOKEN}" \ "${CURL_OPTS[@]}" \ "$url" "$@" } pass() { local suite="$1" msg="$2" ((TEST_NUM++)) || true ((PASSED++)) || true case "$OUTPUT_FORMAT" in tap) TAP_RESULTS+=("ok $TEST_NUM - [$suite] $msg") ;; junit) JUNIT_RESULTS+=("") ;; *) echo -e " ${GREEN}✓${NC} $msg" ;; esac } fail() { local suite="$1" msg="$2" detail="${3:-}" ((TEST_NUM++)) || true ((FAILED++)) || true case "$OUTPUT_FORMAT" in tap) TAP_RESULTS+=("not ok $TEST_NUM - [$suite] $msg") ;; junit) JUNIT_RESULTS+=("$detail") ;; *) echo -e " ${RED}✗${NC} $msg${detail:+ — $detail}" ;; esac } skip() { local suite="$1" msg="$2" ((TEST_NUM++)) || true ((SKIPPED++)) || true case "$OUTPUT_FORMAT" in tap) TAP_RESULTS+=("ok $TEST_NUM - [$suite] $msg # SKIP") ;; junit) JUNIT_RESULTS+=("") ;; *) echo -e " ${YELLOW}⊘${NC} $msg — skipped" ;; esac } suite_header() { if [[ "$OUTPUT_FORMAT" == "text" ]]; then echo -e "\n${BOLD}$1${NC}" fi } # --- Cleanup --- cleanup() { if [[ -n "$TEST_APP_NAME" && -n "$AUTH_TOKEN" ]]; then debug "Cleaning up test app: $TEST_APP_NAME" api_call POST "/api/v2/user/apps/appDefinitions/delete" \ -d "{\"appName\":\"$TEST_APP_NAME\"}" >/dev/null 2>&1 || true TEST_APP_NAME="" fi } trap cleanup EXIT INT TERM # --- Header --- START_TIME=$(date +%s) if [[ "$OUTPUT_FORMAT" == "text" ]]; then echo -e "${BOLD}CapRover Smoke Tests${NC}" echo "Target: $CAPROVER_URL" echo "Time: $(date -u +%Y-%m-%dT%H:%M:%SZ)" fi # ===================================================== # Suite 1: Connectivity # ===================================================== suite_header "Connectivity" http_code=$(curl -s -o /dev/null -w "%{http_code}" \ --connect-timeout "$CURL_TIMEOUT" \ "${CURL_OPTS[@]}" \ "$CAPROVER_URL/" 2>/dev/null || echo "000") if [[ "$http_code" =~ ^(200|302)$ ]]; then pass "Connectivity" "Dashboard reachable — HTTP $http_code" else fail "Connectivity" "Dashboard unreachable" "HTTP $http_code" fi api_code=$(curl -s -o /dev/null -w "%{http_code}" \ --connect-timeout "$CURL_TIMEOUT" \ "${CURL_OPTS[@]}" \ "$CAPROVER_URL/api/v2/user/system/info" 2>/dev/null || echo "000") if [[ "$api_code" != "000" ]]; then pass "Connectivity" "API endpoint responding — HTTP $api_code" else fail "Connectivity" "API endpoint not responding" fi # ===================================================== # Suite 2: API # ===================================================== suite_header "API" login_response=$(curl -s -X POST \ --connect-timeout "$CURL_TIMEOUT" \ --max-time "$((CURL_TIMEOUT * 3))" \ -H "Content-Type: application/json" \ "${CURL_OPTS[@]}" \ "$CAPROVER_URL/api/v2/login" \ -d "{\"password\":\"$CAPROVER_PASSWORD\"}" 2>/dev/null || echo "{}") debug "Login response: $login_response" AUTH_TOKEN=$(echo "$login_response" | jq -r '.data.token // empty' 2>/dev/null || true) if [[ -n "$AUTH_TOKEN" ]]; then pass "API" "API login — authenticated successfully" else fail "API" "API login failed" "Could not obtain auth token" # Cannot continue without auth if [[ "$OUTPUT_FORMAT" == "text" ]]; then echo -e "\n${RED}Cannot continue without authentication. Aborting.${NC}" fi exit 1 fi # App definitions app_response=$(api_call GET "/api/v2/user/apps/appDefinitions" 2>/dev/null || echo "{}") app_count=$(echo "$app_response" | jq -r '.data.appDefinitions | length // 0' 2>/dev/null || echo "0") status_code=$(echo "$app_response" | jq -r '.status // 0' 2>/dev/null || echo "0") if [[ "$status_code" == "100" ]]; then pass "API" "App definitions — $app_count apps found" else fail "API" "App definitions" "Unexpected status: $status_code" fi # Version version_info=$(api_call GET "/api/v2/user/system/versioninfo" 2>/dev/null || echo "{}") cr_version=$(echo "$version_info" | jq -r '.data.currentVersion // "unknown"' 2>/dev/null || echo "unknown") if [[ "$cr_version" != "unknown" ]]; then pass "API" "CapRover version — $cr_version" else fail "API" "CapRover version" "Could not retrieve version" fi # System info sys_response=$(api_call GET "/api/v2/user/system/info" 2>/dev/null || echo "{}") sys_status=$(echo "$sys_response" | jq -r '.status // 0' 2>/dev/null || echo "0") if [[ "$sys_status" == "100" ]]; then pass "API" "System info — retrieved successfully" else fail "API" "System info" "Unexpected status: $sys_status" fi # ===================================================== # Suite 3: App Lifecycle # ===================================================== if [[ "$SKIP_APP" == "true" ]]; then suite_header "App Lifecycle" skip "App Lifecycle" "Create test app" skip "App Lifecycle" "Deploy image" skip "App Lifecycle" "App responding" skip "App Lifecycle" "Delete test app" else suite_header "App Lifecycle" TEST_APP_NAME="smoke-test-$(date +%s)" debug "Test app name: $TEST_APP_NAME" # Create app create_response=$(api_call POST "/api/v2/user/apps/appDefinitions/register" \ -d "{\"appName\":\"$TEST_APP_NAME\",\"hasPersistentData\":false}" 2>/dev/null || echo "{}") create_status=$(echo "$create_response" | jq -r '.status // 0' 2>/dev/null || echo "0") if [[ "$create_status" == "100" ]]; then pass "App Lifecycle" "Create test app — $TEST_APP_NAME" else fail "App Lifecycle" "Create test app" "$(echo "$create_response" | jq -r '.description // "unknown error"' 2>/dev/null)" skip "App Lifecycle" "Deploy image" skip "App Lifecycle" "App responding" skip "App Lifecycle" "Delete test app" TEST_APP_NAME="" SKIP_APP=true fi if [[ "$SKIP_APP" != "true" ]]; then # Deploy image deploy_response=$(api_call POST "/api/v2/user/apps/appData/$TEST_APP_NAME" \ -d "{\"captainDefinitionContent\":\"{\\\"schemaVersion\\\":2,\\\"imageName\\\":\\\"nginxdemos/hello\\\"}\"}" 2>/dev/null || echo "{}") deploy_status=$(echo "$deploy_response" | jq -r '.status // 0' 2>/dev/null || echo "0") if [[ "$deploy_status" == "100" ]]; then pass "App Lifecycle" "Deploy image — nginxdemos/hello deployed" else fail "App Lifecycle" "Deploy image" "$(echo "$deploy_response" | jq -r '.description // "deploy failed"' 2>/dev/null)" fi # Wait for app to be running (up to 60 seconds) app_ready=false for i in $(seq 1 12); do sleep 5 debug "Waiting for app to start... attempt $i/12" check=$(api_call GET "/api/v2/user/apps/appDefinitions" 2>/dev/null || echo "{}") is_running=$(echo "$check" | jq -r ".data.appDefinitions[] | select(.appName==\"$TEST_APP_NAME\") | .isAppBuilding" 2>/dev/null || echo "true") if [[ "$is_running" == "false" ]]; then app_ready=true break fi done # Extract root domain from CapRover URL to build app URL root_domain=$(echo "$CAPROVER_URL" | sed -E 's|https?://captain\.||') app_url="http://${TEST_APP_NAME}.${root_domain}" debug "App URL: $app_url" if [[ "$app_ready" == "true" ]]; then # Give nginx a moment to reconfigure sleep 3 app_http=$(curl -s -o /dev/null -w "%{http_code}" \ --connect-timeout "$CURL_TIMEOUT" \ "${CURL_OPTS[@]}" \ "$app_url" 2>/dev/null || echo "000") if [[ "$app_http" == "200" ]]; then pass "App Lifecycle" "App responding — HTTP 200 at ${TEST_APP_NAME}.${root_domain}" else fail "App Lifecycle" "App responding" "HTTP $app_http at $app_url" fi else fail "App Lifecycle" "App responding" "Timed out waiting for app to start" fi # Delete test app delete_response=$(api_call POST "/api/v2/user/apps/appDefinitions/delete" \ -d "{\"appName\":\"$TEST_APP_NAME\"}" 2>/dev/null || echo "{}") delete_status=$(echo "$delete_response" | jq -r '.status // 0' 2>/dev/null || echo "0") if [[ "$delete_status" == "100" ]]; then pass "App Lifecycle" "Delete test app — cleaned up" TEST_APP_NAME="" else fail "App Lifecycle" "Delete test app" "Manual cleanup may be required" fi fi fi # ===================================================== # Suite 4: SSL # ===================================================== if [[ "$SKIP_SSL" == "true" ]]; then suite_header "SSL" skip "SSL" "TLS certificate valid" skip "SSL" "Certificate chain complete" else suite_header "SSL" # Extract hostname from URL cr_host=$(echo "$CAPROVER_URL" | sed -E 's|https?://||;s|/.*||;s|:.*||') cr_port=$(echo "$CAPROVER_URL" | grep -oP ':\K[0-9]+' || echo "443") if [[ "$CAPROVER_URL" == https://* ]]; then cert_output=$(echo | openssl s_client -servername "$cr_host" -connect "${cr_host}:${cr_port}" 2>/dev/null || true) cert_enddate=$(echo "$cert_output" | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2 || true) if [[ -n "$cert_enddate" ]]; then expiry_epoch=$(date -d "$cert_enddate" +%s 2>/dev/null || echo "0") now_epoch=$(date +%s) days_left=$(( (expiry_epoch - now_epoch) / 86400 )) if [[ "$days_left" -gt 0 ]]; then pass "SSL" "TLS certificate valid — $days_left days remaining" else fail "SSL" "TLS certificate expired" "$days_left days past expiry" fi else fail "SSL" "TLS certificate valid" "Could not read certificate" fi # Check chain verify_result=$(echo | openssl s_client -servername "$cr_host" -connect "${cr_host}:${cr_port}" 2>&1 | grep "Verify return code" || true) if echo "$verify_result" | grep -q "0 (ok)"; then pass "SSL" "Certificate chain complete" else fail "SSL" "Certificate chain complete" "$verify_result" fi else skip "SSL" "TLS certificate valid" skip "SSL" "Certificate chain complete" fi fi # ===================================================== # Suite 5: Docker Swarm # ===================================================== suite_header "Docker Swarm" node_count=$(echo "$sys_response" | jq -r '.data.swarmNodesCount // "unknown"' 2>/dev/null || echo "unknown") if [[ "$node_count" != "unknown" && "$node_count" -gt 0 ]] 2>/dev/null; then pass "Docker Swarm" "Swarm active — $node_count node(s)" else fail "Docker Swarm" "Swarm status" "Could not determine node count" fi # Count running services from app definitions running_count=$(echo "$app_response" | jq '[.data.appDefinitions[] | select(.isAppBuilding == false)] | length' 2>/dev/null || echo "0") total_count=$(echo "$app_response" | jq '.data.appDefinitions | length' 2>/dev/null || echo "0") # Add 3 for captain-captain, captain-nginx, captain-certbot service_count=$((running_count + 3)) pass "Docker Swarm" "Services running — $service_count services ($total_count apps + 3 system)" # ===================================================== # Suite 6: Resources # ===================================================== suite_header "Resources" disk_used=$(echo "$sys_response" | jq -r '.data.diskUsedPercentage // "unknown"' 2>/dev/null || echo "unknown") if [[ "$disk_used" != "unknown" ]]; then pass "Resources" "Disk usage — ${disk_used}%" else fail "Resources" "Disk usage" "Could not retrieve disk info" fi volume_count=$(echo "$sys_response" | jq -r '.data.dockerVolumes | length // "unknown"' 2>/dev/null || echo "unknown") if [[ "$volume_count" != "unknown" ]]; then pass "Resources" "Docker volumes — $volume_count volumes" else # Volumes may not be in system info, skip gracefully skip "Resources" "Docker volumes" fi image_count=$(echo "$sys_response" | jq -r '.data.dockerImages | length // "unknown"' 2>/dev/null || echo "unknown") if [[ "$image_count" != "unknown" ]]; then pass "Resources" "Docker images — $image_count images" else skip "Resources" "Docker images" fi # ===================================================== # Summary # ===================================================== END_TIME=$(date +%s) DURATION=$((END_TIME - START_TIME)) case "$OUTPUT_FORMAT" in tap) echo "TAP version 13" echo "1..$TEST_NUM" for line in "${TAP_RESULTS[@]}"; do echo "$line" done echo "# passed: $PASSED" echo "# failed: $FAILED" echo "# skipped: $SKIPPED" echo "# duration: ${DURATION}s" ;; junit) { echo '' echo "" echo " " echo " " echo " " for result in "${JUNIT_RESULTS[@]}"; do echo " $result" done echo "" } > "$JUNIT_FILE" echo "JUnit results written to $JUNIT_FILE" ;; *) echo "" echo "────────────────────────────────────────" echo -e "Summary ${BOLD}$CAPROVER_URL${NC}" echo -e " ${GREEN}$PASSED passed${NC} ${RED}$FAILED failed${NC} ${YELLOW}$SKIPPED skipped${NC} (${DURATION}s)" echo "────────────────────────────────────────" if [[ "$FAILED" -eq 0 ]]; then echo -e "${GREEN}All tests passed.${NC}" else echo -e "${RED}Some tests failed.${NC}" fi ;; esac exit $((FAILED > 0 ? 1 : 0))