diff --git a/installer.sh b/installer.sh index a1aed81..af52972 100644 --- a/installer.sh +++ b/installer.sh @@ -787,9 +787,13 @@ nginx -t && systemctl reload nginx HELPER chmod 755 /usr/local/sbin/mailwolt-apply-domains -# ===== Sudoers für www-data (helper) ===== +# ===== mailwolt-update installieren ===== +install -m 755 "${APP_DIR}/update.sh" /usr/local/sbin/mailwolt-update + +# ===== Sudoers für www-data (helper + update) ===== cat > /etc/sudoers.d/mailwolt-certbot <<'SUDOERS' www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-apply-domains +www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-update SUDOERS chmod 440 /etc/sudoers.d/mailwolt-certbot diff --git a/update.sh b/update.sh new file mode 100644 index 0000000..58e5cca --- /dev/null +++ b/update.sh @@ -0,0 +1,289 @@ +#!/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}" + +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 --retry=10 --render=errors.503" 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_dirty_check(){ + if [[ "$ALLOW_DIRTY" != "1" ]]; then + local dirty + dirty="$(as_app "git -C ${APP_DIR} status --porcelain")" + if [[ -n "$dirty" ]]; then + echo "[!] Arbeitsbaum hat uncommitted Änderungen. Abbruch (ALLOW_DIRTY=1 zum Überschreiben)." + exit 2 + fi + 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." +} + +# -------- 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" + +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 ''")" + 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" + 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" + 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 + +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 "[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)" + +# 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}" + 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})" \ No newline at end of file