46602f1933
Remove dead code identified in docs/SANITIZATION-REVIEW.md:
- app/blueprints/content_old.py and app/blueprints/playlist.py
- app/models/group.py, app/utils/nginx_config_reader.py
- orphaned templates (content_list, edit_content, upload_content,
player_page) and the related group/Template references
Result: 6 blueprints, 82 routes, no dead modules or orphan templates.
Add files that deploy.sh and docker-entrypoint.sh already require but
which were never tracked:
- https_manager.py (referenced by deploy.sh, migrate_network.sh,
docker-entrypoint.sh)
- Caddyfile.example (seeded by deploy.sh; its absence aborts deploy)
Relocate generated Graphify artifacts from graphify-out/ to
docs/graphify-out/ (110 files, no content change) and archive the
superseded docs under docs/.
Ignore hygiene:
- ignore ad-hoc .env backups (.env.bak*) — they contain live secrets
- keep the pre-sanitization snapshots (docs/legacy code/,
docs/old_code_documentation/) on disk but out of the repo
Fix .env.example: drop a duplicated config block, genericize the
hardcoded host IP, and document HOSTNAME_INTERNAL.
429 lines
15 KiB
Bash
Executable File
429 lines
15 KiB
Bash
Executable File
#!/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
|