#!/bin/bash # Production Deployment Verification Script # Run this before and after production deployment set -e echo "╔════════════════════════════════════════════════════════════════╗" echo "║ DigiServer v2 Production Deployment Verification ║" echo "╚════════════════════════════════════════════════════════════════╝" TIMESTAMP=$(date +%Y-%m-%d\ %H:%M:%S) cd "$(dirname "$0")" # Color codes RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color # Counters PASSED=0 FAILED=0 WARNINGS=0 # Helper functions # NOTE: `((VAR++))` evaluates to the PRE-increment value, so the very first # increment returns 0 — a non-zero exit status that `set -e` treats as a fatal # error, aborting the whole script after the first check. Use `VAR=$((VAR+1))` # (always status 0) instead. pass() { echo -e "${GREEN}✓${NC} $1" PASSED=$((PASSED + 1)) } fail() { echo -e "${RED}✗${NC} $1" FAILED=$((FAILED + 1)) } warn() { echo -e "${YELLOW}⚠${NC} $1" WARNINGS=$((WARNINGS + 1)) } info() { echo -e "${BLUE}ℹ${NC} $1" } section() { echo -e "\n${BLUE}═══════════════════════════════════════${NC}" echo -e "${BLUE} $1${NC}" echo -e "${BLUE}═══════════════════════════════════════${NC}" } # ============================================================================ section "1. Git Status" # ============================================================================ if git rev-parse --git-dir > /dev/null 2>&1; then pass "Git repository initialized" BRANCH=$(git rev-parse --abbrev-ref HEAD) COMMIT=$(git rev-parse --short HEAD) info "Current branch: $BRANCH, Commit: $COMMIT" if [ -n "$(git status --porcelain)" ]; then warn "Uncommitted changes detected" git status --short else pass "All changes committed" fi else fail "Not a git repository" fi # ============================================================================ section "2. Environment Configuration" # ============================================================================ if [ -f .env ]; then pass ".env file exists" else warn ".env file not found (using defaults or docker-compose environment)" fi if [ -f .env.example ]; then pass ".env.example template exists" else warn ".env.example template missing" fi # ============================================================================ section "3. Docker Configuration" # ============================================================================ if command -v docker &> /dev/null; then pass "Docker installed" DOCKER_VERSION=$(docker --version | cut -d' ' -f3 | tr -d ',') info "Docker version: $DOCKER_VERSION" else fail "Docker not installed" fi # Accept either the modern compose plugin or the standalone v1 binary. if docker compose version &> /dev/null; then COMPOSE="docker compose" pass "Docker Compose plugin installed" DC_VERSION=$(docker compose version --short 2>/dev/null || docker compose version | head -1) info "Compose version: $DC_VERSION" elif command -v docker-compose &> /dev/null; then COMPOSE="docker-compose" warn "Using legacy 'docker-compose' (v1); the 'docker compose' plugin is unavailable" DC_VERSION=$(docker-compose --version | cut -d' ' -f3 | tr -d ',') info "Docker Compose version: $DC_VERSION" # Compose v1 builds require buildx >= 0.17; older buildx must use `docker build`. BUILDX_VER=$(docker buildx version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+' | head -1 || true) if [ -n "$BUILDX_VER" ]; then _maj="${BUILDX_VER%%.*}"; _min="${BUILDX_VER##*.}" if [ "$_maj" -eq 0 ] && [ "$_min" -lt 17 ]; then info "buildx $BUILDX_VER too old for compose v1 builds — deploy.sh falls back to 'docker build'" fi else info "buildx not available — deploy.sh falls back to 'docker build'" fi else fail "Docker Compose not installed" COMPOSE="" fi if [ -f docker-compose.yml ]; then pass "docker-compose.yml exists" # Validate syntax if [ -n "$COMPOSE" ] && $COMPOSE config > /dev/null 2>&1; then pass "docker-compose.yml syntax valid" else fail "docker-compose.yml syntax error" fi else fail "docker-compose.yml not found" fi # ============================================================================ section "4. Dockerfile & Images" # ============================================================================ if [ -f Dockerfile ]; then pass "Dockerfile exists" # Check for security best practices if grep -q "HEALTHCHECK" Dockerfile; then pass "Health check configured" else warn "No health check in Dockerfile" fi if grep -q "USER appuser" Dockerfile || grep -q "USER.*:1000" Dockerfile; then pass "Non-root user configured" else warn "Root user may be used in container" fi if grep -q "FROM.*alpine\|FROM.*slim\|FROM.*distroless" Dockerfile; then pass "Minimal base image used" else warn "Large base image detected" fi else fail "Dockerfile not found" fi # ============================================================================ section "5. Python Dependencies" # ============================================================================ if [ -f requirements.txt ]; then pass "requirements.txt exists" PACKAGE_COUNT=$(wc -l < requirements.txt) info "Total packages: $PACKAGE_COUNT" # Check for critical packages for pkg in Flask SQLAlchemy gunicorn flask-cors cryptography; do if grep -q "$pkg" requirements.txt; then pass "$pkg installed" else warn "$pkg not found in requirements.txt" fi done # Check for specific versions FLASK_VERSION=$(grep "^Flask==" requirements.txt 2>/dev/null | cut -d'=' -f3 || true) SQLALCHEMY_VERSION=$(grep "^SQLAlchemy==" requirements.txt | cut -d'=' -f3) if [ -n "$FLASK_VERSION" ]; then info "Flask version: $FLASK_VERSION" fi if [ -n "$SQLALCHEMY_VERSION" ]; then info "SQLAlchemy version: $SQLALCHEMY_VERSION" fi else fail "requirements.txt not found" fi # ============================================================================ section "6. Database Configuration" # ============================================================================ if [ -d migrations ]; then pass "migrations directory exists" MIGRATION_COUNT=$(find migrations -name "*.py" | wc -l) info "Migration files: $MIGRATION_COUNT" if [ "$MIGRATION_COUNT" -gt 0 ]; then pass "Database migrations configured" else warn "No migration files found" fi else warn "migrations directory not found" fi # ============================================================================ section "7. TLS Certificate (Caddy)" # ============================================================================ # Caddy stores its internal CA and issued certificates under data/caddy-data. # Those files are created by the (root) Caddy process, so read them through the # container rather than from the host filesystem. CADDY_CA_IN_CONTAINER="/data/caddy/pki/authorities/local/root.crt" if $COMPOSE exec -T caddy sh -c "test -f $CADDY_CA_IN_CONTAINER" 2>/dev/null; then pass "Caddy internal CA root certificate found" CERT_EXPIRY=$($COMPOSE exec -T caddy sh -c \ "caddy version >/dev/null 2>&1; cat $CADDY_CA_IN_CONTAINER" 2>/dev/null \ | openssl x509 -enddate -noout 2>/dev/null | cut -d= -f2) if [ -n "$CERT_EXPIRY" ]; then EXPIRY_EPOCH=$(date -d "$CERT_EXPIRY" +%s 2>/dev/null || echo 0) NOW_EPOCH=$(date +%s) DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 )) info "Root CA expires: $CERT_EXPIRY" info "Days remaining: $DAYS_LEFT days" if [ "$DAYS_LEFT" -lt 0 ]; then fail "Caddy internal CA has expired!" elif [ "$DAYS_LEFT" -lt 30 ]; then warn "Caddy internal CA expires in less than 30 days" else pass "Caddy internal CA is valid" fi fi info "Install this CA on client devices to trust the internal certificate:" info " $COMPOSE cp caddy:$CADDY_CA_IN_CONTAINER ./caddy-root.crt" else info "No Caddy internal CA yet (created on first 'tls internal' issuance)" fi # ============================================================================ section "8. Configuration Files" # ============================================================================ if [ -f app/config.py ]; then pass "Flask config.py exists" if grep -q "class ProductionConfig" app/config.py; then pass "ProductionConfig class defined" else warn "ProductionConfig class missing" fi if grep -q "SESSION_COOKIE_SECURE" app/config.py; then pass "SESSION_COOKIE_SECURE configured" else warn "SESSION_COOKIE_SECURE not configured" fi else fail "app/config.py not found" fi # The reverse proxy is Caddy; data/Caddyfile is the live config. if [ -f data/Caddyfile ]; then pass "data/Caddyfile exists" if grep -q "reverse_proxy" data/Caddyfile; then pass "Caddy reverse_proxy configured" else warn "No reverse_proxy directive in Caddyfile" fi if grep -q "admin " data/Caddyfile; then pass "Caddy admin API configured (needed for live reloads)" else warn "Caddy admin API not configured — HTTPS changes cannot hot-reload" fi if grep -qE "tls internal" data/Caddyfile; then info "Using Caddy internal CA (intranet/non-public hostname)" elif grep -qE "^https://|^[a-zA-Z0-9.-]+ \{" data/Caddyfile; then info "TLS enabled (Let's Encrypt)" else info "HTTP-only configuration" fi elif [ -f Caddyfile.example ]; then info "data/Caddyfile not present yet; deploy.sh seeds it from Caddyfile.example" else fail "Neither data/Caddyfile nor Caddyfile.example found" fi # ============================================================================ section "9. Runtime Verification" # ============================================================================ if [ -n "$COMPOSE" ] && $COMPOSE ps 2>/dev/null | grep -q "Up"; then pass "Docker containers are running" if $COMPOSE ps 2>/dev/null | grep -q "digiserver-v2.*healthy"; then pass "DigiServer app container is healthy" else warn "DigiServer app container health status unknown" fi if $COMPOSE ps 2>/dev/null | grep -q "digiserver-caddy.*healthy"; then pass "Caddy container is healthy" else warn "Caddy container health status unknown" fi # Probe the actual endpoints rather than trusting status alone. if command -v curl >/dev/null 2>&1; then HTTP_PORT="${HTTP_PORT:-80}" HTTPS_PORT="${HTTPS_PORT:-443}" HTTP_URL="http://localhost" [ "$HTTP_PORT" != "80" ] && HTTP_URL="http://localhost:$HTTP_PORT" HTTPS_URL="https://localhost" [ "$HTTPS_PORT" != "443" ] && HTTPS_URL="https://localhost:$HTTPS_PORT" HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -m 5 "$HTTP_URL/" 2>/dev/null || echo 000) if [ "$HTTP_CODE" != "000" ]; then pass "HTTP endpoint responds ($HTTP_URL, status $HTTP_CODE)" else warn "HTTP endpoint not responding ($HTTP_URL)" fi # TLS site blocks match on the requested name (SNI) or on default_sni. # localhost matches neither when the server is configured for an IP / # intranet hostname, so probe the address the config actually serves. # NOTE: default_sni is indented inside the global block, so anchor on # optional leading whitespace, not `^`. PROBE_HOST=$(awk '/^[[:space:]]*default_sni/{print $2; exit}' data/Caddyfile 2>/dev/null || true) if [ -z "$PROBE_HOST" ]; then PROBE_HOST=$(awk '/^[[:space:]]*https:\/\//{sub(/^[[:space:]]*https:\/\//,""); sub(/[[:space:]{].*$/,""); print; exit}' data/Caddyfile 2>/dev/null || true) fi if [ -z "$PROBE_HOST" ]; then PROBE_HOST="localhost" fi if curl -sk -o /dev/null -m 5 --resolve "$PROBE_HOST:$HTTPS_PORT:127.0.0.1" \ "https://$PROBE_HOST:$HTTPS_PORT/" 2>/dev/null; then pass "HTTPS endpoint responds (https://$PROBE_HOST:$HTTPS_PORT)" elif grep -qE "tls internal|^[[:space:]]*https://" data/Caddyfile 2>/dev/null; then warn "HTTPS configured but not responding on https://$PROBE_HOST:$HTTPS_PORT" else info "HTTPS not configured (HTTP-only deployment)" fi fi else info "Docker containers not running (will start on deployment)" fi # ============================================================================ section "10. Security Best Practices" # ============================================================================ # Check for hardcoded secrets if grep -r "SECRET_KEY\|PASSWORD\|API_KEY" app/ 2>/dev/null | grep -v "os.getenv\|config.py\|#" | wc -l | grep -q "^0$"; then pass "No hardcoded secrets found" else warn "Possible hardcoded secrets detected (verify they use os.getenv)" fi # Check for debug mode. Only ProductionConfig matters — config.py intentionally # sets DEBUG=True for DevelopmentConfig and TestingConfig. # NOTE: grep exits 1 when it matches nothing; under `set -e` that would abort # the script, so the whole pipeline must end with a command that always succeeds. DEBUG_LINE=$(awk '/class ProductionConfig/,/^class |^# Configuration/' app/config.py 2>/dev/null \ | grep -E "^[[:space:]]*DEBUG[[:space:]]*=[[:space:]]*True" || true) if [ -n "$DEBUG_LINE" ]; then fail "DEBUG mode is enabled in ProductionConfig" elif grep -q "class ProductionConfig" app/config.py 2>/dev/null; then pass "Debug mode disabled in ProductionConfig" else warn "ProductionConfig class not found — cannot verify debug mode" fi # ============================================================================ section "Summary" # ============================================================================ echo "" echo -e "Test Results:" echo -e " ${GREEN}Passed: $PASSED${NC}" echo -e " ${YELLOW}Warnings: $WARNINGS${NC}" echo -e " ${RED}Failed: $FAILED${NC}" TOTAL=$((PASSED + FAILED + WARNINGS)) PERCENTAGE=$((PASSED * 100 / (PASSED + FAILED))) if [ "$FAILED" -eq 0 ]; then echo -e "\n${GREEN}✓ Production Deployment Ready!${NC}" echo "Recommendation: Safe to deploy to production" exit 0 elif [ "$FAILED" -le 2 ] && [ "$WARNINGS" -gt 0 ]; then echo -e "\n${YELLOW}⚠ Deployment Possible with Caution${NC}" echo "Recommendation: Address warnings before deployment" exit 0 else echo -e "\n${RED}✗ Deployment Not Recommended${NC}" echo "Recommendation: Fix critical failures before deployment" exit 1 fi