#!/bin/bash ################################################################################ # Script Name: dokku-smoke-tests.sh # Version: 1.0 # Description: Smoke test suite for Dokku PaaS — validates connectivity, # app deployment lifecycle, plugin health, SSL certificates, # and resource usage via the dokku CLI # # Author: Phil Connor # Contact: contact@mylinux.work # Website: https://mylinux.work # License: MIT # # Prerequisites: # - bash 4+ # - dokku binary (run on the Dokku host) # - Root or dokku user access # # Usage: # sudo ./dokku-smoke-tests.sh # sudo ./dokku-smoke-tests.sh --skip-app --skip-ssl # sudo ./dokku-smoke-tests.sh --format tap # sudo ./dokku-smoke-tests.sh --format junit --junit-file results.xml # ################################################################################ set -euo pipefail # --- Defaults --- SKIP_APP="${SKIP_APP_LIFECYCLE:-false}" SKIP_SSL="${SKIP_SSL:-false}" OUTPUT_FORMAT="${OUTPUT_FORMAT:-text}" JUNIT_FILE="${JUNIT_FILE:-smoke-results.xml}" DOKKU_DOMAIN="${DOKKU_DOMAIN:-}" VERBOSE=false USE_COLOR=true TEST_APP_NAME="" PASSED=0 FAILED=0 SKIPPED=0 START_TIME="" 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 fi } 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" ]]; then debug "Cleaning up test app: $TEST_APP_NAME" dokku apps:destroy "$TEST_APP_NAME" --force >/dev/null 2>&1 || true TEST_APP_NAME="" fi } trap cleanup EXIT INT TERM # --- Header --- START_TIME=$(date +%s) HOSTNAME_STR=$(hostname -f 2>/dev/null || hostname) if [[ "$OUTPUT_FORMAT" == "text" ]]; then echo -e "${BOLD}Dokku Smoke Tests${NC}" echo "Host: $HOSTNAME_STR" echo "Time: $(date -u +%Y-%m-%dT%H:%M:%SZ)" fi # ===================================================== # Suite 1: Connectivity # ===================================================== suite_header "Connectivity" # Check dokku binary DOKKU_BIN=$(command -v dokku 2>/dev/null || true) if [[ -n "$DOKKU_BIN" ]]; then pass "Connectivity" "Dokku binary found — $DOKKU_BIN" else fail "Connectivity" "Dokku binary not found" "dokku is not in PATH" echo -e "\n${RED}Cannot continue without dokku binary. Aborting.${NC}" >&2 exit 1 fi # Check Docker daemon if docker info >/dev/null 2>&1; then pass "Connectivity" "Docker daemon running" else fail "Connectivity" "Docker daemon not running" "docker info failed" fi # Check dokku version DOKKU_VERSION=$(dokku version 2>/dev/null | grep -oP 'dokku version \K[0-9]+\.[0-9]+\.[0-9]+' || true) if [[ -z "$DOKKU_VERSION" ]]; then # Try alternate format DOKKU_VERSION=$(dokku version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' || true) fi if [[ -n "$DOKKU_VERSION" ]]; then pass "Connectivity" "Dokku version — $DOKKU_VERSION" else fail "Connectivity" "Dokku version" "Could not parse version string" fi # Auto-detect global domain if not set if [[ -z "$DOKKU_DOMAIN" ]]; then DOKKU_DOMAIN=$(dokku domains:report --global 2>/dev/null | grep -i "global vhosts" | awk '{print $NF}' || true) if [[ -z "$DOKKU_DOMAIN" ]]; then DOKKU_DOMAIN=$(dokku domains:report --global 2>/dev/null | tail -1 | awk '{print $NF}' || true) fi debug "Auto-detected domain: $DOKKU_DOMAIN" fi # ===================================================== # Suite 2: 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="dokku-smoke-$(date +%s)" debug "Test app name: $TEST_APP_NAME" # Create app create_output=$(dokku apps:create "$TEST_APP_NAME" 2>&1) || true debug "Create output: $create_output" if dokku apps:exists "$TEST_APP_NAME" >/dev/null 2>&1; then pass "App Lifecycle" "Create test app — $TEST_APP_NAME" else fail "App Lifecycle" "Create test app" "$create_output" 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 via git:from-image deploy_output=$(dokku git:from-image "$TEST_APP_NAME" nginxdemos/hello 2>&1) || true debug "Deploy output: $deploy_output" # Check if app is running app_running=false for i in $(seq 1 12); do sleep 5 debug "Waiting for app to start... attempt $i/12" ps_output=$(dokku ps:report "$TEST_APP_NAME" 2>/dev/null || true) if echo "$ps_output" | grep -qi "running"; then app_running=true break fi # Also check container status directly running_count=$(dokku ps:report "$TEST_APP_NAME" 2>/dev/null | grep -i "running" | wc -l || echo "0") if [[ "$running_count" -gt 0 ]]; then app_running=true break fi done if [[ "$app_running" == "true" ]]; then pass "App Lifecycle" "Deploy image — nginxdemos/hello deployed" else fail "App Lifecycle" "Deploy image" "App not running after 60s" fi # Verify HTTP response if [[ -n "$DOKKU_DOMAIN" ]]; then app_url="http://${TEST_APP_NAME}.${DOKKU_DOMAIN}" debug "App URL: $app_url" sleep 3 app_http=$(curl -s -o /dev/null -w "%{http_code}" \ --connect-timeout 10 --max-time 30 \ "$app_url" 2>/dev/null || echo "000") if [[ "$app_http" == "200" ]]; then pass "App Lifecycle" "App responding — HTTP 200 at ${TEST_APP_NAME}.${DOKKU_DOMAIN}" else fail "App Lifecycle" "App responding" "HTTP $app_http at $app_url" fi else # No domain configured — check container port directly debug "No global domain — checking container directly" port=$(dokku proxy:ports "$TEST_APP_NAME" 2>/dev/null | grep -oP ':\K[0-9]+$' | head -1 || true) if [[ -n "$port" ]]; then app_http=$(curl -s -o /dev/null -w "%{http_code}" \ --connect-timeout 10 --max-time 30 \ "http://localhost:$port" 2>/dev/null || echo "000") if [[ "$app_http" == "200" ]]; then pass "App Lifecycle" "App responding — HTTP 200 on port $port" else fail "App Lifecycle" "App responding" "HTTP $app_http on port $port" fi else skip "App Lifecycle" "App responding" fi fi # Delete test app delete_output=$(dokku apps:destroy "$TEST_APP_NAME" --force 2>&1) || true debug "Delete output: $delete_output" if ! dokku apps:exists "$TEST_APP_NAME" >/dev/null 2>&1; 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 3: Plugin Health # ===================================================== suite_header "Plugins" plugin_list=$(dokku plugin:list 2>/dev/null || true) debug "Plugin list: $plugin_list" if [[ -n "$plugin_list" ]]; then plugin_count=$(echo "$plugin_list" | grep -c "enabled" || echo "0") pass "Plugins" "Plugin list — $plugin_count plugins installed" else fail "Plugins" "Plugin list" "dokku plugin:list failed" fi # Check core plugins CORE_PLUGINS=("nginx-vhosts" "apps" "config" "ps") for plugin in "${CORE_PLUGINS[@]}"; do if echo "$plugin_list" | grep -q "$plugin"; then pass "Plugins" "Core plugin present — $plugin" else fail "Plugins" "Core plugin present — $plugin" "Not found in plugin list" fi done # ===================================================== # Suite 4: SSL # ===================================================== if [[ "$SKIP_SSL" == "true" ]]; then suite_header "SSL" skip "SSL" "Letsencrypt plugin installed" skip "SSL" "TLS certificate valid" else suite_header "SSL" # Check if letsencrypt plugin is installed le_installed=false if echo "$plugin_list" | grep -qi "letsencrypt"; then le_installed=true pass "SSL" "Letsencrypt plugin installed" else skip "SSL" "Letsencrypt plugin installed" fi # Check global domain certificate if [[ "$le_installed" == "true" && -n "$DOKKU_DOMAIN" ]]; then # Check certificate via openssl if the domain resolves cert_host="$DOKKU_DOMAIN" cert_output=$(echo | openssl s_client -servername "$cert_host" -connect "${cert_host}:443" 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 skip "SSL" "TLS certificate valid" fi else skip "SSL" "TLS certificate valid" fi fi # ===================================================== # Suite 5: Resources # ===================================================== suite_header "Resources" # Disk usage disk_line=$(df -h / 2>/dev/null | tail -1 || true) if [[ -n "$disk_line" ]]; then disk_pct=$(echo "$disk_line" | awk '{print $5}' | tr -d '%') disk_used=$(echo "$disk_line" | awk '{print $3}') disk_total=$(echo "$disk_line" | awk '{print $2}') pass "Resources" "Disk usage — ${disk_pct}% (${disk_used} / ${disk_total})" else fail "Resources" "Disk usage" "Could not read disk info" fi # Docker images image_count=$(docker images -q 2>/dev/null | wc -l || echo "0") pass "Resources" "Docker images — $image_count images" # Docker volumes volume_count=$(docker volume ls -q 2>/dev/null | wc -l || echo "0") pass "Resources" "Docker volumes — $volume_count volumes" # Docker containers container_count=$(docker ps -q 2>/dev/null | wc -l || echo "0") pass "Resources" "Docker containers — $container_count running" # ===================================================== # 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}$HOSTNAME_STR${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))