Files
etcprs/scripts/resume-deploy.sh
2026-03-18 03:24:25 -06:00

441 lines
19 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# =============================================================================
# ETC PRS — Resume Deployment Script
# Picks up a failed deploy.sh run by detecting what's already been done.
# Usage: sudo bash scripts/resume-deploy.sh
# =============================================================================
set -euo pipefail
# ── Colours ───────────────────────────────────────────────────────────────────
RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m'
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
info() { echo -e "${CYAN}$*${RESET}"; }
success() { echo -e "${GREEN}$*${RESET}"; }
warn() { echo -e "${YELLOW}$*${RESET}"; }
error() { echo -e "${RED}$*${RESET}"; exit 1; }
skip() { echo -e "${GREEN}$* — already done, skipping${RESET}"; }
header() { echo -e "\n${BOLD}${CYAN}══ $* ══${RESET}\n"; }
divider() { echo -e "${CYAN}─────────────────────────────────────────────────${RESET}"; }
# ── Root check ────────────────────────────────────────────────────────────────
if [[ $EUID -ne 0 ]]; then
error "This script must be run as root (sudo bash scripts/resume-deploy.sh)"
fi
# ── Constants (must match deploy.sh) ─────────────────────────────────────────
APP_USER="prs"
APP_DIR="/opt/etc-prs/app"
DATA_DIR="/var/lib/etc-prs"
LOG_DIR="/var/log/etc-prs"
BACKUP_DIR="/var/backups/etc-prs"
BACKUP_SCRIPT="/opt/etc-prs/backup.sh"
NGINX_CONF="/etc/nginx/sites-available/etc-prs"
ECOSYSTEM="${APP_DIR}/ecosystem.config.cjs"
# ── Banner ────────────────────────────────────────────────────────────────────
clear
echo -e "${BOLD}${CYAN}"
echo " ███████╗████████╗ ██████╗ ██████╗ ██████╗ ███████╗"
echo " ██╔════╝╚══██╔══╝██╔════╝ ██╔══██╗██╔══██╗██╔════╝"
echo " █████╗ ██║ ██║ ██████╔╝██████╔╝███████╗"
echo " ██╔══╝ ██║ ██║ ██╔═══╝ ██╔══██╗╚════██║"
echo " ███████╗ ██║ ╚██████╗ ██║ ██║ ██║███████║"
echo " ╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝"
echo -e "${RESET}"
echo -e " ${BOLD}Personality Editor — Resume Deployment${RESET}"
divider
echo ""
# ── State detection ───────────────────────────────────────────────────────────
header "Detecting current state"
HAS_NODE=false; command -v node &>/dev/null && HAS_NODE=true
HAS_PM2=false; command -v pm2 &>/dev/null && HAS_PM2=true
HAS_NGINX=false; command -v nginx &>/dev/null && HAS_NGINX=true
HAS_CERTBOT=false; command -v certbot &>/dev/null && HAS_CERTBOT=true
HAS_USER=false; id "$APP_USER" &>/dev/null && HAS_USER=true
HAS_REPO=false; [[ -d "${APP_DIR}/.git" ]] && HAS_REPO=true
HAS_ENV=false; [[ -f "${APP_DIR}/.env" ]] && HAS_ENV=true
HAS_BUILD=false; [[ -f "${APP_DIR}/build/index.js" ]] && HAS_BUILD=true
HAS_ECOSYSTEM=false; [[ -f "$ECOSYSTEM" ]] && HAS_ECOSYSTEM=true
PM2_RUNNING=false; pm2 list 2>/dev/null | grep -q "etc-prs" && PM2_RUNNING=true
NGINX_ENABLED=false; [[ -L "/etc/nginx/sites-enabled/etc-prs" ]] && NGINX_ENABLED=true
HAS_SSL=false; [[ -d "/etc/letsencrypt/live" ]] && ls /etc/letsencrypt/live/*/fullchain.pem &>/dev/null && HAS_SSL=true
HAS_CRON=false; crontab -l 2>/dev/null | grep -q "$BACKUP_SCRIPT" && HAS_CRON=true
HAS_ADMIN=false; [[ -f "${DATA_DIR}/personalities.db" ]] && \
sqlite3 "${DATA_DIR}/personalities.db" "SELECT COUNT(*) FROM admins;" &>/dev/null && \
[[ $(sqlite3 "${DATA_DIR}/personalities.db" "SELECT COUNT(*) FROM admins;") -gt 0 ]] && \
HAS_ADMIN=true
echo -e " Node.js: $( $HAS_NODE && echo -e "${GREEN}$(node --version)${RESET}" || echo -e "${RED}✗ missing${RESET}" )"
echo -e " PM2: $( $HAS_PM2 && echo -e "${GREEN}$(pm2 --version)${RESET}" || echo -e "${RED}✗ missing${RESET}" )"
echo -e " Nginx: $( $HAS_NGINX && echo -e "${GREEN}${RESET}" || echo -e "${RED}✗ missing${RESET}" )"
echo -e " Certbot: $( $HAS_CERTBOT && echo -e "${GREEN}${RESET}" || echo -e "${RED}✗ missing${RESET}" )"
echo -e " prs user: $( $HAS_USER && echo -e "${GREEN}${RESET}" || echo -e "${RED}✗ missing${RESET}" )"
echo -e " Git repo: $( $HAS_REPO && echo -e "${GREEN}${RESET}" || echo -e "${RED}✗ missing${RESET}" )"
echo -e " .env: $( $HAS_ENV && echo -e "${GREEN}${RESET}" || echo -e "${RED}✗ missing${RESET}" )"
echo -e " Build: $( $HAS_BUILD && echo -e "${GREEN}${RESET}" || echo -e "${RED}✗ missing${RESET}" )"
echo -e " PM2 running: $( $PM2_RUNNING && echo -e "${GREEN}${RESET}" || echo -e "${YELLOW}✗ not running${RESET}" )"
echo -e " Nginx site: $( $NGINX_ENABLED && echo -e "${GREEN}${RESET}" || echo -e "${RED}✗ not enabled${RESET}" )"
echo -e " SSL cert: $( $HAS_SSL && echo -e "${GREEN}${RESET}" || echo -e "${YELLOW}✗ not issued${RESET}" )"
echo -e " Backup cron: $( $HAS_CRON && echo -e "${GREEN}${RESET}" || echo -e "${RED}✗ missing${RESET}" )"
echo -e " Admin user: $( $HAS_ADMIN && echo -e "${GREEN}${RESET}" || echo -e "${RED}✗ none found${RESET}" )"
echo ""
divider
echo ""
# ── Prompt for any info we need ───────────────────────────────────────────────
header "Configuration"
# Domain — try to read from .env first
DOMAIN=""
if $HAS_ENV; then
DOMAIN=$(grep "^PUBLIC_BASE_URL=" "${APP_DIR}/.env" | sed 's|PUBLIC_BASE_URL=https\?://||')
fi
if [[ -z "$DOMAIN" ]]; then
while true; do
read -rp "$(echo -e "${BOLD}Domain name${RESET} (e.g. etcprs.app): ")" DOMAIN
DOMAIN="${DOMAIN#www.}"; DOMAIN="${DOMAIN%/}"
[[ -n "$DOMAIN" ]] && break
warn "Domain cannot be empty."
done
else
echo -e " Domain: ${CYAN}${DOMAIN}${RESET} (read from .env)"
fi
# Repo — only needed if not already cloned
REPO_URL=""
GITEA_PASS=""
if ! $HAS_REPO; then
echo ""
read -rp "$(echo -e "${BOLD}Gitea repo path${RESET} [default: raine/etc-prs-ui]: ")" REPO_PATH
REPO_PATH="${REPO_PATH:-raine/etc-prs-ui}"
REPO_URL="https://git.etcprs.app/${REPO_PATH}.git"
echo ""
echo -e "${YELLOW}If your Gitea repo is private, enter credentials. Leave blank if public.${RESET}"
read -rp "$(echo -e "${BOLD}Gitea username${RESET} (blank = public): ")" GITEA_USER
if [[ -n "${GITEA_USER:-}" ]]; then
read -rsp "$(echo -e "${BOLD}Gitea password / token${RESET}: ")" GITEA_PASS
echo ""
REPO_URL="https://${GITEA_USER}:${GITEA_PASS}@git.etcprs.app/${REPO_PATH}.git"
fi
fi
# Rate limits — only needed if .env is missing
RATE_PUBLISH="5"; RATE_READ="100"
if ! $HAS_ENV; then
echo ""
echo -e "${BOLD}Rate limits${RESET} (press Enter to accept defaults):"
read -rp " Publish rate limit per IP/hour [default: 5]: " RATE_PUBLISH
RATE_PUBLISH="${RATE_PUBLISH:-5}"
read -rp " Read rate limit per IP/hour [default: 100]: " RATE_READ
RATE_READ="${RATE_READ:-100}"
fi
# SSL email — only needed if no cert
SSL_EMAIL=""
if ! $HAS_SSL; then
echo ""
read -rp "$(echo -e "${BOLD}Email for SSL certificate${RESET}: ")" SSL_EMAIL
[[ -n "$SSL_EMAIL" ]] || error "SSL email is required."
fi
# Admin creds — only needed if no admin exists
ADMIN_USER=""; ADMIN_PASS=""
if ! $HAS_ADMIN; then
echo ""
echo -e "${BOLD}Create the first admin account:${RESET}"
while true; do
read -rp " Admin username (232 chars): " ADMIN_USER
[[ ${#ADMIN_USER} -ge 2 && ${#ADMIN_USER} -le 32 ]] && break
warn "Username must be 232 characters."
done
while true; do
read -rsp " Admin password (min 8 chars): " ADMIN_PASS
echo ""
[[ ${#ADMIN_PASS} -ge 8 ]] && break
warn "Password must be at least 8 characters."
done
read -rsp " Confirm password: " ADMIN_PASS_CONFIRM
echo ""
[[ "$ADMIN_PASS" == "$ADMIN_PASS_CONFIRM" ]] || error "Passwords do not match."
fi
echo ""
read -rp "$(echo -e "${BOLD}Resume deployment now? [y/N]: ${RESET}")" CONFIRM
[[ "${CONFIRM,,}" == "y" ]] || { echo "Aborted."; exit 0; }
# ── Step 1: System packages ───────────────────────────────────────────────────
header "Step 1 / 9 — System Packages"
if $HAS_NODE && $HAS_PM2 && $HAS_NGINX && $HAS_CERTBOT; then
skip "All packages already installed"
else
info "Installing missing packages…"
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
build-essential python3 git nginx certbot python3-certbot-nginx sqlite3 ufw curl
if ! $HAS_NODE; then
info "Installing Node.js 20 LTS…"
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - > /dev/null 2>&1
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nodejs
fi
if ! $HAS_PM2; then
info "Installing PM2…"
npm install -g pm2 --quiet
fi
success "Packages ready — Node $(node --version) / PM2 $(pm2 --version)"
fi
# ── Step 2: Users & directories ───────────────────────────────────────────────
header "Step 2 / 9 — Users & Directories"
if ! $HAS_USER; then
info "Creating system user '${APP_USER}'…"
useradd --system --shell /bin/false --home /opt/etc-prs "$APP_USER"
success "User '${APP_USER}' created"
else
skip "User '${APP_USER}'"
fi
for DIR in /opt/etc-prs "$APP_DIR" "$DATA_DIR" "$LOG_DIR" "$BACKUP_DIR"; do
mkdir -p "$DIR"
done
chown -R "${APP_USER}:${APP_USER}" /opt/etc-prs "$DATA_DIR" "$LOG_DIR" "$BACKUP_DIR"
success "Directories OK"
# ── Step 3: Clone / pull repo ─────────────────────────────────────────────────
header "Step 3 / 9 — Repository"
if $HAS_REPO; then
info "Repo already cloned — pulling latest…"
git -C "$APP_DIR" pull
success "Repo up to date"
else
info "Cloning from ${REPO_URL//:*@/:***@}"
git clone "$REPO_URL" "$APP_DIR" 2>&1 | \
sed 's/'"${GITEA_PASS:-NOPASS}"'/***REDACTED***/g' || \
error "Git clone failed. Check your repo URL and credentials."
success "Repository cloned to ${APP_DIR}"
fi
chown -R "${APP_USER}:${APP_USER}" "$APP_DIR"
# ── Step 4: Environment file ──────────────────────────────────────────────────
header "Step 4 / 9 — Environment Configuration"
if $HAS_ENV; then
skip ".env (${APP_DIR}/.env)"
else
cat > "${APP_DIR}/.env" <<EOF
DATABASE_URL=${DATA_DIR}/personalities.db
RATE_LIMIT_PUBLISH=${RATE_PUBLISH}
RATE_LIMIT_READ=${RATE_READ}
PUBLIC_BASE_URL=https://${DOMAIN}
EOF
chown "${APP_USER}:${APP_USER}" "${APP_DIR}/.env"
chmod 600 "${APP_DIR}/.env"
success ".env written"
fi
# ── Step 5: Install deps & build ──────────────────────────────────────────────
header "Step 5 / 9 — Install & Build"
if $HAS_BUILD; then
skip "Build already exists (${APP_DIR}/build/index.js)"
else
info "Installing npm dependencies…"
npm --prefix "$APP_DIR" install --quiet
info "Building SvelteKit app…"
npm --prefix "$APP_DIR" run build
chown -R "${APP_USER}:${APP_USER}" "$APP_DIR"
success "Build complete"
fi
# ── Step 6: PM2 ───────────────────────────────────────────────────────────────
header "Step 6 / 9 — PM2 Process Manager"
if $PM2_RUNNING; then
skip "PM2 process 'etc-prs' is already running"
else
# Write ecosystem if missing
if ! $HAS_ECOSYSTEM; then
cat > "$ECOSYSTEM" <<EOF
module.exports = {
apps: [{
name: 'etc-prs',
script: '${APP_DIR}/build/index.js',
cwd: '${APP_DIR}',
env: {
NODE_ENV: 'production',
PORT: '3000',
HOST: '127.0.0.1',
},
error_file: '${LOG_DIR}/error.log',
out_file: '${LOG_DIR}/out.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss',
restart_delay: 3000,
max_restarts: 10,
}]
};
EOF
chown "${APP_USER}:${APP_USER}" "$ECOSYSTEM"
fi
info "Starting app with PM2…"
pm2 start "$ECOSYSTEM"
pm2 save
pm2 startup systemd -u root --hp /root | grep -E "^sudo" | bash || true
success "PM2 running on 127.0.0.1:3000"
fi
# ── Step 7: Nginx ─────────────────────────────────────────────────────────────
header "Step 7 / 9 — Nginx"
if $NGINX_ENABLED; then
skip "Nginx site already enabled"
else
info "Writing Nginx config for ${DOMAIN}"
cat > "$NGINX_CONF" <<EOF
server {
listen 80;
server_name ${DOMAIN} www.${DOMAIN};
gzip on;
gzip_vary on;
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml application/xml+rss text/javascript;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
add_header Referrer-Policy "strict-origin-when-cross-origin";
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_cache_bypass \$http_upgrade;
proxy_read_timeout 30s;
client_max_body_size 2M;
}
}
EOF
rm -f /etc/nginx/sites-enabled/default
ln -sf "$NGINX_CONF" /etc/nginx/sites-enabled/etc-prs
nginx -t || error "Nginx config test failed — check ${NGINX_CONF}"
systemctl reload nginx
success "Nginx configured for ${DOMAIN}"
fi
# ── Step 8: SSL ───────────────────────────────────────────────────────────────
header "Step 8 / 9 — SSL Certificate"
if $HAS_SSL; then
skip "SSL certificate already exists"
else
info "Requesting certificate for ${DOMAIN} and www.${DOMAIN}"
if certbot --nginx \
--non-interactive \
--agree-tos \
--email "$SSL_EMAIL" \
--domains "${DOMAIN},www.${DOMAIN}" \
--redirect; then
success "SSL certificate issued"
certbot renew --dry-run --quiet && success "Auto-renewal check passed"
else
warn "Certbot failed — HTTP is still working."
warn "Once DNS is pointing here, run:"
warn " sudo certbot --nginx -d ${DOMAIN} -d www.${DOMAIN} --email ${SSL_EMAIL} --agree-tos"
fi
fi
# ── Step 9: Backups, firewall & admin ─────────────────────────────────────────
header "Step 9 / 9 — Backups, Firewall & Admin Account"
# Backup script
if [[ ! -f "$BACKUP_SCRIPT" ]]; then
cat > "$BACKUP_SCRIPT" <<'EOF'
#!/usr/bin/env bash
DATE=$(date +%Y-%m-%d_%H%M)
BACKUP_DIR=/var/backups/etc-prs
DB_PATH=/var/lib/etc-prs/personalities.db
mkdir -p "$BACKUP_DIR"
sqlite3 "$DB_PATH" ".backup ${BACKUP_DIR}/personalities-${DATE}.db"
gzip "${BACKUP_DIR}/personalities-${DATE}.db"
find "$BACKUP_DIR" -name "personalities-*.db.gz" -mtime +30 -delete
echo "$(date '+%Y-%m-%d %H:%M:%S') Backup OK: personalities-${DATE}.db.gz"
EOF
chmod +x "$BACKUP_SCRIPT"
success "Backup script written"
else
skip "Backup script"
fi
if $HAS_CRON; then
skip "Backup cron job"
else
CRON_LINE="0 2 * * * ${BACKUP_SCRIPT} >> ${LOG_DIR}/backup.log 2>&1"
( crontab -l 2>/dev/null | grep -v "$BACKUP_SCRIPT"; echo "$CRON_LINE" ) | crontab -
success "Backup cron job scheduled (daily at 2am)"
fi
# Firewall
info "Ensuring firewall rules are set…"
ufw allow OpenSSH > /dev/null
ufw allow 'Nginx Full' > /dev/null
ufw --force enable > /dev/null
success "Firewall OK"
# Admin account
if $HAS_ADMIN; then
skip "Admin account already exists in DB"
else
info "Creating admin account '${ADMIN_USER}'…"
cd "$APP_DIR"
node scripts/create-admin.js "$ADMIN_USER" "$ADMIN_PASS" || \
warn "Admin creation failed — run manually: cd ${APP_DIR} && node scripts/create-admin.js <user> <pass>"
success "Admin account '${ADMIN_USER}' created"
fi
# ── Done ──────────────────────────────────────────────────────────────────────
echo ""
divider
echo -e "${BOLD}${GREEN} Deployment complete!${RESET}"
divider
echo ""
echo -e " ${BOLD}Site:${RESET} https://${DOMAIN}"
echo -e " ${BOLD}Admin panel:${RESET} https://${DOMAIN}/admin"
echo -e " ${BOLD}App logs:${RESET} pm2 logs etc-prs"
echo -e " ${BOLD}DB path:${RESET} ${DATA_DIR}/personalities.db"
echo -e " ${BOLD}Backups:${RESET} ${BACKUP_DIR}"
echo ""
echo -e " ${BOLD}Redeploy after a push:${RESET}"
echo -e " ${CYAN} cd ${APP_DIR} && git pull && \\"
echo -e " npm install && npm run build && \\"
echo -e " chown -R ${APP_USER}:${APP_USER} ${APP_DIR} && \\"
echo -e " pm2 reload etc-prs${RESET}"
echo ""
divider
echo ""
# Verify
info "Checking app is responding on port 3000…"
sleep 2
if curl -sf http://127.0.0.1:3000 > /dev/null; then
success "App is up and responding ✓"
else
warn "App did not respond on port 3000. Check logs: pm2 logs etc-prs --lines 30"
fi
echo ""