mailwolt/scripts/update.sh

449 lines
18 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

#!/usr/bin/env bash
set -euo pipefail
# -------- Konfiguration -------------------------------------------------------
APP_USER="${APP_USER:-mailwolt}"
APP_GROUP="${APP_GROUP:-www-data}"
APP_DIR="${APP_DIR:-/var/www/mailwolt}"
BRANCH="${BRANCH:-main}"
MODE="${UPDATE_MODE:-tags}"
ALLOW_DIRTY="${ALLOW_DIRTY:-0}"
# -------- --check-only: nur Remote-Tags holen, Version schreiben, sicher beenden ---
# (wird von CheckUpdates via sudo aufgerufen — kein Update, nur Versionsprüfung)
if [[ "${1:-}" == "--check-only" ]]; then
LATEST="$(sudo -u "$APP_USER" bash -c \
"git -C '${APP_DIR}' ls-remote --tags --sort=-v:refname origin 'v*' 2>/dev/null \
| grep -v '\^{}' | head -1 | sed 's|.*refs/tags/||'" 2>/dev/null || true)"
if [[ -n "$LATEST" ]]; then
mkdir -p /var/lib/mailwolt
printf '%s\n' "$LATEST" > /var/lib/mailwolt/version_remote
fi
exit 0
fi
STATE_DIR="/var/lib/mailwolt/update"
LOCK_FILE="/var/run/mailwolt-update.lock"
LOG_FILE="/var/log/mailwolt-update.log"
# npm / CI Defaults
export CI=1
export NPM_CONFIG_FUND=false
export NPM_CONFIG_AUDIT=false
export npm_config_loglevel=warn
# -------- Helper --------------------------------------------------------------
as_app(){ sudo -u "$APP_USER" -H bash -lc "$*"; }
restart_if_exists(){
local u="$1"
systemctl list-unit-files | grep -q "^${u}\.service" && systemctl restart "$u" || true
}
reload_if_active(){
local u="$1"
systemctl is-active --quiet "$u" && systemctl reload "$u" || true
}
restart_php_fpm(){
for u in php8.3-fpm php8.2-fpm php8.1-fpm php-fpm; do
if systemctl list-unit-files | grep -q "^${u}\.service"; then
# reload = graceful: laufende Requests fertig, dann Workers tauschen
systemctl reload "$u" 2>/dev/null || systemctl restart "$u"
return 0
fi
done
}
artisan_down(){
as_app "cd ${APP_DIR} && php artisan down --render=errors.503 --retry=10" 2>/dev/null || true
echo "[i] Wartungsmodus aktiviert"
}
artisan_up(){
as_app "cd ${APP_DIR} && php artisan up" 2>/dev/null || true
echo "[i] Wartungsmodus beendet"
}
git_safe(){
# Falls nötig: Repo als safe markieren (z.B. wenn Root den Wrapper aufruft)
as_app "git -C ${APP_DIR} config --global --add safe.directory ${APP_DIR} >/dev/null 2>&1 || true"
# .git/objects-Rechte reparieren: root-Läufe können Objekte mit falschen Rechten anlegen
find "${APP_DIR}/.git/objects" -not -user "$APP_USER" \
-exec chown "$APP_USER":"$APP_GROUP" {} \; 2>/dev/null || true
}
git_dirty_check(){
local dirty
dirty="$(as_app "git -C ${APP_DIR} status --porcelain" 2>/dev/null || true)"
if [[ -n "$dirty" ]]; then
echo "[!] Uncommitted Änderungen gefunden — tracked Dateien werden zurückgesetzt:"
echo "$dirty"
as_app "git -C ${APP_DIR} reset --hard HEAD" 2>/dev/null || true
as_app "git -C ${APP_DIR} clean -fd --exclude='.env' --exclude='storage/' --exclude='node_modules/'" 2>/dev/null || true
fi
}
get_version(){
as_app "cd ${APP_DIR} && (git describe --tags --always 2>/dev/null || git rev-parse --short=7 HEAD)"
}
write_version_files(){
local ver="$1" rev="$2"
install -d -m 0755 /etc/mailwolt /var/lib/mailwolt || true
printf "version=%s\nrev=%s\nupdated=%s\n" "$ver" "$rev" "$(date -Is)" > /etc/mailwolt/build.info || true
printf '%s\n' "${ver#v}" > /var/lib/mailwolt/version || true
printf '%s\n' "$ver" > /var/lib/mailwolt/version_raw || true
}
write_build_info(){ write_version_files "$@"; }
# --- Frontend build (quiet & robust) ------------------------------------------
frontend_build_quiet() {
local LOG="/var/log/mailwolt-frontend-build.log"
local NPM_ENV="CI=1 NPM_CONFIG_FUND=false NPM_CONFIG_AUDIT=false npm_config_loglevel=warn"
# Logfile vorbereiten
install -d -m 0755 "$(dirname "$LOG")"
: > "$LOG" || true
chmod 0644 "$LOG"
echo "[i] Frontend: vorbereiten …"
# Build-Ziele & Temp besitzbar machen
as_app "mkdir -p '${APP_DIR}/public/build' '${APP_DIR}/node_modules' '${APP_DIR}/.vite' '${APP_DIR}/.npm-cache'"
chown -R "$APP_USER":"$APP_GROUP" "${APP_DIR}/public/build" "${APP_DIR}/node_modules" "${APP_DIR}/.vite" "${APP_DIR}/.npm-cache" || true
chmod -R g+rwX "${APP_DIR}/public/build" "${APP_DIR}/node_modules" "${APP_DIR}/.vite" || true
# evtl. störrische Reste entfernen (vermeidet EACCES beim Unlink/Rimraf)
rm -rf "${APP_DIR}/node_modules/.vite" "${APP_DIR}/public/build/"* 2>/dev/null || true
# npm Config (kein Fund/Audit, eigener Cache unter App-User)
as_app "printf 'fund=false\naudit=false\ncache=${APP_DIR}/.npm-cache\n' > ~/.npmrc" >>"$LOG" 2>&1 || true
echo "[i] Frontend: Dependencies … (Details: $LOG)"
if ! as_app "cd '${APP_DIR}' && ${NPM_ENV} npm ci --no-audit --no-fund --no-progress" >>"$LOG" 2>&1; then
if ! as_app "cd '${APP_DIR}' && ${NPM_ENV} npm install --no-audit --no-fund --no-progress" >>"$LOG" 2>&1; then
echo "[!] npm install fehlgeschlagen. Letzte 60 Zeilen:"
tail -n 60 "$LOG" || true
return 1
fi
fi
echo "[i] Frontend: Build … (Details: $LOG)"
if ! as_app "cd '${APP_DIR}' && ${NPM_ENV} npm run build --silent --loglevel=warn" >>"$LOG" 2>&1; then
echo "[!] Build fehlgeschlagen. Letzte 80 Zeilen:"
tail -n 80 "$LOG" || true
return 1
fi
# Nach dem Build noch einmal Rechte begradigen
chown -R "$APP_USER":"$APP_GROUP" "${APP_DIR}/public/build" || true
find "${APP_DIR}/public/build" -type d -exec chmod 2775 {} \; 2>/dev/null || true
find "${APP_DIR}/public/build" -type f -exec chmod 0664 {} \; 2>/dev/null || true
echo "[✓] Frontend gebaut."
}
# -------- Systemzustand sicherstellen (läuft bei JEDER Ausführung) -----------
# Neue Scripts/Sudoers-Regeln werden so auch ohne echtes Update eingespielt.
_sbin_install(){
local src="$1" dst="$2"
[[ -f "$src" ]] && install -m 755 "$src" "$dst" && return 0 || return 0
}
_sudoers_add(){
local file="$1" rule="$2"
[[ -f "$file" ]] || return 0
grep -qF "$rule" "$file" && return 0
chmod 640 "$file"
printf '%s\n' "$rule" >> "$file"
chmod 440 "$file"
}
ensure_system(){
# Scripts nach sbin
_sbin_install "${APP_DIR}/scripts/mailwolt-apply-domains" /usr/local/sbin/mailwolt-apply-domains
_sbin_install "${APP_DIR}/scripts/mailwolt-fetch-tags" /usr/local/sbin/mailwolt-fetch-tags
_sbin_install "${APP_DIR}/scripts/update.sh" /usr/local/sbin/mailwolt-update
# Sudoers-Regeln nachrüsten
for f in /etc/sudoers.d/mailwolt-certbot /etc/sudoers.d/mailwolt-dkim; do
_sudoers_add "$f" 'www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-fetch-tags'
_sudoers_add "$f" 'www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-update --check-only'
_sudoers_add "$f" 'www-data ALL=(root) NOPASSWD: /usr/bin/openssl x509 -enddate -noout -in /etc/letsencrypt/live/*/fullchain.pem'
done
# Laravel Scheduler cron (schedule:run muss jede Minute laufen)
local cron_file="/etc/cron.d/mailwolt"
local cron_line="* * * * * www-data php ${APP_DIR}/artisan schedule:run >> /dev/null 2>&1"
if ! grep -qF "artisan schedule:run" "${cron_file}" 2>/dev/null; then
printf '%s\n' "${cron_line}" > "${cron_file}"
chmod 644 "${cron_file}"
echo "[✓] Laravel Scheduler cron installiert."
fi
# Reverb WebSocket Service
local ws_service="/etc/systemd/system/mailwolt-ws.service"
if [[ ! -f "${ws_service}" ]]; then
local php_bin
php_bin=$(command -v php || echo "/usr/bin/php")
cat > "${ws_service}" <<UNIT
[Unit]
Description=Laravel Reverb WebSocket (MailWolt)
After=network.target
[Service]
WorkingDirectory=${APP_DIR}
ExecStart=${php_bin} artisan reverb:start --host=127.0.0.1 --port=8080 --no-interaction
Restart=always
RestartSec=5
User=www-data
Group=www-data
KillSignal=SIGTERM
TimeoutStopSec=30
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable mailwolt-ws
systemctl start mailwolt-ws
echo "[✓] mailwolt-ws Service installiert und gestartet."
elif ! systemctl is-active --quiet mailwolt-ws; then
systemctl start mailwolt-ws
echo "[✓] mailwolt-ws Service gestartet."
fi
}
# -------- Guards --------------------------------------------------------------
[[ "$(id -u)" -eq 0 ]] || { echo "[!] Bitte als root ausführen"; exit 1; }
[[ -d "$APP_DIR/.git" ]] || { echo "[!] $APP_DIR scheint kein Git-Repo zu sein"; exit 1; }
# -------- Lock ----------------------------------------------------------------
mkdir -p "$STATE_DIR"
if [[ -f "$LOCK_FILE" ]]; then
LOCK_PID=$(cat "$LOCK_FILE" 2>/dev/null || true)
if [[ -n "$LOCK_PID" ]] && kill -0 "$LOCK_PID" 2>/dev/null; then
echo "[!] Update läuft bereits (PID $LOCK_PID) — Abbruch."
exit 1
fi
rm -f "$LOCK_FILE"
fi
echo $$ > "$LOCK_FILE"
# -------- State & Cleanup-Trap -----------------------------------------------
MAINTENANCE_ACTIVE=0
_cleanup() {
local rc=$?
[[ "$MAINTENANCE_ACTIVE" -eq 1 ]] && artisan_up
mkdir -p "$STATE_DIR"
echo "done" > "$STATE_DIR/state"
echo "$rc" > "$STATE_DIR/rc"
rm -f "$LOCK_FILE"
}
trap '_cleanup' EXIT INT TERM
# Update als laufend markieren
echo "running" > "$STATE_DIR/state"
rm -f "$STATE_DIR/rc"
: > "$LOG_FILE"
# Alle echo-Ausgaben in Log-Datei schreiben (und stdout — für CLI-Nutzung)
exec > >(tee -a "$LOG_FILE") 2>&1
git_safe
git_dirty_check
# -------- Git: neuen Stand holen ---------------------------------------------
echo "[i] Prüfe Repository …"
OLD_REV="$(as_app "git -C ${APP_DIR} rev-parse HEAD")"
OLD_VER="$(get_version)"
NEW_REV="$OLD_REV"
if [[ "$MODE" = "tags" ]]; then
# → Neueste Tags holen
as_app "git -C ${APP_DIR} fetch --quiet origin && git -C ${APP_DIR} fetch --tags --quiet origin || true"
LATEST_TAG="$(as_app "git -C ${APP_DIR} describe --tags --abbrev=0 \$(git -C ${APP_DIR} rev-list --tags --max-count=1 2>/dev/null) 2>/dev/null || echo ''")"
# Neuesten Remote-Tag cachen für CheckUpdates (läuft als www-data ohne git-Credentials)
[[ -n "$LATEST_TAG" ]] && { mkdir -p /var/lib/mailwolt; printf '%s\n' "$LATEST_TAG" > /var/lib/mailwolt/version_remote; } || true
if [[ -z "$LATEST_TAG" ]]; then
echo "[!] Keine Tags gefunden falle auf origin/${BRANCH} zurück"
as_app "git -C ${APP_DIR} checkout -q ${BRANCH} && git -C ${APP_DIR} pull --ff-only origin ${BRANCH}"
else
TARGET_REV="$(as_app "git -C ${APP_DIR} rev-list -n1 ${LATEST_TAG}")"
if [[ "$TARGET_REV" = "$OLD_REV" ]]; then
echo "[✓] Bereits auf neuestem Release (${LATEST_TAG}) nichts zu tun."
write_build_info "$(get_version)" "$OLD_REV"
ensure_system
exit 0
fi
echo "[i] Checkout auf Release ${LATEST_TAG} (${TARGET_REV:0:7}) …"
as_app "git -C ${APP_DIR} checkout -q ${LATEST_TAG}"
fi
NEW_REV="$(as_app "git -C ${APP_DIR} rev-parse HEAD")"
else
# Rolling: branch folgen
as_app "git -C ${APP_DIR} fetch --quiet origin ${BRANCH}"
BEHIND="$(as_app "git -C ${APP_DIR} rev-list --count HEAD..origin/${BRANCH} || echo 0")"
if [[ "$BEHIND" -eq 0 ]]; then
echo "[✓] Branch origin/${BRANCH} ist bereits aktuell nichts zu tun."
write_build_info "$(get_version)" "$OLD_REV"
ensure_system
exit 0
fi
echo "[i] Es gibt ${BEHIND} neue Commit(s) ziehe Änderungen …"
as_app "git -C ${APP_DIR} checkout -q ${BRANCH} && git -C ${APP_DIR} pull --ff-only origin ${BRANCH}"
NEW_REV="$(as_app "git -C ${APP_DIR} rev-parse HEAD")"
fi
# -------- Änderungstypen ermitteln -------------------------------------------
CHANGED_FILES="$(as_app "git -C ${APP_DIR} diff --name-only ${OLD_REV}..${NEW_REV}")"
NEED_COMPOSER=0
NEED_MIGRATIONS=0
NEED_FRONTEND=0
NEED_PHP_RESTART=0
NEED_SBIN_UPDATE=0
echo "$CHANGED_FILES" | grep -qE '(^|/)composer\.(json|lock)$' && NEED_COMPOSER=1
echo "$CHANGED_FILES" | grep -qE '^database/migrations/' && NEED_MIGRATIONS=1
echo "$CHANGED_FILES" | grep -qE '^(package(-lock)?\.json|vite\.config(\.ts|\.js)?|resources/|public/.*\.(js|css))' && NEED_FRONTEND=1
echo "$CHANGED_FILES" | grep -qE '^(app/|routes/|config/|resources/views/)' && NEED_PHP_RESTART=1
echo "$CHANGED_FILES" | grep -qE '^scripts/' && NEED_SBIN_UPDATE=1
echo "[i] Zusammenfassung:"
echo " Composer : $([[ $NEED_COMPOSER -eq 1 ]] && echo JA || echo nein)"
echo " Migrations : $([[ $NEED_MIGRATIONS -eq 1 ]] && echo JA || echo nein)"
echo " Frontend : $([[ $NEED_FRONTEND -eq 1 ]] && echo JA || echo nein)"
echo " PHP restart : $([[ $NEED_PHP_RESTART -eq 1 ]] && echo JA || echo nein)"
echo " Sbin-Scripts : $([[ $NEED_SBIN_UPDATE -eq 1 ]] && echo JA || echo nein)"
# System-Scripts aus scripts/ sofort nach dem Pull einspielen (kein Downtime nötig)
# ensure_system() am Ende des Scripts übernimmt das vollständig.
# .env immer migrieren (unabhängig vom Update-Pfad)
as_app "cd ${APP_DIR} && php artisan mailwolt:migrate-env-reverb" 2>/dev/null || migrate_env_reverb
# Wenn gar nichts relevantes geändert wurde → sauber beenden
if [[ $NEED_COMPOSER -eq 0 && $NEED_MIGRATIONS -eq 0 && $NEED_FRONTEND -eq 0 && $NEED_PHP_RESTART -eq 0 ]]; then
echo "[✓] Code-Stand aktualisiert, aber keine Build/Runtime-Änderungen keine Neustarts nötig."
NEW_VER="$(get_version)"
write_build_info "$NEW_VER" "$NEW_REV"
echo "[i] Version: ${OLD_VER}${NEW_VER}"
ensure_system
exit 0
fi
# -------- Composer (ohne Downtime) -------------------------------------------
if [[ $NEED_COMPOSER -eq 1 ]]; then
echo "[i] Composer …"
as_app "cd ${APP_DIR} && composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev"
fi
# -------- Frontend (ohne Downtime — Assets sind statisch) --------------------
if [[ $NEED_FRONTEND -eq 1 ]]; then
echo "[i] Frontend-Änderungen erkannt baue Assets …"
if ! frontend_build_quiet; then
echo "[!] Frontend-Build ist fehlgeschlagen (siehe /var/log/mailwolt-frontend-build.log)."
exit 1
fi
fi
# -------- Wartungsmodus: Migrations + Cache + PHP-FPM reload -----------------
if [[ $NEED_MIGRATIONS -eq 1 || $NEED_PHP_RESTART -eq 1 || $NEED_COMPOSER -eq 1 ]]; then
artisan_down
MAINTENANCE_ACTIVE=1
fi
if [[ $NEED_MIGRATIONS -eq 1 ]]; then
echo "[i] DB-Migrationen …"
as_app "cd ${APP_DIR} && php artisan migrate --force"
fi
if [[ $NEED_PHP_RESTART -eq 1 || $NEED_COMPOSER -eq 1 || $NEED_MIGRATIONS -eq 1 ]]; then
echo "[i] Cache/Optimierungen …"
as_app "cd ${APP_DIR} && php artisan optimize:clear || true"
as_app "cd ${APP_DIR} && php artisan config:cache || true"
as_app "cd ${APP_DIR} && php artisan route:cache || true"
as_app "cd ${APP_DIR} && php artisan queue:restart || true"
fi
# -------- Dienste neu laden (gezielt) ----------------------------------------
echo "[i] Dienste neu laden …"
if [[ $NEED_PHP_RESTART -eq 1 || $NEED_COMPOSER -eq 1 || $NEED_MIGRATIONS -eq 1 ]]; then
restart_php_fpm
restart_if_exists "${APP_USER}-queue"
restart_if_exists "${APP_USER}-schedule"
restart_if_exists "${APP_USER}-ws"
fi
if [[ $NEED_FRONTEND -eq 1 || $NEED_PHP_RESTART -eq 1 ]]; then
reload_if_active nginx
fi
# -------- Wartungsmodus beenden ----------------------------------------------
if [[ "$MAINTENANCE_ACTIVE" -eq 1 ]]; then
artisan_up
MAINTENANCE_ACTIVE=0
fi
# -------- Version + Build-Info ablegen ----------------------------------------
NEW_VER="$(get_version)"
write_version_files "$NEW_VER" "$NEW_REV"
echo "[✓] Update abgeschlossen: ${OLD_VER}${NEW_VER} (${OLD_REV:0:7}${NEW_REV:0:7})"
# -------- .env: REVERB-Werte auf Domain-Basis normalisieren -------------------
migrate_env_reverb() {
local env_file="${APP_DIR}/.env"
[ -f "${env_file}" ] || return 0
# APP_HOST auslesen — Fallback auf APP_URL wenn nicht gesetzt
local app_host
app_host=$(grep '^APP_HOST=' "${env_file}" | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'")
if [ -z "${app_host}" ]; then
local app_url
app_url=$(grep '^APP_URL=' "${env_file}" | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'")
# https://domain.at → domain.at oder http://1.2.3.4 → 1.2.3.4
app_host=$(echo "${app_url}" | sed 's|https\?://||' | cut -d/ -f1 | cut -d: -f1)
fi
[ -z "${app_host}" ] && return 0
# IP-Adresse → nichts tun (kein sinnvoller Hostname für TLS)
echo "${app_host}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' && return 0
# Nur migrieren wenn VITE_REVERB_HOST nicht auf app_host zeigt
local current_vite_host
current_vite_host=$(grep '^VITE_REVERB_HOST=' "${env_file}" | head -1 | cut -d= -f2-)
if [ "${current_vite_host}" = "\${REVERB_HOST}" ] || [ "${current_vite_host}" = "${app_host}" ]; then
return 0 # bereits korrekt
fi
echo "[i] .env: REVERB-Werte werden auf Domain '${app_host}' aktualisiert …"
local scheme="http" port="80"
[ -f "/etc/letsencrypt/live/${app_host}/fullchain.pem" ] && scheme="https" && port="443"
_uenv() {
local k="$1" v="$2"
if grep -q "^${k}=" "${env_file}"; then
sed -i "s|^${k}=.*|${k}=${v}|" "${env_file}"
else
echo "${k}=${v}" >> "${env_file}"
fi
}
_uenv "REVERB_HOST" "\${APP_HOST}"
_uenv "REVERB_PORT" "${port}"
_uenv "REVERB_SCHEME" "${scheme}"
_uenv "REVERB_PATH" "/ws"
_uenv "REVERB_SERVER_HOST" "127.0.0.1"
_uenv "REVERB_SERVER_PORT" "8080"
_uenv "REVERB_SERVER_SCHEME" "http"
_uenv "REVERB_SERVER_PATH" ""
_uenv "VITE_REVERB_HOST" "\${REVERB_HOST}"
_uenv "VITE_REVERB_PORT" "\${REVERB_PORT}"
_uenv "VITE_REVERB_SCHEME" "\${REVERB_SCHEME}"
_uenv "VITE_REVERB_PATH" "\${REVERB_PATH}"
# Assets neu bauen mit den korrekten Werten
frontend_build_quiet || true
as_app "cd ${APP_DIR} && php artisan optimize:clear || true"
echo "[✓] REVERB .env migriert + Assets neu gebaut."
}
migrate_env_reverb
ensure_system