Fix deployment issues found during end-to-end testing

- install.sh: Use tar --strip-components=1 instead of mv for robust
  extraction when install dir partially exists (root-owned Docker
  artifacts)
- config.sh: Add --non-interactive mode (--domain, --admin-password,
  --enable-all flags) for CI/CD and automated deployments
- docker-entrypoint.sh: Validate critical env vars on startup, fail
  early with clear messages instead of silent failures
- docker-compose.yml: Change Redis eviction policy from allkeys-lru
  to noeviction (required by BullMQ job queues)
- Prisma: Add missing petitions.coverVideoId migration (schema had
  the column but migration omitted it, causing 500 on public endpoint)
- Add scripts/uninstall.sh for clean removal including root-owned files
- Add scripts/test-deployment.sh for automated post-install verification

Bunker Admin
This commit is contained in:
bunker-admin 2026-04-07 14:06:05 -06:00
parent 74e5fa6475
commit 530551f568
8 changed files with 929 additions and 130 deletions

View File

@ -7,6 +7,36 @@ if [ "$NODE_ENV" = "production" ] && [ "$NODE_TLS_REJECT_UNAUTHORIZED" = "0" ];
exit 1
fi
# Validate critical environment variables
ENV_ERRORS=0
check_env() {
if [ -z "$2" ] || echo "$2" | grep -q "REQUIRED_STRONG_PASSWORD\|GENERATE_WITH_openssl"; then
echo "FATAL: $1 is not set or contains a placeholder value"
ENV_ERRORS=$((ENV_ERRORS + 1))
fi
}
check_env "DATABASE_URL" "$DATABASE_URL"
check_env "REDIS_URL" "$REDIS_URL"
check_env "JWT_ACCESS_SECRET" "$JWT_ACCESS_SECRET"
check_env "JWT_REFRESH_SECRET" "$JWT_REFRESH_SECRET"
check_env "ENCRYPTION_KEY" "$ENCRYPTION_KEY"
check_env "INITIAL_ADMIN_PASSWORD" "$INITIAL_ADMIN_PASSWORD"
if [ "$ENV_ERRORS" -gt 0 ]; then
echo ""
echo "FATAL: $ENV_ERRORS required environment variable(s) missing or invalid."
echo "Run the configuration wizard: bash config.sh"
echo "Or set them manually in .env and restart."
exit 1
fi
# Fix permissions for mounted volumes (host dirs may be root-owned on first run)
if [ "$(id -u)" = "0" ]; then
mkdir -p /app/logs /data/geoip /app/uploads 2>/dev/null || true
chown -R node:node /app/logs /data/geoip /app/uploads 2>/dev/null || true
fi
# Wait for PostgreSQL to be ready before running migrations
echo "Waiting for database..."
MAX_WAIT=30
@ -43,6 +73,7 @@ if [ -n "$MAXMIND_ACCOUNT_ID" ] && [ -n "$MAXMIND_LICENSE_KEY" ]; then
if node -e "
const https = require('https');
const fs = require('fs');
setTimeout(() => { console.error('GeoIP download timed out after 60s'); process.exit(1); }, 60000).unref();
const auth = Buffer.from('$MAXMIND_ACCOUNT_ID:$MAXMIND_LICENSE_KEY').toString('base64');
const get = (url, cb) => https.get(url, { headers: url.includes('maxmind.com') ? { Authorization: 'Basic ' + auth } : {} }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) return get(res.headers.location, cb);
@ -51,7 +82,7 @@ if [ -n "$MAXMIND_ACCOUNT_ID" ] && [ -n "$MAXMIND_LICENSE_KEY" ]; then
res.pipe(out);
out.on('close', () => cb(null));
}).on('error', cb);
get('$DOWNLOAD_URL', (err) => { if (err) { console.error(err.message); process.exit(1); } });
get('$DOWNLOAD_URL', (err) => { if (err) { console.error(err.message); process.exit(1); } else { process.exit(0); } });
"; then
tar -xzf /tmp/geolite2.tar.gz -C /tmp/ 2>/dev/null
MMDB_FILE=$(find /tmp -name 'GeoLite2-City.mmdb' -type f 2>/dev/null | head -1)
@ -82,4 +113,9 @@ if [ -f "src/server.ts" ] && echo "$@" | grep -q "npm.*start\|node.*dist"; then
fi
echo "Starting server..."
exec "$@"
# Drop to node user if running as root (production image uses su-exec)
if [ "$(id -u)" = "0" ] && command -v su-exec >/dev/null 2>&1; then
exec su-exec node "$@"
else
exec "$@"
fi

View File

@ -0,0 +1,4 @@
-- AlterTable: Add missing coverVideoId column to petitions table
-- This column was present in the Prisma schema but omitted from the original
-- 20260402200000_add_petitions migration.
ALTER TABLE "petitions" ADD COLUMN "coverVideoId" INTEGER;

578
config.sh
View File

@ -12,6 +12,42 @@ ENV_EXAMPLE="$SCRIPT_DIR/.env.example"
MKDOCS_YML="$SCRIPT_DIR/mkdocs/mkdocs.yml"
SERVICES_YAML="$SCRIPT_DIR/configs/homepage/services.yaml"
# --- Non-interactive mode flags ---
NON_INTERACTIVE=false
NI_DOMAIN=""
NI_ADMIN_EMAIL=""
NI_ADMIN_PASSWORD=""
NI_PRODUCTION=true
NI_ENABLE_ALL=false
# --- Arg parser ---
while [[ $# -gt 0 ]]; do
case "$1" in
--non-interactive|-y) NON_INTERACTIVE=true; shift ;;
--domain) NI_DOMAIN="$2"; shift 2 ;;
--admin-email) NI_ADMIN_EMAIL="$2"; shift 2 ;;
--admin-password) NI_ADMIN_PASSWORD="$2"; shift 2 ;;
--development) NI_PRODUCTION=false; shift ;;
--enable-all) NI_ENABLE_ALL=true; shift ;;
--help|-h)
echo "Usage: bash config.sh [OPTIONS]"
echo ""
echo "Options:"
echo " --non-interactive, -y Skip all prompts (generate secrets, use defaults)"
echo " --domain DOMAIN Set domain (default: cmlite.org)"
echo " --admin-email EMAIL Set admin email (default: admin@DOMAIN)"
echo " --admin-password PASS Set admin password (must meet policy: 12+ chars, upper+lower+digit)"
echo " --development Set NODE_ENV=development (default: production)"
echo " --enable-all Enable all optional features"
echo " --help, -h Show this help"
echo ""
echo "Example:"
echo " bash config.sh --non-interactive --domain example.org --admin-password MyStr0ngPass123"
exit 0 ;;
*) shift ;;
esac
done
# --- Detect install mode ---
# Release mode: installed from tarball (has VERSION file, no .git directory)
# Source mode: cloned from git repository
@ -35,13 +71,15 @@ fi
# =============================================================================
# Ensure stdin is connected to the terminal (handles curl | bash case)
# =============================================================================
if [[ ! -t 0 ]]; then
if [[ -e /dev/tty ]]; then
exec 0</dev/tty
else
echo "[ERR] This script requires an interactive terminal." >&2
echo " Download and run manually: bash config.sh" >&2
exit 1
if [[ "$NON_INTERACTIVE" == "false" ]]; then
if [[ ! -t 0 ]]; then
if [[ -e /dev/tty ]]; then
exec 0</dev/tty
else
echo "[ERR] This script requires an interactive terminal." >&2
echo " Use --non-interactive for headless mode, or run manually: bash config.sh" >&2
exit 1
fi
fi
fi
@ -110,6 +148,13 @@ validate_password() {
prompt_yes_no() {
local prompt=$1 default=${2:-n}
# In non-interactive mode, use default (or yes-for-all if --enable-all)
if [[ "$NON_INTERACTIVE" == "true" ]]; then
if [[ "$NI_ENABLE_ALL" == "true" ]]; then
return 0 # yes to everything
fi
[[ "$default" == "y" ]] && return 0 || return 1
fi
local yn
if [[ "$default" == "y" ]]; then
read -rp "$prompt [Y/n]: " yn
@ -196,14 +241,21 @@ initialize_env() {
RECONFIGURE_MODE=false
if [[ -f "$ENV_FILE" ]]; then
warn "Existing .env file found at $ENV_FILE"
if prompt_yes_no "Back up existing .env and create a fresh one?"; then
if [[ "$NON_INTERACTIVE" == "true" ]]; then
# Non-interactive: back up and create fresh
backup_env_file
cp "$ENV_EXAMPLE" "$ENV_FILE"
success "Created fresh .env from .env.example"
success "Created fresh .env from .env.example (backed up existing)"
else
info "Keeping existing .env. Existing secrets will be preserved."
RECONFIGURE_MODE=true
warn "Existing .env file found at $ENV_FILE"
if prompt_yes_no "Back up existing .env and create a fresh one?"; then
backup_env_file
cp "$ENV_EXAMPLE" "$ENV_FILE"
success "Created fresh .env from .env.example"
else
info "Keeping existing .env. Existing secrets will be preserved."
RECONFIGURE_MODE=true
fi
fi
else
cp "$ENV_EXAMPLE" "$ENV_FILE"
@ -218,12 +270,15 @@ initialize_env() {
configure_domain() {
header "Domain Configuration"
info "Root domain serves MkDocs documentation."
info "All application routes are at app.<domain>."
echo ""
read -rp "Enter your domain (e.g., example.org) [default: cmlite.org]: " domain
domain=${domain:-cmlite.org}
if [[ "$NON_INTERACTIVE" == "true" ]]; then
domain="${NI_DOMAIN:-cmlite.org}"
else
info "Root domain serves MkDocs documentation."
info "All application routes are at app.<domain>."
echo ""
read -rp "Enter your domain (e.g., example.org) [default: cmlite.org]: " domain
domain=${domain:-cmlite.org}
fi
update_env_var "DOMAIN" "$domain"
update_env_var "BASE_DOMAIN" "https://$domain"
@ -252,14 +307,23 @@ configure_domain() {
success "Domain set to: $domain"
echo ""
if prompt_yes_no "Is this a production deployment?"; then
update_env_var "NODE_ENV" "production"
success "NODE_ENV set to production"
IS_PRODUCTION="yes"
if [[ "$NON_INTERACTIVE" == "true" ]]; then
if [[ "$NI_PRODUCTION" == "true" ]]; then
update_env_var "NODE_ENV" "production"
IS_PRODUCTION="yes"
else
IS_PRODUCTION="no"
fi
else
info "NODE_ENV stays as development"
IS_PRODUCTION="no"
echo ""
if prompt_yes_no "Is this a production deployment?"; then
update_env_var "NODE_ENV" "production"
success "NODE_ENV set to production"
IS_PRODUCTION="yes"
else
info "NODE_ENV stays as development"
IS_PRODUCTION="no"
fi
fi
# Store for later use
@ -269,34 +333,55 @@ configure_domain() {
configure_admin() {
header "Admin Credentials"
local default_email="admin@${CONFIGURED_DOMAIN:-cmlite.org}"
read -rp "Admin email [default: $default_email]: " admin_email
admin_email=${admin_email:-$default_email}
if [[ "$NON_INTERACTIVE" == "true" ]]; then
local admin_email="${NI_ADMIN_EMAIL:-admin@${CONFIGURED_DOMAIN:-cmlite.org}}"
local admin_password="${NI_ADMIN_PASSWORD:-}"
local admin_password=""
while true; do
echo ""
read -rsp "Admin password (min 12 chars, uppercase + lowercase + digit): " admin_password
echo ""
if validate_password "$admin_password"; then
read -rsp "Confirm password: " confirm_password
echo ""
if [[ "$admin_password" == "$confirm_password" ]]; then
break
else
warn "Passwords do not match. Try again."
fi
else
warn "Password must be 12+ characters with uppercase, lowercase, and a digit."
# Generate a password if not provided
if [[ -z "$admin_password" ]]; then
admin_password="CmLite$(openssl rand -base64 12 | tr -dc 'a-zA-Z0-9' | head -c 12)1"
info "Generated admin password: $admin_password"
info "SAVE THIS — it will not be shown again."
fi
done
update_env_var "INITIAL_ADMIN_EMAIL" "$admin_email"
update_env_var "INITIAL_ADMIN_PASSWORD" "$admin_password"
update_env_var "N8N_USER_EMAIL" "$admin_email"
if ! validate_password "$admin_password"; then
error "Admin password does not meet policy (12+ chars, uppercase + lowercase + digit)"
exit 1
fi
success "Admin credentials configured"
update_env_var "INITIAL_ADMIN_EMAIL" "$admin_email"
update_env_var "INITIAL_ADMIN_PASSWORD" "$admin_password"
update_env_var "N8N_USER_EMAIL" "$admin_email"
success "Admin credentials configured ($admin_email)"
else
local default_email="admin@${CONFIGURED_DOMAIN:-cmlite.org}"
read -rp "Admin email [default: $default_email]: " admin_email
admin_email=${admin_email:-$default_email}
local admin_password=""
while true; do
echo ""
read -rsp "Admin password (min 12 chars, uppercase + lowercase + digit): " admin_password
echo ""
if validate_password "$admin_password"; then
read -rsp "Confirm password: " confirm_password
echo ""
if [[ "$admin_password" == "$confirm_password" ]]; then
break
else
warn "Passwords do not match. Try again."
fi
else
warn "Password must be 12+ characters with uppercase, lowercase, and a digit."
fi
done
update_env_var "INITIAL_ADMIN_EMAIL" "$admin_email"
update_env_var "INITIAL_ADMIN_PASSWORD" "$admin_password"
update_env_var "N8N_USER_EMAIL" "$admin_email"
success "Admin credentials configured"
fi
}
# Check if a key already has a real value in .env (non-empty, not a placeholder)
@ -537,6 +622,14 @@ generate_all_secrets() {
configure_smtp() {
header "Email Configuration"
if [[ "$NON_INTERACTIVE" == "true" ]]; then
# Non-interactive: use MailHog defaults (production SMTP can be configured later)
update_env_var "VAULTWARDEN_SMTP_SECURITY" "off"
info "Using MailHog for email (configure SMTP later via .env)"
SMTP_MODE="mailhog"
return
fi
info "By default, emails are captured by MailHog (test mode)."
info "You can configure a production SMTP server now or later."
echo ""
@ -623,15 +716,17 @@ configure_features() {
success "Jitsi Meet enabled"
MEET_ENABLED="yes"
echo ""
info "Jitsi requires your server's public IP for media traffic (NAT traversal)."
info "Firewall must allow UDP port 10000 for video/audio."
read -rp " Server public IP [leave blank to set later]: " jvb_ip
if [[ -n "$jvb_ip" ]]; then
update_env_var "JVB_ADVERTISE_IP" "$jvb_ip"
success "JVB advertise IP set to $jvb_ip"
else
warn "Set JVB_ADVERTISE_IP in .env before starting Jitsi containers."
if [[ "$NON_INTERACTIVE" == "false" ]]; then
echo ""
info "Jitsi requires your server's public IP for media traffic (NAT traversal)."
info "Firewall must allow UDP port 10000 for video/audio."
read -rp " Server public IP [leave blank to set later]: " jvb_ip
if [[ -n "$jvb_ip" ]]; then
update_env_var "JVB_ADVERTISE_IP" "$jvb_ip"
success "JVB advertise IP set to $jvb_ip"
else
warn "Set JVB_ADVERTISE_IP in .env before starting Jitsi containers."
fi
fi
else
MEET_ENABLED="no"
@ -642,38 +737,63 @@ configure_features() {
success "SMS Campaigns enabled"
SMS_ENABLED="yes"
echo ""
info "SMS uses a Termux-based Android phone as the sending device."
read -rp " Termux API URL [default: http://10.0.0.193:5001]: " termux_url
termux_url=${termux_url:-http://10.0.0.193:5001}
update_env_var "TERMUX_API_URL" "$termux_url"
if [[ "$NON_INTERACTIVE" == "false" ]]; then
echo ""
info "SMS uses a Termux-based Android phone as the sending device."
read -rp " Termux API URL [default: http://10.0.0.193:5001]: " termux_url
termux_url=${termux_url:-http://10.0.0.193:5001}
update_env_var "TERMUX_API_URL" "$termux_url"
read -rp " Termux API Key [leave blank to set later]: " termux_key
if [[ -n "$termux_key" ]]; then
update_env_var "TERMUX_API_KEY" "$termux_key"
read -rp " Termux API Key [leave blank to set later]: " termux_key
if [[ -n "$termux_key" ]]; then
update_env_var "TERMUX_API_KEY" "$termux_key"
fi
fi
else
SMS_ENABLED="no"
fi
if prompt_yes_no "Enable Social Connections (friendships, challenges, spotlights)?"; then
update_env_var "ENABLE_SOCIAL" "true"
success "Social Connections enabled"
else
update_env_var "ENABLE_SOCIAL" "false"
fi
if prompt_yes_no "Enable People CRM (unified contact management)?"; then
update_env_var "ENABLE_PEOPLE" "true"
success "People CRM enabled"
else
update_env_var "ENABLE_PEOPLE" "false"
fi
if prompt_yes_no "Enable Analytics & GeoIP (visitor tracking, geo dashboard)?"; then
update_env_var "ENABLE_ANALYTICS" "true"
success "Analytics enabled"
else
update_env_var "ENABLE_ANALYTICS" "false"
fi
if prompt_yes_no "Enable Docs Comments & Version History (Gitea-backed)?"; then
update_env_var "GITEA_COMMENTS_ENABLED" "true"
success "Docs Comments & Version History enabled"
DOCS_COMMENTS_ENABLED="yes"
echo ""
info "Gitea auto-setup will create the API token, repos, and OAuth app automatically."
info "You need to provide the Gitea admin password (set during Gitea's first-run install)."
echo ""
if [[ "$NON_INTERACTIVE" == "false" ]]; then
echo ""
info "Gitea auto-setup will create the API token, repos, and OAuth app automatically."
info "You need to provide the Gitea admin password (set during Gitea's first-run install)."
echo ""
read -srp " Gitea admin password [leave blank to set up later via admin GUI]: " gitea_admin_pw
echo ""
if [[ -n "$gitea_admin_pw" ]]; then
update_env_var "GITEA_ADMIN_PASSWORD" "$gitea_admin_pw"
update_env_var "GITEA_COMMENTS_REPO_OWNER" "admin"
success "Gitea admin password saved — auto-setup will run on next start"
else
info "No password provided. Run Gitea Setup from the admin GUI after first start."
read -srp " Gitea admin password [leave blank to set up later via admin GUI]: " gitea_admin_pw
echo ""
if [[ -n "$gitea_admin_pw" ]]; then
update_env_var "GITEA_ADMIN_PASSWORD" "$gitea_admin_pw"
update_env_var "GITEA_COMMENTS_REPO_OWNER" "admin"
success "Gitea admin password saved — auto-setup will run on next start"
else
info "No password provided. Run Gitea Setup from the admin GUI after first start."
fi
fi
else
DOCS_COMMENTS_ENABLED="no"
@ -692,15 +812,17 @@ configure_features() {
success "Bunker Ops enabled"
BUNKER_OPS_ENABLED="yes"
echo ""
read -rp " Instance label [default: domain name]: " instance_label
if [[ -n "$instance_label" ]]; then
update_env_var "INSTANCE_LABEL" "$instance_label"
fi
if [[ "$NON_INTERACTIVE" == "false" ]]; then
echo ""
read -rp " Instance label [default: domain name]: " instance_label
if [[ -n "$instance_label" ]]; then
update_env_var "INSTANCE_LABEL" "$instance_label"
fi
read -rp " Remote write URL (VictoriaMetrics endpoint): " remote_write_url
if [[ -n "$remote_write_url" ]]; then
update_env_var "BUNKER_OPS_REMOTE_WRITE_URL" "$remote_write_url"
read -rp " Remote write URL (VictoriaMetrics endpoint): " remote_write_url
if [[ -n "$remote_write_url" ]]; then
update_env_var "BUNKER_OPS_REMOTE_WRITE_URL" "$remote_write_url"
fi
fi
else
BUNKER_OPS_ENABLED="no"
@ -711,19 +833,21 @@ configure_features() {
update_env_var "ENABLE_ANALYTICS" "true"
success "Analytics enabled"
echo ""
info "GeoIP tracking requires a free MaxMind account."
info "Sign up at: https://www.maxmind.com/en/geolite2/signup"
read -rp " MaxMind Account ID [leave blank to set later]: " maxmind_id
if [[ -n "$maxmind_id" ]]; then
update_env_var "MAXMIND_ACCOUNT_ID" "$maxmind_id"
read -rp " MaxMind License Key: " maxmind_key
if [[ -n "$maxmind_key" ]]; then
update_env_var "MAXMIND_LICENSE_KEY" "$maxmind_key"
success "MaxMind GeoIP credentials configured"
if [[ "$NON_INTERACTIVE" == "false" ]]; then
echo ""
info "GeoIP tracking requires a free MaxMind account."
info "Sign up at: https://www.maxmind.com/en/geolite2/signup"
read -rp " MaxMind Account ID [leave blank to set later]: " maxmind_id
if [[ -n "$maxmind_id" ]]; then
update_env_var "MAXMIND_ACCOUNT_ID" "$maxmind_id"
read -rp " MaxMind License Key: " maxmind_key
if [[ -n "$maxmind_key" ]]; then
update_env_var "MAXMIND_LICENSE_KEY" "$maxmind_key"
success "MaxMind GeoIP credentials configured"
fi
else
info "Set MAXMIND_ACCOUNT_ID and MAXMIND_LICENSE_KEY in .env to enable geo tracking."
fi
else
info "Set MAXMIND_ACCOUNT_ID and MAXMIND_LICENSE_KEY in .env to enable geo tracking."
fi
else
info "Analytics disabled (can enable later in admin Settings)"
@ -733,25 +857,245 @@ configure_features() {
configure_pangolin() {
header "Tunnel Configuration (Pangolin)"
if [[ "$NON_INTERACTIVE" == "true" ]]; then
info "Skipping Pangolin setup (configure later via admin GUI or .env)"
PANGOLIN_CONFIGURED="no"
return
fi
info "Pangolin provides secure public access to your services."
info "Skip this if you'll configure tunneling later."
echo ""
if prompt_yes_no "Configure Pangolin tunnel now?"; then
read -rp " Pangolin API URL [default: https://api.bnkserve.org/v1]: " pang_url
pang_url=${pang_url:-https://api.bnkserve.org/v1}
read -rp " Pangolin API key: " pang_key
read -rp " Pangolin Organization ID: " pang_org
if ! prompt_yes_no "Configure Pangolin tunnel now?"; then
PANGOLIN_CONFIGURED="no"
return
fi
update_env_var "PANGOLIN_API_URL" "$pang_url"
update_env_var "PANGOLIN_API_KEY" "$pang_key"
update_env_var "PANGOLIN_ORG_ID" "$pang_org"
read -rp " Pangolin API URL [default: https://api.bnkserve.org/v1]: " pang_url
pang_url=${pang_url:-https://api.bnkserve.org/v1}
read -rp " Pangolin API key: " pang_key
read -rp " Pangolin Organization ID: " pang_org
success "Pangolin configured"
update_env_var "PANGOLIN_API_URL" "$pang_url"
update_env_var "PANGOLIN_API_KEY" "$pang_key"
update_env_var "PANGOLIN_ORG_ID" "$pang_org"
success "Pangolin API credentials saved"
# Check if curl and jq are available for site setup
if ! command -v curl &>/dev/null || ! command -v jq &>/dev/null; then
warn "curl or jq not found — skipping site setup."
info "Complete tunnel setup in the admin GUI at /app/pangolin after starting services."
PANGOLIN_CONFIGURED="yes"
return
fi
# Verify API connectivity
info "Verifying Pangolin API connectivity..."
local health_status
health_status=$(curl -s -o /dev/null -w "%{http_code}" -m 10 \
-H "Authorization: Bearer $pang_key" \
"$pang_url/" 2>/dev/null) || true
if [[ "$health_status" != "200" ]]; then
warn "Could not reach Pangolin API (HTTP $health_status)."
info "Complete tunnel setup in the admin GUI at /app/pangolin after starting services."
PANGOLIN_CONFIGURED="yes"
return
fi
success "Pangolin API reachable"
echo ""
# Offer site setup options
info "Set up a tunnel site now?"
echo -e " ${BOLD}1)${NC} Create a new site"
echo -e " ${BOLD}2)${NC} Connect to an existing site"
echo -e " ${BOLD}3)${NC} Skip (configure later in admin GUI)"
echo ""
local site_choice
read -rp " Choose [1-3, default: 3]: " site_choice
site_choice=${site_choice:-3}
case "$site_choice" in
1) pangolin_create_site "$pang_url" "$pang_key" "$pang_org" ;;
2) pangolin_connect_site "$pang_url" "$pang_key" "$pang_org" ;;
*)
info "Complete tunnel setup in the admin GUI at /app/pangolin after starting services."
;;
esac
PANGOLIN_CONFIGURED="yes"
}
# Helper: Call Pangolin API and extract data from response envelope
pangolin_api() {
local method=$1 url=$2 api_key=$3
shift 3
local body_args=()
if [[ $# -gt 0 ]]; then
body_args=(-d "$1")
fi
curl -s -m 15 -X "$method" "$url" \
-H "Authorization: Bearer $api_key" \
-H "Content-Type: application/json" \
"${body_args[@]}" 2>/dev/null
}
pangolin_create_site() {
local api_url=$1 api_key=$2 org_id=$3
local domain="${CONFIGURED_DOMAIN:-cmlite.org}"
local site_name
read -rp " Site name [default: changemaker-$domain]: " site_name
site_name=${site_name:-changemaker-$domain}
info "Fetching Newt credentials..."
local defaults_resp
defaults_resp=$(pangolin_api GET "$api_url/org/$org_id/pick-site-defaults" "$api_key")
local newt_id newt_secret client_address
newt_id=$(echo "$defaults_resp" | jq -r '.data.newtId // .newtId // empty' 2>/dev/null)
newt_secret=$(echo "$defaults_resp" | jq -r '.data.newtSecret // .newtSecret // .data.secret // .secret // empty' 2>/dev/null)
client_address=$(echo "$defaults_resp" | jq -r '.data.clientAddress // .clientAddress // .data.address // .address // empty' 2>/dev/null)
if [[ -z "$newt_id" || -z "$newt_secret" ]]; then
warn "Could not fetch Newt credentials from pickSiteDefaults."
info "Complete tunnel setup in the admin GUI at /app/pangolin after starting services."
return
fi
success "Got Newt credentials (newtId: $newt_id)"
info "Creating site \"$site_name\"..."
local create_payload
create_payload=$(jq -n \
--arg name "$site_name" \
--arg newtId "$newt_id" \
--arg secret "$newt_secret" \
--arg address "$client_address" \
'{name: $name, type: "newt", newtId: $newtId, secret: $secret, address: $address}')
local site_resp
site_resp=$(pangolin_api PUT "$api_url/org/$org_id/site" "$api_key" "$create_payload")
local site_id
site_id=$(echo "$site_resp" | jq -r '.data.siteId // .siteId // empty' 2>/dev/null)
if [[ -z "$site_id" ]]; then
local err_msg
err_msg=$(echo "$site_resp" | jq -r '.message // .error // "Unknown error"' 2>/dev/null)
warn "Site creation failed: $err_msg"
info "Complete tunnel setup in the admin GUI at /app/pangolin after starting services."
return
fi
success "Site created: $site_id ($site_name)"
# Derive endpoint from API URL (strip /v1 path)
local endpoint
endpoint=$(echo "$api_url" | sed 's|/v1/*$||')
# Write credentials to .env
update_env_var "PANGOLIN_SITE_ID" "$site_id"
update_env_var "PANGOLIN_NEWT_ID" "$newt_id"
update_env_var "PANGOLIN_NEWT_SECRET" "$newt_secret"
update_env_var "PANGOLIN_ENDPOINT" "$endpoint"
success "Tunnel credentials written to .env"
info "Resources will be created automatically via the admin GUI or sync endpoint."
}
pangolin_connect_site() {
local api_url=$1 api_key=$2 org_id=$3
info "Fetching sites from organization..."
local sites_resp
sites_resp=$(pangolin_api GET "$api_url/org/$org_id/sites" "$api_key")
# Extract sites array (handle Pangolin response envelope)
local sites_json
sites_json=$(echo "$sites_resp" | jq '.data.sites // .data // .sites // []' 2>/dev/null)
local site_count
site_count=$(echo "$sites_json" | jq 'length' 2>/dev/null)
if [[ -z "$site_count" || "$site_count" -eq 0 ]]; then
warn "No sites found in this organization."
info "Use option 1 to create a new site, or set up via the admin GUI."
return
fi
echo ""
echo -e " ${BOLD}Available sites:${NC}"
echo ""
local i=1
while IFS= read -r line; do
local name site_id online last_seen
name=$(echo "$line" | jq -r '.name')
site_id=$(echo "$line" | jq -r '.siteId')
online=$(echo "$line" | jq -r '.online // false')
last_seen=$(echo "$line" | jq -r '.lastSeen // "never"')
local status_tag
if [[ "$online" == "true" ]]; then
status_tag="${GREEN}online${NC}"
else
status_tag="${DIM}offline${NC}"
fi
echo -e " ${BOLD}$i)${NC} $name ${DIM}(ID: $site_id)${NC}$status_tag"
i=$((i + 1))
done < <(echo "$sites_json" | jq -c '.[]')
echo ""
local choice
read -rp " Select site [1-$site_count]: " choice
if [[ -z "$choice" || "$choice" -lt 1 || "$choice" -gt "$site_count" ]] 2>/dev/null; then
warn "Invalid selection."
return
fi
local selected
selected=$(echo "$sites_json" | jq -c ".[$((choice - 1))]")
local sel_id sel_name
sel_id=$(echo "$selected" | jq -r '.siteId')
sel_name=$(echo "$selected" | jq -r '.name')
# Derive endpoint from API URL
local endpoint
endpoint=$(echo "$api_url" | sed 's|/v1/*$||')
# Write site ID to .env
update_env_var "PANGOLIN_SITE_ID" "$sel_id"
update_env_var "PANGOLIN_ENDPOINT" "$endpoint"
success "Connected to site: $sel_name (ID: $sel_id)"
# Check if we also need Newt credentials
local existing_newt_id
existing_newt_id=$(grep "^PANGOLIN_NEWT_ID=" "$ENV_FILE" 2>/dev/null | cut -d= -f2-)
if [[ -z "$existing_newt_id" ]]; then
info "Newt credentials not yet set."
info "If you have the Newt ID and Secret for this site, enter them now."
info "Otherwise, set them later via the admin GUI."
echo ""
read -rp " Newt ID (leave blank to skip): " newt_id_input
if [[ -n "$newt_id_input" ]]; then
read -rp " Newt Secret: " newt_secret_input
if [[ -n "$newt_secret_input" ]]; then
update_env_var "PANGOLIN_NEWT_ID" "$newt_id_input"
update_env_var "PANGOLIN_NEWT_SECRET" "$newt_secret_input"
success "Newt credentials written to .env"
fi
fi
else
PANGOLIN_CONFIGURED="no"
info "Existing Newt credentials found in .env (newtId: $existing_newt_id)"
fi
}
@ -1182,6 +1526,11 @@ YAML
configure_docs_reset() {
header "Documentation Site"
if [[ "$NON_INTERACTIVE" == "true" ]]; then
info "Keeping existing documentation content"
return
fi
if prompt_yes_no "Reset documentation site to baseline? (keeps header & tracking)"; then
touch "$SCRIPT_DIR/mkdocs/.reset-docs-on-startup"
success "Docs reset scheduled for first startup"
@ -1248,6 +1597,13 @@ fix_container_permissions() {
install_upgrade_watcher() {
header "System Upgrade Watcher"
if [[ "$NON_INTERACTIVE" == "true" ]]; then
mkdir -p "$SCRIPT_DIR/data/upgrade"
info "Skipping systemd watcher install (run manually later)"
UPGRADE_WATCHER="skipped"
return
fi
info "The upgrade watcher lets you trigger upgrades from the admin Settings page."
info "It installs a systemd path watcher that monitors for trigger files."
echo ""
@ -1304,6 +1660,12 @@ install_upgrade_watcher() {
install_backup_timer() {
header "Automated Backups"
if [[ "$NON_INTERACTIVE" == "true" ]]; then
info "Skipping backup timer install (run manually later)"
BACKUP_TIMER="skipped"
return
fi
info "Daily automated backups protect against data loss."
info "Backs up PostgreSQL databases + uploads to ./backups/ with 30-day retention."
echo ""

View File

@ -102,6 +102,10 @@ services:
- ALERTMANAGER_EMBED_PORT=${ALERTMANAGER_EMBED_PORT:-8895}
# SMS Campaigns (Termux Android Bridge)
- ENABLE_SMS=${ENABLE_SMS:-false}
# Social, People, Analytics (initial defaults; DB authoritative once admin saves)
- ENABLE_SOCIAL=${ENABLE_SOCIAL:-false}
- ENABLE_PEOPLE=${ENABLE_PEOPLE:-false}
- ENABLE_ANALYTICS=${ENABLE_ANALYTICS:-false}
- TERMUX_API_URL=${TERMUX_API_URL:-http://10.0.0.193:5001}
- TERMUX_API_KEY=${TERMUX_API_KEY:-}
- SMS_DELAY_BETWEEN_MS=${SMS_DELAY_BETWEEN_MS:-3000}
@ -128,6 +132,7 @@ services:
- ./data/upgrade:/app/upgrade:rw
- ./configs:/app/configs:ro
- ./logs/api:/app/logs
- ./.env:/.env:rw # Pangolin setup auto-writes credentials
deploy:
resources:
limits:
@ -167,6 +172,7 @@ services:
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
- JWT_INVITE_SECRET=${JWT_INVITE_SECRET}
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000,http://localhost:3100}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- ENABLE_MEDIA_FEATURES=${ENABLE_MEDIA_FEATURES:-true}
- MEDIA_ROOT=/media/local
- MEDIA_UPLOADS=/media/uploads
@ -362,7 +368,7 @@ services:
redis:
image: ${GITEA_REGISTRY:-gitea.bnkops.com/admin}/redis:7-alpine
container_name: redis-changemaker
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru --requirepass "${REDIS_PASSWORD}"
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy noeviction --requirepass "${REDIS_PASSWORD}"
ports:
- "127.0.0.1:6379:6379"
volumes:

View File

@ -103,6 +103,10 @@ services:
- ALERTMANAGER_EMBED_PORT=${ALERTMANAGER_EMBED_PORT:-8895}
# SMS Campaigns (Termux Android Bridge)
- ENABLE_SMS=${ENABLE_SMS:-false}
# Social, People, Analytics (initial defaults; DB authoritative once admin saves)
- ENABLE_SOCIAL=${ENABLE_SOCIAL:-false}
- ENABLE_PEOPLE=${ENABLE_PEOPLE:-false}
- ENABLE_ANALYTICS=${ENABLE_ANALYTICS:-false}
- TERMUX_API_URL=${TERMUX_API_URL:-http://10.0.0.193:5001}
- TERMUX_API_KEY=${TERMUX_API_KEY:-}
- SMS_DELAY_BETWEEN_MS=${SMS_DELAY_BETWEEN_MS:-3000}
@ -138,6 +142,7 @@ services:
- ./data/upgrade:/app/upgrade:rw
- ./configs:/app/configs:ro
- ./logs/api:/app/logs
- ./.env:/.env:rw # Pangolin setup auto-writes credentials
deploy:
resources:
limits:
@ -181,6 +186,7 @@ services:
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
- JWT_INVITE_SECRET=${JWT_INVITE_SECRET}
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000,http://localhost:3100}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- ENABLE_MEDIA_FEATURES=${ENABLE_MEDIA_FEATURES:-true}
- MEDIA_ROOT=/media/local
- MEDIA_UPLOADS=/media/uploads
@ -386,7 +392,7 @@ services:
redis:
image: redis:7-alpine
container_name: redis-changemaker
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru --requirepass "${REDIS_PASSWORD}"
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy noeviction --requirepass "${REDIS_PASSWORD}"
ports:
- "127.0.0.1:6379:6379"
volumes:

View File

@ -26,7 +26,6 @@ HEALTH_TIMEOUT=180
HEALTH_INTERVAL=5
# --- State flags for cleanup ---
EXTRACT_DIR=""
TARBALL_PATH=""
CONFIG_COMPLETE=false
@ -47,10 +46,6 @@ error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
cleanup() {
local exit_code=$?
if [[ $exit_code -ne 0 ]]; then
# Clean up temp extraction directory
if [[ -n "$EXTRACT_DIR" ]] && [[ -d "$EXTRACT_DIR" ]]; then
rm -rf "$EXTRACT_DIR"
fi
# Clean up downloaded tarball (but not user-provided ones)
if [[ -z "$LOCAL_TARBALL" ]] && [[ -n "$TARBALL_PATH" ]] && [[ -f "$TARBALL_PATH" ]]; then
rm -f "$TARBALL_PATH"
@ -192,22 +187,32 @@ fi
# =============================================================================
info "Extracting to ${INSTALL_DIR}..."
mkdir -p "$(dirname "$INSTALL_DIR")"
# Extract to temp, then move (handles tarball root directory naming)
EXTRACT_DIR=$(mktemp -d)
tar xzf "$TARBALL_PATH" -C "$EXTRACT_DIR"
# Find the extracted directory (tarball might have any root name)
EXTRACTED=$(find "$EXTRACT_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)
if [[ -z "$EXTRACTED" ]]; then
error "Tarball extraction failed — no directory found"
exit 1
# Ensure the install directory exists and is empty
if [[ -d "$INSTALL_DIR" ]]; then
# Remove any leftover files (may need sudo for root-owned Docker artifacts)
rm -rf "${INSTALL_DIR:?}"/* "${INSTALL_DIR}"/.[!.]* 2>/dev/null || true
if [[ -n "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]]; then
if command -v sudo &>/dev/null; then
warn "Removing root-owned leftovers from previous install..."
sudo rm -rf "${INSTALL_DIR:?}"/* "${INSTALL_DIR}"/.[!.]* 2>/dev/null || true
fi
if [[ -n "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]]; then
error "Cannot clean install directory ${INSTALL_DIR} — remove manually (may need sudo)"
exit 1
fi
fi
else
mkdir -p "$INSTALL_DIR"
fi
mv "$EXTRACTED" "$INSTALL_DIR"
rm -rf "$EXTRACT_DIR"
EXTRACT_DIR="" # Clear so cleanup doesn't try to remove it
# Extract directly into INSTALL_DIR, stripping the tarball's root directory
tar xzf "$TARBALL_PATH" --strip-components=1 -C "$INSTALL_DIR"
if [[ ! -f "$INSTALL_DIR/docker-compose.yml" ]]; then
error "Tarball extraction failed — docker-compose.yml not found"
exit 1
fi
# Clean up downloaded tarball
if [[ -z "$LOCAL_TARBALL" ]] && [[ -f "$TARBALL_PATH" ]]; then

253
scripts/test-deployment.sh Executable file
View File

@ -0,0 +1,253 @@
#!/usr/bin/env bash
# =============================================================================
# Changemaker Lite — Deployment Test Suite
#
# Verifies that all core services are running and responding correctly.
# Run after install or upgrade to validate the deployment.
#
# Usage:
# bash scripts/test-deployment.sh [OPTIONS]
#
# Options:
# --wait SECS Wait for services before testing (default: 0)
# --domain DOM Test tunnel connectivity for this domain
# --quiet Only show failures and summary
# --help Show this help
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WAIT_SECS=0
TEST_DOMAIN=""
QUIET=false
# Colors
if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then
RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m'
BOLD='\033[1m' NC='\033[0m'
else
RED='' GREEN='' YELLOW='' BOLD='' NC=''
fi
while [[ $# -gt 0 ]]; do
case "$1" in
--wait) WAIT_SECS="$2"; shift 2 ;;
--domain) TEST_DOMAIN="$2"; shift 2 ;;
--quiet) QUIET=true; shift ;;
--help|-h)
sed -n '2,16p' "$0" | grep '^#' | sed 's/^# \?//'
exit 0 ;;
*) shift ;;
esac
done
PASS=0 FAIL=0 WARN=0 SKIP=0
pass() { PASS=$((PASS+1)); [[ "$QUIET" == "false" ]] && echo -e " ${GREEN}PASS${NC} $1"; }
fail() { FAIL=$((FAIL+1)); echo -e " ${RED}FAIL${NC} $1"; }
warn() { WARN=$((WARN+1)); [[ "$QUIET" == "false" ]] && echo -e " ${YELLOW}WARN${NC} $1"; }
skip() { SKIP=$((SKIP+1)); [[ "$QUIET" == "false" ]] && echo -e " ${YELLOW}SKIP${NC} $1"; }
# Load .env
if [[ -f "$SCRIPT_DIR/.env" ]]; then
source <(grep -E '^(API_PORT|ADMIN_PORT|MEDIA_API_PORT|DOMAIN|ENABLE_|INITIAL_ADMIN_EMAIL)=' "$SCRIPT_DIR/.env" 2>/dev/null || true)
fi
API_PORT="${API_PORT:-4000}"
ADMIN_PORT="${ADMIN_PORT:-3000}"
MEDIA_API_PORT="${MEDIA_API_PORT:-4100}"
DOMAIN="${DOMAIN:-cmlite.org}"
[[ -n "$TEST_DOMAIN" ]] && DOMAIN="$TEST_DOMAIN"
cd "$SCRIPT_DIR"
echo -e "${BOLD}Changemaker Lite — Deployment Test${NC}"
echo ""
# Wait if requested
if [[ "$WAIT_SECS" -gt 0 ]]; then
echo "Waiting ${WAIT_SECS}s for services to start..."
sleep "$WAIT_SECS"
fi
# ─── Core Health ─────────────────────────────────────────────────────────
echo -e "${BOLD}Core Health${NC}"
HEALTH=$(curl -sf "http://localhost:${API_PORT}/api/health" 2>/dev/null || echo "")
if echo "$HEALTH" | grep -q '"healthy"'; then
pass "API health"
else
fail "API health (${HEALTH:-no response})"
fi
MEDIA=$(curl -sf "http://localhost:${MEDIA_API_PORT}/health" 2>/dev/null || echo "")
if echo "$MEDIA" | grep -q '"ok"'; then
pass "Media API health"
else
fail "Media API health"
fi
ADMIN_CODE=$(curl -sf -o /dev/null -w "%{http_code}" "http://localhost:${ADMIN_PORT}/" 2>/dev/null || echo "0")
if [[ "$ADMIN_CODE" == "200" ]]; then
pass "Admin GUI (HTTP $ADMIN_CODE)"
else
fail "Admin GUI (HTTP $ADMIN_CODE)"
fi
# ─── Authentication ──────────────────────────────────────────────────────
echo ""
echo -e "${BOLD}Authentication${NC}"
# Get admin email from .env
ADMIN_EMAIL="${INITIAL_ADMIN_EMAIL:-admin@${DOMAIN}}"
# Try to login — write payload to file to avoid ! escaping
LOGIN_FILE=$(mktemp)
# Read password from .env directly (avoid bash expansion issues with !)
ADMIN_PW=$(grep "^INITIAL_ADMIN_PASSWORD=" "$SCRIPT_DIR/.env" 2>/dev/null | head -1 | cut -d= -f2-)
printf '{"email":"%s","password":"%s"}' "$ADMIN_EMAIL" "$ADMIN_PW" > "$LOGIN_FILE"
TOKEN=$(curl -sf -X POST "http://localhost:${API_PORT}/api/auth/login" \
-H "Content-Type: application/json" -d "@$LOGIN_FILE" 2>/dev/null \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('accessToken',''))" 2>/dev/null || echo "")
rm -f "$LOGIN_FILE"
if [[ -n "$TOKEN" ]]; then
pass "Admin login"
else
fail "Admin login ($ADMIN_EMAIL)"
fi
# ─── Authenticated Endpoints ─────────────────────────────────────────────
echo ""
echo -e "${BOLD}Authenticated API${NC}"
if [[ -n "$TOKEN" ]]; then
AUTH="Authorization: Bearer $TOKEN"
for ep in "/api/settings" "/api/users" "/api/dashboard/summary" "/api/services/status"; do
CODE=$(curl -sf -o /dev/null -w "%{http_code}" "http://localhost:${API_PORT}${ep}" -H "$AUTH" 2>/dev/null || echo "0")
if [[ "$CODE" == "200" ]]; then
pass "GET $ep"
else
fail "GET $ep (HTTP $CODE)"
fi
done
else
skip "Authenticated endpoints (no token)"
fi
# ─── Public Endpoints ────────────────────────────────────────────────────
echo ""
echo -e "${BOLD}Public API${NC}"
for ep in "/api/campaigns/public" "/api/map/shifts/public" "/api/pages/listed" \
"/api/donation-pages" "/api/homepage" "/api/qr?text=test" \
"/api/petitions/public" "/api/payments/plans" "/api/payments/products" \
"/api/map/cuts/public"; do
CODE=$(curl -sf -o /dev/null -w "%{http_code}" "http://localhost:${API_PORT}${ep}" 2>/dev/null || echo "0")
if [[ "$CODE" == "200" ]]; then
pass "GET $ep"
else
fail "GET $ep (HTTP $CODE)"
fi
done
# ─── Supporting Services ─────────────────────────────────────────────────
echo ""
echo -e "${BOLD}Supporting Services${NC}"
declare -A SERVICES=(
[gitea]=3030 [nocodb]=8091 [listmonk]=9001 [n8n]=5678
[homepage]=3010 [excalidraw]=8090 [vaultwarden]=8445
[mailhog]=8025 [mkdocs-dev]=4003 [mkdocs-static]=4004 [mini-qr]=8089
)
for svc in "${!SERVICES[@]}"; do
port=${SERVICES[$svc]}
CODE=$(curl -sf -o /dev/null -w "%{http_code}" --max-time 5 "http://localhost:${port}/" 2>/dev/null || echo "0")
if [[ "$CODE" == "200" || "$CODE" == "302" ]]; then
pass "$svc (:$port)"
else
fail "$svc (:$port) (HTTP $CODE)"
fi
done
# ─── Nginx Proxy ─────────────────────────────────────────────────────────
echo ""
echo -e "${BOLD}Nginx Proxy${NC}"
NGINX_HEALTH=$(docker compose ps nginx --format '{{.Health}}' 2>/dev/null || echo "unknown")
if [[ "$NGINX_HEALTH" == "healthy" ]]; then
pass "Nginx container healthy"
API_PROXY=$(curl -sf -H "Host: api.${DOMAIN}" "http://localhost:80/api/health" 2>/dev/null || echo "")
if echo "$API_PROXY" | grep -q '"healthy"'; then
pass "Nginx -> API proxy"
else
fail "Nginx -> API proxy"
fi
ADMIN_PROXY=$(curl -sf -o /dev/null -w "%{http_code}" -H "Host: app.${DOMAIN}" "http://localhost:80/" 2>/dev/null || echo "0")
if [[ "$ADMIN_PROXY" == "200" ]]; then
pass "Nginx -> Admin proxy"
else
fail "Nginx -> Admin proxy (HTTP $ADMIN_PROXY)"
fi
else
fail "Nginx container ($NGINX_HEALTH)"
fi
# ─── Tunnel (optional) ──────────────────────────────────────────────────
echo ""
echo -e "${BOLD}Tunnel${NC}"
NEWT_STATUS=$(docker compose ps newt --format '{{.Status}}' 2>/dev/null || echo "")
if echo "$NEWT_STATUS" | grep -q "Up"; then
pass "Newt container running"
TUNNEL_API=$(curl -sf -o /dev/null -w "%{http_code}" --max-time 10 "https://api.${DOMAIN}/api/health" 2>/dev/null || echo "0")
if [[ "$TUNNEL_API" == "200" ]]; then
pass "Tunnel -> api.${DOMAIN}"
else
warn "Tunnel -> api.${DOMAIN} (HTTP $TUNNEL_API)"
fi
TUNNEL_APP=$(curl -sf -o /dev/null -w "%{http_code}" --max-time 10 "https://app.${DOMAIN}/" 2>/dev/null || echo "0")
if [[ "$TUNNEL_APP" == "200" ]]; then
pass "Tunnel -> app.${DOMAIN}"
else
warn "Tunnel -> app.${DOMAIN} (HTTP $TUNNEL_APP — check Pangolin resource auth)"
fi
else
skip "Tunnel (Newt not running)"
fi
# ─── Database ────────────────────────────────────────────────────────────
echo ""
echo -e "${BOLD}Database${NC}"
USER_COUNT=$(docker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 -t -c "SELECT COUNT(*) FROM users" 2>/dev/null | tr -d ' ' || echo "0")
if [[ "$USER_COUNT" -gt "0" ]] 2>/dev/null; then
pass "Users in DB: $USER_COUNT"
else
fail "No users found in database"
fi
TABLE_COUNT=$(docker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 -t -c "SELECT COUNT(*) FROM pg_tables WHERE schemaname='public'" 2>/dev/null | tr -d ' ' || echo "0")
if [[ "$TABLE_COUNT" -gt "100" ]] 2>/dev/null; then
pass "Tables in DB: $TABLE_COUNT"
else
warn "Table count: $TABLE_COUNT (expected 180+)"
fi
# ─── Summary ─────────────────────────────────────────────────────────────
TOTAL=$((PASS + FAIL + WARN + SKIP))
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if [[ "$FAIL" -eq 0 ]]; then
echo -e " ${GREEN}${BOLD}ALL TESTS PASSED${NC} ($PASS passed, $WARN warnings, $SKIP skipped)"
else
echo -e " ${RED}${BOLD}$FAIL FAILURES${NC} ($PASS passed, $FAIL failed, $WARN warnings, $SKIP skipped)"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
exit "$FAIL"

127
scripts/uninstall.sh Executable file
View File

@ -0,0 +1,127 @@
#!/usr/bin/env bash
# =============================================================================
# Changemaker Lite — Uninstall Script
#
# Safely stops and removes all Changemaker containers, volumes, and files.
# Handles root-owned files created by Docker containers.
#
# Usage:
# bash scripts/uninstall.sh [OPTIONS]
#
# Options:
# --keep-data Keep database volumes (PostgreSQL, Redis, etc.)
# --keep-media Keep the media/ directory (uploaded files)
# --yes, -y Skip confirmation prompt
# --help Show this help
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
KEEP_DATA=false
KEEP_MEDIA=false
SKIP_CONFIRM=false
# Colors
if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then
RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m'
BLUE='\033[0;34m' BOLD='\033[1m' NC='\033[0m'
else
RED='' GREEN='' YELLOW='' BLUE='' BOLD='' NC=''
fi
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
success() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
while [[ $# -gt 0 ]]; do
case "$1" in
--keep-data) KEEP_DATA=true; shift ;;
--keep-media) KEEP_MEDIA=true; shift ;;
--yes|-y) SKIP_CONFIRM=true; shift ;;
--help|-h)
sed -n '2,16p' "$0" | grep '^#' | sed 's/^# \?//'
exit 0 ;;
*) shift ;;
esac
done
echo -e "${BOLD}Changemaker Lite — Uninstall${NC}"
echo ""
if [[ ! -f "$SCRIPT_DIR/docker-compose.yml" ]]; then
error "Not a Changemaker Lite installation: $SCRIPT_DIR"
exit 1
fi
info "Install directory: $SCRIPT_DIR"
[[ "$KEEP_DATA" == "true" ]] && info "Database volumes will be preserved"
[[ "$KEEP_MEDIA" == "true" ]] && info "Media files will be preserved"
echo ""
if [[ "$SKIP_CONFIRM" == "false" ]]; then
echo -e "${RED}${BOLD}WARNING: This will permanently remove Changemaker Lite.${NC}"
read -rp "Type 'uninstall' to confirm: " confirm
if [[ "$confirm" != "uninstall" ]]; then
echo "Cancelled."
exit 0
fi
fi
# Step 1: Stop and remove containers
echo ""
info "Stopping containers..."
cd "$SCRIPT_DIR"
if [[ "$KEEP_DATA" == "true" ]]; then
docker compose down --remove-orphans 2>/dev/null || true
else
docker compose down -v --remove-orphans 2>/dev/null || true
fi
success "Containers stopped"
# Step 2: Remove systemd units (if installed)
if command -v systemctl &>/dev/null; then
for unit in changemaker-upgrade.path changemaker-upgrade.service changemaker-backup.timer changemaker-backup.service; do
if systemctl is-enabled "$unit" &>/dev/null 2>&1; then
info "Removing systemd unit: $unit"
sudo systemctl disable --now "$unit" 2>/dev/null || true
sudo rm -f "/etc/systemd/system/$unit" 2>/dev/null || true
fi
done
sudo systemctl daemon-reload 2>/dev/null || true
fi
# Step 3: Remove files
info "Removing installation files..."
# Try normal rm first
cd "$HOME"
if [[ "$KEEP_MEDIA" == "true" ]]; then
# Move media out, remove everything, move media back
MEDIA_TMP=$(mktemp -d)
mv "$SCRIPT_DIR/media" "$MEDIA_TMP/" 2>/dev/null || true
fi
rm -rf "$SCRIPT_DIR" 2>/dev/null || true
# Handle root-owned files from Docker
if [[ -d "$SCRIPT_DIR" ]]; then
warn "Some files are root-owned (from Docker). Using sudo to clean up..."
sudo rm -rf "$SCRIPT_DIR" 2>/dev/null || {
error "Could not remove $SCRIPT_DIR — manual cleanup needed:"
echo " sudo rm -rf $SCRIPT_DIR"
}
fi
# Restore media if requested
if [[ "$KEEP_MEDIA" == "true" ]] && [[ -d "${MEDIA_TMP:-}/media" ]]; then
mkdir -p "$SCRIPT_DIR"
mv "$MEDIA_TMP/media" "$SCRIPT_DIR/"
rm -rf "$MEDIA_TMP"
success "Media files preserved at $SCRIPT_DIR/media/"
fi
echo ""
success "Changemaker Lite has been uninstalled."
[[ "$KEEP_DATA" == "true" ]] && info "Database volumes were preserved (use 'docker volume ls' to manage)"
echo ""