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:
parent
74e5fa6475
commit
530551f568
@ -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
|
||||
|
||||
@ -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
578
config.sh
@ -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 ""
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
253
scripts/test-deployment.sh
Executable 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
127
scripts/uninstall.sh
Executable 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 ""
|
||||
Loading…
x
Reference in New Issue
Block a user