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 exit 1
fi 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 # Wait for PostgreSQL to be ready before running migrations
echo "Waiting for database..." echo "Waiting for database..."
MAX_WAIT=30 MAX_WAIT=30
@ -43,6 +73,7 @@ if [ -n "$MAXMIND_ACCOUNT_ID" ] && [ -n "$MAXMIND_LICENSE_KEY" ]; then
if node -e " if node -e "
const https = require('https'); const https = require('https');
const fs = require('fs'); 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 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) => { 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); 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); res.pipe(out);
out.on('close', () => cb(null)); out.on('close', () => cb(null));
}).on('error', cb); }).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 "; then
tar -xzf /tmp/geolite2.tar.gz -C /tmp/ 2>/dev/null 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) 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 fi
echo "Starting server..." echo "Starting server..."
# 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 "$@" 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;

374
config.sh
View File

@ -12,6 +12,42 @@ ENV_EXAMPLE="$SCRIPT_DIR/.env.example"
MKDOCS_YML="$SCRIPT_DIR/mkdocs/mkdocs.yml" MKDOCS_YML="$SCRIPT_DIR/mkdocs/mkdocs.yml"
SERVICES_YAML="$SCRIPT_DIR/configs/homepage/services.yaml" 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 --- # --- Detect install mode ---
# Release mode: installed from tarball (has VERSION file, no .git directory) # Release mode: installed from tarball (has VERSION file, no .git directory)
# Source mode: cloned from git repository # Source mode: cloned from git repository
@ -35,15 +71,17 @@ fi
# ============================================================================= # =============================================================================
# Ensure stdin is connected to the terminal (handles curl | bash case) # Ensure stdin is connected to the terminal (handles curl | bash case)
# ============================================================================= # =============================================================================
if [[ "$NON_INTERACTIVE" == "false" ]]; then
if [[ ! -t 0 ]]; then if [[ ! -t 0 ]]; then
if [[ -e /dev/tty ]]; then if [[ -e /dev/tty ]]; then
exec 0</dev/tty exec 0</dev/tty
else else
echo "[ERR] This script requires an interactive terminal." >&2 echo "[ERR] This script requires an interactive terminal." >&2
echo " Download and run manually: bash config.sh" >&2 echo " Use --non-interactive for headless mode, or run manually: bash config.sh" >&2
exit 1 exit 1
fi fi
fi fi
fi
# ============================================================================= # =============================================================================
# Utility Functions # Utility Functions
@ -110,6 +148,13 @@ validate_password() {
prompt_yes_no() { prompt_yes_no() {
local prompt=$1 default=${2:-n} 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 local yn
if [[ "$default" == "y" ]]; then if [[ "$default" == "y" ]]; then
read -rp "$prompt [Y/n]: " yn read -rp "$prompt [Y/n]: " yn
@ -196,6 +241,12 @@ initialize_env() {
RECONFIGURE_MODE=false RECONFIGURE_MODE=false
if [[ -f "$ENV_FILE" ]]; then if [[ -f "$ENV_FILE" ]]; 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 (backed up existing)"
else
warn "Existing .env file found at $ENV_FILE" warn "Existing .env file found at $ENV_FILE"
if prompt_yes_no "Back up existing .env and create a fresh one?"; then if prompt_yes_no "Back up existing .env and create a fresh one?"; then
backup_env_file backup_env_file
@ -205,6 +256,7 @@ initialize_env() {
info "Keeping existing .env. Existing secrets will be preserved." info "Keeping existing .env. Existing secrets will be preserved."
RECONFIGURE_MODE=true RECONFIGURE_MODE=true
fi fi
fi
else else
cp "$ENV_EXAMPLE" "$ENV_FILE" cp "$ENV_EXAMPLE" "$ENV_FILE"
success "Created .env from .env.example" success "Created .env from .env.example"
@ -218,12 +270,15 @@ initialize_env() {
configure_domain() { configure_domain() {
header "Domain Configuration" header "Domain Configuration"
if [[ "$NON_INTERACTIVE" == "true" ]]; then
domain="${NI_DOMAIN:-cmlite.org}"
else
info "Root domain serves MkDocs documentation." info "Root domain serves MkDocs documentation."
info "All application routes are at app.<domain>." info "All application routes are at app.<domain>."
echo "" echo ""
read -rp "Enter your domain (e.g., example.org) [default: cmlite.org]: " domain read -rp "Enter your domain (e.g., example.org) [default: cmlite.org]: " domain
domain=${domain:-cmlite.org} domain=${domain:-cmlite.org}
fi
update_env_var "DOMAIN" "$domain" update_env_var "DOMAIN" "$domain"
update_env_var "BASE_DOMAIN" "https://$domain" update_env_var "BASE_DOMAIN" "https://$domain"
@ -252,6 +307,14 @@ configure_domain() {
success "Domain set to: $domain" success "Domain set to: $domain"
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
echo "" echo ""
if prompt_yes_no "Is this a production deployment?"; then if prompt_yes_no "Is this a production deployment?"; then
update_env_var "NODE_ENV" "production" update_env_var "NODE_ENV" "production"
@ -261,6 +324,7 @@ configure_domain() {
info "NODE_ENV stays as development" info "NODE_ENV stays as development"
IS_PRODUCTION="no" IS_PRODUCTION="no"
fi fi
fi
# Store for later use # Store for later use
CONFIGURED_DOMAIN="$domain" CONFIGURED_DOMAIN="$domain"
@ -269,6 +333,27 @@ configure_domain() {
configure_admin() { configure_admin() {
header "Admin Credentials" header "Admin Credentials"
if [[ "$NON_INTERACTIVE" == "true" ]]; then
local admin_email="${NI_ADMIN_EMAIL:-admin@${CONFIGURED_DOMAIN:-cmlite.org}}"
local admin_password="${NI_ADMIN_PASSWORD:-}"
# 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
if ! validate_password "$admin_password"; then
error "Admin password does not meet policy (12+ chars, uppercase + lowercase + digit)"
exit 1
fi
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}" local default_email="admin@${CONFIGURED_DOMAIN:-cmlite.org}"
read -rp "Admin email [default: $default_email]: " admin_email read -rp "Admin email [default: $default_email]: " admin_email
admin_email=${admin_email:-$default_email} admin_email=${admin_email:-$default_email}
@ -295,8 +380,8 @@ configure_admin() {
update_env_var "INITIAL_ADMIN_EMAIL" "$admin_email" update_env_var "INITIAL_ADMIN_EMAIL" "$admin_email"
update_env_var "INITIAL_ADMIN_PASSWORD" "$admin_password" update_env_var "INITIAL_ADMIN_PASSWORD" "$admin_password"
update_env_var "N8N_USER_EMAIL" "$admin_email" update_env_var "N8N_USER_EMAIL" "$admin_email"
success "Admin credentials configured" success "Admin credentials configured"
fi
} }
# Check if a key already has a real value in .env (non-empty, not a placeholder) # 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() { configure_smtp() {
header "Email Configuration" 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 "By default, emails are captured by MailHog (test mode)."
info "You can configure a production SMTP server now or later." info "You can configure a production SMTP server now or later."
echo "" echo ""
@ -623,6 +716,7 @@ configure_features() {
success "Jitsi Meet enabled" success "Jitsi Meet enabled"
MEET_ENABLED="yes" MEET_ENABLED="yes"
if [[ "$NON_INTERACTIVE" == "false" ]]; then
echo "" echo ""
info "Jitsi requires your server's public IP for media traffic (NAT traversal)." info "Jitsi requires your server's public IP for media traffic (NAT traversal)."
info "Firewall must allow UDP port 10000 for video/audio." info "Firewall must allow UDP port 10000 for video/audio."
@ -633,6 +727,7 @@ configure_features() {
else else
warn "Set JVB_ADVERTISE_IP in .env before starting Jitsi containers." warn "Set JVB_ADVERTISE_IP in .env before starting Jitsi containers."
fi fi
fi
else else
MEET_ENABLED="no" MEET_ENABLED="no"
fi fi
@ -642,6 +737,7 @@ configure_features() {
success "SMS Campaigns enabled" success "SMS Campaigns enabled"
SMS_ENABLED="yes" SMS_ENABLED="yes"
if [[ "$NON_INTERACTIVE" == "false" ]]; then
echo "" echo ""
info "SMS uses a Termux-based Android phone as the sending device." 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 read -rp " Termux API URL [default: http://10.0.0.193:5001]: " termux_url
@ -652,15 +748,38 @@ configure_features() {
if [[ -n "$termux_key" ]]; then if [[ -n "$termux_key" ]]; then
update_env_var "TERMUX_API_KEY" "$termux_key" update_env_var "TERMUX_API_KEY" "$termux_key"
fi fi
fi
else else
SMS_ENABLED="no" SMS_ENABLED="no"
fi 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 if prompt_yes_no "Enable Docs Comments & Version History (Gitea-backed)?"; then
update_env_var "GITEA_COMMENTS_ENABLED" "true" update_env_var "GITEA_COMMENTS_ENABLED" "true"
success "Docs Comments & Version History enabled" success "Docs Comments & Version History enabled"
DOCS_COMMENTS_ENABLED="yes" DOCS_COMMENTS_ENABLED="yes"
if [[ "$NON_INTERACTIVE" == "false" ]]; then
echo "" echo ""
info "Gitea auto-setup will create the API token, repos, and OAuth app automatically." 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)." info "You need to provide the Gitea admin password (set during Gitea's first-run install)."
@ -675,6 +794,7 @@ configure_features() {
else else
info "No password provided. Run Gitea Setup from the admin GUI after first start." info "No password provided. Run Gitea Setup from the admin GUI after first start."
fi fi
fi
else else
DOCS_COMMENTS_ENABLED="no" DOCS_COMMENTS_ENABLED="no"
fi fi
@ -692,6 +812,7 @@ configure_features() {
success "Bunker Ops enabled" success "Bunker Ops enabled"
BUNKER_OPS_ENABLED="yes" BUNKER_OPS_ENABLED="yes"
if [[ "$NON_INTERACTIVE" == "false" ]]; then
echo "" echo ""
read -rp " Instance label [default: domain name]: " instance_label read -rp " Instance label [default: domain name]: " instance_label
if [[ -n "$instance_label" ]]; then if [[ -n "$instance_label" ]]; then
@ -702,6 +823,7 @@ configure_features() {
if [[ -n "$remote_write_url" ]]; then if [[ -n "$remote_write_url" ]]; then
update_env_var "BUNKER_OPS_REMOTE_WRITE_URL" "$remote_write_url" update_env_var "BUNKER_OPS_REMOTE_WRITE_URL" "$remote_write_url"
fi fi
fi
else else
BUNKER_OPS_ENABLED="no" BUNKER_OPS_ENABLED="no"
fi fi
@ -711,6 +833,7 @@ configure_features() {
update_env_var "ENABLE_ANALYTICS" "true" update_env_var "ENABLE_ANALYTICS" "true"
success "Analytics enabled" success "Analytics enabled"
if [[ "$NON_INTERACTIVE" == "false" ]]; then
echo "" echo ""
info "GeoIP tracking requires a free MaxMind account." info "GeoIP tracking requires a free MaxMind account."
info "Sign up at: https://www.maxmind.com/en/geolite2/signup" info "Sign up at: https://www.maxmind.com/en/geolite2/signup"
@ -725,6 +848,7 @@ configure_features() {
else else
info "Set MAXMIND_ACCOUNT_ID and MAXMIND_LICENSE_KEY in .env to enable geo tracking." info "Set MAXMIND_ACCOUNT_ID and MAXMIND_LICENSE_KEY in .env to enable geo tracking."
fi fi
fi
else else
info "Analytics disabled (can enable later in admin Settings)" info "Analytics disabled (can enable later in admin Settings)"
fi fi
@ -733,11 +857,21 @@ configure_features() {
configure_pangolin() { configure_pangolin() {
header "Tunnel Configuration (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 "Pangolin provides secure public access to your services."
info "Skip this if you'll configure tunneling later." info "Skip this if you'll configure tunneling later."
echo "" echo ""
if prompt_yes_no "Configure Pangolin tunnel now?"; then if ! prompt_yes_no "Configure Pangolin tunnel now?"; then
PANGOLIN_CONFIGURED="no"
return
fi
read -rp " Pangolin API URL [default: https://api.bnkserve.org/v1]: " pang_url read -rp " Pangolin API URL [default: https://api.bnkserve.org/v1]: " pang_url
pang_url=${pang_url:-https://api.bnkserve.org/v1} pang_url=${pang_url:-https://api.bnkserve.org/v1}
read -rp " Pangolin API key: " pang_key read -rp " Pangolin API key: " pang_key
@ -747,11 +881,221 @@ configure_pangolin() {
update_env_var "PANGOLIN_API_KEY" "$pang_key" update_env_var "PANGOLIN_API_KEY" "$pang_key"
update_env_var "PANGOLIN_ORG_ID" "$pang_org" update_env_var "PANGOLIN_ORG_ID" "$pang_org"
success "Pangolin configured" 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." info "Complete tunnel setup in the admin GUI at /app/pangolin after starting services."
PANGOLIN_CONFIGURED="yes" 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 else
PANGOLIN_CONFIGURED="no" 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
info "Existing Newt credentials found in .env (newtId: $existing_newt_id)"
fi fi
} }
@ -1182,6 +1526,11 @@ YAML
configure_docs_reset() { configure_docs_reset() {
header "Documentation Site" 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 if prompt_yes_no "Reset documentation site to baseline? (keeps header & tracking)"; then
touch "$SCRIPT_DIR/mkdocs/.reset-docs-on-startup" touch "$SCRIPT_DIR/mkdocs/.reset-docs-on-startup"
success "Docs reset scheduled for first startup" success "Docs reset scheduled for first startup"
@ -1248,6 +1597,13 @@ fix_container_permissions() {
install_upgrade_watcher() { install_upgrade_watcher() {
header "System 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 "The upgrade watcher lets you trigger upgrades from the admin Settings page."
info "It installs a systemd path watcher that monitors for trigger files." info "It installs a systemd path watcher that monitors for trigger files."
echo "" echo ""
@ -1304,6 +1660,12 @@ install_upgrade_watcher() {
install_backup_timer() { install_backup_timer() {
header "Automated Backups" 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 "Daily automated backups protect against data loss."
info "Backs up PostgreSQL databases + uploads to ./backups/ with 30-day retention." info "Backs up PostgreSQL databases + uploads to ./backups/ with 30-day retention."
echo "" echo ""

View File

@ -102,6 +102,10 @@ services:
- ALERTMANAGER_EMBED_PORT=${ALERTMANAGER_EMBED_PORT:-8895} - ALERTMANAGER_EMBED_PORT=${ALERTMANAGER_EMBED_PORT:-8895}
# SMS Campaigns (Termux Android Bridge) # SMS Campaigns (Termux Android Bridge)
- ENABLE_SMS=${ENABLE_SMS:-false} - 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_URL=${TERMUX_API_URL:-http://10.0.0.193:5001}
- TERMUX_API_KEY=${TERMUX_API_KEY:-} - TERMUX_API_KEY=${TERMUX_API_KEY:-}
- SMS_DELAY_BETWEEN_MS=${SMS_DELAY_BETWEEN_MS:-3000} - SMS_DELAY_BETWEEN_MS=${SMS_DELAY_BETWEEN_MS:-3000}
@ -128,6 +132,7 @@ services:
- ./data/upgrade:/app/upgrade:rw - ./data/upgrade:/app/upgrade:rw
- ./configs:/app/configs:ro - ./configs:/app/configs:ro
- ./logs/api:/app/logs - ./logs/api:/app/logs
- ./.env:/.env:rw # Pangolin setup auto-writes credentials
deploy: deploy:
resources: resources:
limits: limits:
@ -167,6 +172,7 @@ services:
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET} - JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
- JWT_INVITE_SECRET=${JWT_INVITE_SECRET} - JWT_INVITE_SECRET=${JWT_INVITE_SECRET}
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000,http://localhost:3100} - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000,http://localhost:3100}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- ENABLE_MEDIA_FEATURES=${ENABLE_MEDIA_FEATURES:-true} - ENABLE_MEDIA_FEATURES=${ENABLE_MEDIA_FEATURES:-true}
- MEDIA_ROOT=/media/local - MEDIA_ROOT=/media/local
- MEDIA_UPLOADS=/media/uploads - MEDIA_UPLOADS=/media/uploads
@ -362,7 +368,7 @@ services:
redis: redis:
image: ${GITEA_REGISTRY:-gitea.bnkops.com/admin}/redis:7-alpine image: ${GITEA_REGISTRY:-gitea.bnkops.com/admin}/redis:7-alpine
container_name: redis-changemaker 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: ports:
- "127.0.0.1:6379:6379" - "127.0.0.1:6379:6379"
volumes: volumes:

View File

@ -103,6 +103,10 @@ services:
- ALERTMANAGER_EMBED_PORT=${ALERTMANAGER_EMBED_PORT:-8895} - ALERTMANAGER_EMBED_PORT=${ALERTMANAGER_EMBED_PORT:-8895}
# SMS Campaigns (Termux Android Bridge) # SMS Campaigns (Termux Android Bridge)
- ENABLE_SMS=${ENABLE_SMS:-false} - 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_URL=${TERMUX_API_URL:-http://10.0.0.193:5001}
- TERMUX_API_KEY=${TERMUX_API_KEY:-} - TERMUX_API_KEY=${TERMUX_API_KEY:-}
- SMS_DELAY_BETWEEN_MS=${SMS_DELAY_BETWEEN_MS:-3000} - SMS_DELAY_BETWEEN_MS=${SMS_DELAY_BETWEEN_MS:-3000}
@ -138,6 +142,7 @@ services:
- ./data/upgrade:/app/upgrade:rw - ./data/upgrade:/app/upgrade:rw
- ./configs:/app/configs:ro - ./configs:/app/configs:ro
- ./logs/api:/app/logs - ./logs/api:/app/logs
- ./.env:/.env:rw # Pangolin setup auto-writes credentials
deploy: deploy:
resources: resources:
limits: limits:
@ -181,6 +186,7 @@ services:
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET} - JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
- JWT_INVITE_SECRET=${JWT_INVITE_SECRET} - JWT_INVITE_SECRET=${JWT_INVITE_SECRET}
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000,http://localhost:3100} - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000,http://localhost:3100}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- ENABLE_MEDIA_FEATURES=${ENABLE_MEDIA_FEATURES:-true} - ENABLE_MEDIA_FEATURES=${ENABLE_MEDIA_FEATURES:-true}
- MEDIA_ROOT=/media/local - MEDIA_ROOT=/media/local
- MEDIA_UPLOADS=/media/uploads - MEDIA_UPLOADS=/media/uploads
@ -386,7 +392,7 @@ services:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: redis-changemaker 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: ports:
- "127.0.0.1:6379:6379" - "127.0.0.1:6379:6379"
volumes: volumes:

View File

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