#!/bin/bash
# alpha-webtune — size the SERVING stack's memory to this box. The sibling of
# alpha-dbtune, which does the same job for the database.
#
# Three problems it fixes, none of which look like a problem until the box is
# out of memory:
#
# 1. PHP-FPM workers NEVER SHRINK. PHP returns freed memory to its own allocator,
#    not to the OS, so a worker's RSS only ever climbs. Debian sets no
#    pm.max_requests, so a worker lives until the service restarts: measured here
#    at 30 MB fresh and 97 MB after 40 hours, on an idle panel. Recycling a
#    worker after N requests is the only thing that bounds it.
#
# 2. pm.max_children is a PROMISE OF RAM THE BOX MAY NOT HAVE. The default 24
#    children × 97 MB is 2.3 GB — on a 6 GB server also running a database, a
#    web server and containers. It is never hit in normal traffic, which is
#    exactly why it goes unnoticed until the one burst that hits it.
#
# 3. EVERY installed PHP version runs a full pool, whether or not any site uses
#    it. Six versions × a master + idle workers, five of them serving nothing.
#    Those pools are NOT disabled here — a version is switched to `ondemand`, so
#    the master stays (the socket exists, a site pointed at it later just works)
#    but no worker exists until a request arrives.
#
# Apache gets the same treatment: MaxRequestWorkers computed instead of the
# stock 150, plus MaxConnectionsPerChild so its children recycle too.
#
#   alpha-webtune                     tune everything, reload what changed
#   alpha-webtune --budget-pct 20     share of RAM PHP-FPM may use (default 25)
#   alpha-webtune --dry-run           print the plan, change nothing
#   alpha-webtune --no-reload         write configs, leave running services alone
#   alpha-webtune --json              machine-readable summary (for the panel)
#
# ⚠️ Every config is validated with the real binary (`php-fpm<v> -t`,
# `apache2ctl configtest`) BEFORE anything is reloaded. A bad pool file does not
# fail at write time, it fails at the next restart — possibly weeks later.
set -eu

BUDGET_PCT=25
APACHE_PCT=8
MAX_REQUESTS=500
DRY=0; RELOAD=1; JSON=0

while [ $# -gt 0 ]; do
  case "$1" in
    --budget-pct) BUDGET_PCT="${2:-25}"; shift 2 ;;
    --budget-pct=*) BUDGET_PCT="${1#*=}"; shift ;;
    --dry-run|-n) DRY=1; shift ;;
    --no-reload) RELOAD=0; shift ;;
    --json) JSON=1; shift ;;
    -h|--help) sed -n '2,36p' "$0"; exit 0 ;;
    *) echo "BAD_ARG: $1" >&2; exit 2 ;;
  esac
done
case "$BUDGET_PCT" in ''|*[!0-9]*) echo "BAD_BUDGET: $BUDGET_PCT" >&2; exit 2 ;; esac
[ "$BUDGET_PCT" -ge 5 ] && [ "$BUDGET_PCT" -le 80 ] || { echo "BAD_BUDGET: $BUDGET_PCT (5-80)" >&2; exit 2; }

num() { case "${1:-}" in ''|*[!0-9]*) echo "${2:-0}" ;; *) echo "$1" ;; esac; }
RAM_MB=$(( $(num "$(awk '/^MemTotal:/{print $2}' /proc/meminfo)" 1048576) / 1024 ))
BUDGET_MB=$(( RAM_MB * BUDGET_PCT / 100 ))

CHANGED_POOLS=""; RELOAD_UNITS=""; NOTES=""
PLAN=""

# ---- which versions actually serve a live vhost? ----------------------------
# ENABLED vhosts only. sites-available is full of templates and disabled sites,
# and counting those would keep a pool warm for a site nobody can reach.
# ⚠️ grep -R (not -r): sites-enabled is entirely symlinks and -r ignores them.
vhosts_using() { # <version> → count of enabled vhosts pointing at that socket
  grep -Rl "php$1-fpm.sock" \
    /etc/nginx/sites-enabled/ /etc/nginx/snippets/ /etc/apache2/sites-enabled/ \
    2>/dev/null | wc -l
}

# PEAK RSS of a version's live workers, in MB — deliberately not the average.
# max_children is a memory CEILING, so it has to be divided by the worst worker
# the box has actually produced, not the typical one. Averaging in the small
# master process biases the figure down, which raises max_children, which is
# precisely the wrong direction for a limit whose job is to stop an OOM.
#
# ⚠️ Floored at 64 MB, and that floor matters: once pm.max_requests recycles the
# workers, the measured peak DROPS, which would raise max_children on the next
# run, which lets workers bloat again — the ceiling would oscillate instead of
# converging. The floor is what a worker realistically reaches before it is
# recycled, so the answer is stable across runs.
PEAK_FLOOR_MB=64
peak_worker_mb() { # <version>
  local v="$1" out
  out="$(ps -o rss= -C "php-fpm$v" 2>/dev/null | sort -rn | head -1 | awk '{printf "%d", $1/1024}')"
  case "$out" in ''|0|*[!0-9]*) out=$PEAK_FLOOR_MB ;; esac
  [ "$out" -lt "$PEAK_FLOOR_MB" ] && out=$PEAK_FLOOR_MB
  echo "$out"
}

# set_directive <file> <key> <value> — replace in place, uncommenting if needed,
# append if absent. Returns 0 when it changed something, 1 when already correct,
# so a re-run does not mark the pool dirty and bounce a healthy service.
#
# ⚠️ BOTH of these honour $DRY. They did not at first, and a --dry-run happily
# rewrote every pool file while printing "nothing written" — then the real run
# found the files already correct, decided nothing had changed, and skipped the
# reload. A dry run that edits the thing it is previewing is worse than no dry
# run at all, because it is trusted.
set_directive() {
  local f="$1" k="$2" val="$3" cur ke
  ke="$(printf '%s' "$k" | sed 's/\./\\./g')"
  cur="$(grep -E "^[[:space:]]*$ke[[:space:]]*=" "$f" 2>/dev/null | head -1 | sed 's/.*=[[:space:]]*//')"
  [ "$cur" = "$val" ] && return 1
  [ "$DRY" = 1 ] && return 0
  if grep -qE "^[[:space:]]*;?[[:space:]]*$ke[[:space:]]*=" "$f" 2>/dev/null; then
    sed -i -E "s|^[[:space:]]*;?[[:space:]]*$ke[[:space:]]*=.*|$k = $val|" "$f"
  else
    printf '%s = %s\n' "$k" "$val" >> "$f"
  fi
  return 0
}
# comment_directive <file> <key> — for settings a pm mode ignores; php-fpm warns
# about them on every start and the warning trains people to ignore the log.
comment_directive() {
  local f="$1" k="$2" ke
  ke="$(printf '%s' "$k" | sed 's/\./\\./g')"
  grep -qE "^[[:space:]]*$ke[[:space:]]*=" "$f" 2>/dev/null || return 1
  [ "$DRY" = 1 ] && return 0
  sed -i -E "s|^([[:space:]]*)($ke[[:space:]]*=.*)|\1; \2  ; ignored in this pm mode (alpha-webtune)|" "$f"
  return 0
}

# Has THIS EXACT config content been loaded into the running service?
#
# Config being correct on disk is not the same as it being in effect: a file
# edited by hand, by the panel, or by an interrupted run leaves the service
# running the old numbers with nothing on disk to show for it.
#
# ⚠️ The obvious test — "is the file newer than the service?" — does NOT work.
# `systemctl reload` does not update ActiveEnterTimestamp (that records when the
# unit went active, and a reload is not a state change), so the file stays newer
# forever and the helper reloads PHP-FPM on every single run. A content stamp
# written after the reload is queued answers the actual question.
STAMP_DIR=/var/lib/alpha-webtune
stamp_matches() { # <file> <tag>
  local want have
  want="$(md5sum "$1" 2>/dev/null | awk '{print $1}')"
  have="$(cat "$STAMP_DIR/$2" 2>/dev/null || true)"
  [ -n "$want" ] && [ "$want" = "$have" ]
}
stamp_write() { # <file> <tag>
  mkdir -p "$STAMP_DIR" 2>/dev/null || return 0
  md5sum "$1" 2>/dev/null | awk '{print $1}' > "$STAMP_DIR/$2" 2>/dev/null || true
}

# hysteresis <computed> <current> — keep the CURRENT value when the newly
# computed one is within 15 % of it.
#
# Without this the helper oscillates: worker RSS is sampled live, so a peak of
# 95 MB one minute and 97 MB the next flips max_children between 14 and 13,
# every run rewrites the pool, and every provisioning pass bounces PHP-FPM for a
# difference that means nothing. An auto-tuner that never settles is worse than
# a wrong constant, because it also causes downtime.
hysteresis() {
  local new="$1" cur="${2:-}" delta
  case "$cur" in ''|*[!0-9]*) echo "$new"; return ;; esac
  [ "$cur" -le 0 ] && { echo "$new"; return; }
  delta=$(( (new - cur) * 100 / cur )); [ "$delta" -lt 0 ] && delta=$(( -delta ))
  if [ "$delta" -le 15 ]; then echo "$cur"; else echo "$new"; fi
}
current_of() { # <file> <key>
  local ke; ke="$(printf '%s' "$2" | sed 's/\./\\./g')"
  grep -E "^[[:space:]]*$ke[[:space:]]*=" "$1" 2>/dev/null | head -1 | sed 's/.*=[[:space:]]*//'
}

# ---- weight the budget by how much each version actually serves -------------
TOTAL_VHOSTS=0; ACTIVE_VERSIONS=""
for d in /etc/php/*/fpm/pool.d; do
  [ -f "$d/www.conf" ] || continue
  v="$(printf '%s' "$d" | sed -nE 's#.*/php/([0-9.]+)/.*#\1#p')"; [ -n "$v" ] || continue
  n="$(vhosts_using "$v")"
  if [ "$n" -gt 0 ]; then ACTIVE_VERSIONS="$ACTIVE_VERSIONS $v:$n"; TOTAL_VHOSTS=$(( TOTAL_VHOSTS + n )); fi
done
[ "$TOTAL_VHOSTS" -eq 0 ] && TOTAL_VHOSTS=1

# ---- tune each pool ---------------------------------------------------------
for d in /etc/php/*/fpm/pool.d; do
  [ -f "$d/www.conf" ] || continue
  f="$d/www.conf"
  v="$(printf '%s' "$d" | sed -nE 's#.*/php/([0-9.]+)/.*#\1#p')"; [ -n "$v" ] || continue
  n="$(vhosts_using "$v")"
  peak="$(peak_worker_mb "$v")"
  dirty=0

  if [ "$n" -gt 0 ]; then
    # In use: a real dynamic pool, ceilinged by its weighted share of the budget.
    share=$(( BUDGET_MB * n / TOTAL_VHOSTS ))
    mc=$(( share / peak )); [ "$mc" -lt 4 ] && mc=4; [ "$mc" -gt 128 ] && mc=128
    mc="$(hysteresis "$mc" "$(current_of "$f" pm.max_children)")"
    minsp=$(( mc / 8 )); [ "$minsp" -lt 2 ] && minsp=2
    maxsp=$(( mc / 4 )); [ "$maxsp" -lt 4 ] && maxsp=4
    [ "$maxsp" -le "$minsp" ] && maxsp=$(( minsp + 2 ))
    start=$(( minsp + (maxsp - minsp) / 2 ))
    MODE=dynamic
    set_directive "$f" "pm" "dynamic"                 && dirty=1
    set_directive "$f" "pm.max_children" "$mc"        && dirty=1
    set_directive "$f" "pm.start_servers" "$start"    && dirty=1
    set_directive "$f" "pm.min_spare_servers" "$minsp" && dirty=1
    set_directive "$f" "pm.max_spare_servers" "$maxsp" && dirty=1
    set_directive "$f" "pm.max_requests" "$MAX_REQUESTS" && dirty=1
    PLAN="$PLAN
  php$v  dynamic   max_children=$mc (${n} vhost(s), ${peak} MB peak worker, share ${share} MB)  spare ${minsp}-${maxsp}  recycle@${MAX_REQUESTS}  worst ≈$(( mc * peak )) MB"
  else
    # Unused: ondemand. Master only until something asks for it — which is the
    # difference between "installed" and "costing RAM".
    mc=6
    MODE=ondemand
    set_directive "$f" "pm" "ondemand"                       && dirty=1
    set_directive "$f" "pm.max_children" "$mc"               && dirty=1
    set_directive "$f" "pm.process_idle_timeout" "10s"       && dirty=1
    set_directive "$f" "pm.max_requests" "$MAX_REQUESTS"     && dirty=1
    comment_directive "$f" "pm.start_servers"     && dirty=1
    comment_directive "$f" "pm.min_spare_servers" && dirty=1
    comment_directive "$f" "pm.max_spare_servers" && dirty=1
    PLAN="$PLAN
  php$v  ondemand  no vhost uses it — master only, workers spawn on demand (idle 10s)"
  fi

  # Reload when we changed the file, or when the content on disk has never been
  # stamped as loaded (hand edit, panel edit, interrupted run).
  if [ "$dirty" = 1 ]; then
    CHANGED_POOLS="$CHANGED_POOLS php$v"
    RELOAD_UNITS="$RELOAD_UNITS php$v-fpm"
  elif [ "$DRY" = 0 ] && ! stamp_matches "$f" "php$v"; then
    RELOAD_UNITS="$RELOAD_UNITS php$v-fpm"
    NOTES="$NOTES; php$v config was on disk but not loaded — reloading"
  fi
done

# ---- Apache: only if it is actually installed -------------------------------
APACHE_PLAN=""; APACHE_CONF=/etc/apache2/conf-available/alpha-mpm.conf
APACHE_CHANGED=0; MODPHP_DROPPED=""
if [ -d /etc/apache2 ] && command -v apache2ctl >/dev/null 2>&1; then
  a_avg="$(ps -o rss= -C apache2 2>/dev/null | awk '{s+=$1;n++} END{if(n>1) printf "%d", s/n/1024}')"
  case "$a_avg" in ''|0|*[!0-9]*) a_avg=18 ;; esac
  a_budget=$(( RAM_MB * APACHE_PCT / 100 ))
  a_max=$(( a_budget / a_avg )); [ "$a_max" -lt 8 ] && a_max=8; [ "$a_max" -gt 256 ] && a_max=256
  # Apache directives are space-separated, not `key = value`, so current_of
  # (which expects an ini) cannot read this one.
  a_max="$(hysteresis "$a_max" "$(grep -E '^[[:space:]]*MaxRequestWorkers' "$APACHE_CONF" 2>/dev/null | head -1 | awk '{print $2}')")"
  a_minsp=2; a_maxsp=$(( a_max / 4 )); [ "$a_maxsp" -lt 4 ] && a_maxsp=4
  a_start=$(( a_minsp + (a_maxsp - a_minsp) / 2 ))

  TMPA="$(mktemp)"
  cat > "$TMPA" <<APACHECONF
# Alpha Panel — Apache MPM sizing. GENERATED by alpha-webtune; do not hand-edit.
#
# Loaded from conf-enabled, which Apache reads AFTER mods-enabled, so these win
# over the stock mpm_prefork.conf without editing a distro-owned file.
#
# Stock MaxRequestWorkers is 150. At the ${a_avg} MB per child measured on this
# box that is $(( 150 * a_avg )) MB of promise — on a server with ${RAM_MB} MB total, also
# running a database and PHP-FPM. The figure below is ${APACHE_PCT}% of RAM divided by
# the real child size.
<IfModule mpm_prefork_module>
    StartServers            ${a_start}
    MinSpareServers         ${a_minsp}
    MaxSpareServers         ${a_maxsp}
    MaxRequestWorkers       ${a_max}
    # Recycle children. Apache children accumulate the same way PHP workers do
    # when a loaded module leaks; 0 (the default) means never.
    MaxConnectionsPerChild  1000
</IfModule>
<IfModule mpm_event_module>
    StartServers            2
    MinSpareThreads         25
    MaxSpareThreads         75
    ThreadsPerChild         25
    MaxRequestWorkers       ${a_max}
    MaxConnectionsPerChild  1000
</IfModule>
APACHECONF
  if [ ! -f "$APACHE_CONF" ] || ! cmp -s "$TMPA" "$APACHE_CONF"; then
    [ "$DRY" = 1 ] || install -o root -g root -m 0644 "$TMPA" "$APACHE_CONF"
    APACHE_CHANGED=1
  fi
  rm -f "$TMPA"
  # Same "written but never loaded" case as the pools.
  if [ "$APACHE_CHANGED" = 0 ] && [ "$DRY" = 0 ] && [ -f "$APACHE_CONF" ] && ! stamp_matches "$APACHE_CONF" apache2; then
    APACHE_CHANGED=1
    NOTES="$NOTES; apache config was on disk but not loaded — reloading"
  fi
  APACHE_PLAN="  apache2  MaxRequestWorkers=${a_max} (was $(grep -hE '^\s*MaxRequestWorkers' /etc/apache2/mods-enabled/mpm_*.conf 2>/dev/null | head -1 | awk '{print $2}' || echo '?')), spare ${a_minsp}-${a_maxsp}, recycle@1000, ${a_avg} MB/child"

  # mod_php inside prefork puts a whole PHP interpreter in EVERY child. If the
  # vhosts route PHP to FPM (SetHandler proxy:…fcgi) the module is pure weight.
  # Only drop it when nothing can possibly need it — php_value/php_flag in a
  # vhost or .htaccess, or a mod_php handler, all count as "needed".
  # ⚠️ Anchor the version pattern: mods-enabled holds `php8.4.conf` and
  # `php8.4.load`, so a greedy [0-9.]+ swallows the dot before the extension and
  # yields "php8.4." — a module name a2dismod does not recognise, which fails
  # silently and leaves the interpreter loaded in every child.
  MODPHP="$(ls /etc/apache2/mods-enabled/ 2>/dev/null | grep -oE '^php[0-9]+\.[0-9]+' | head -1 || true)"
  if [ -n "$MODPHP" ]; then
    need=0
    grep -RqE '^[^#]*(php_value|php_flag|php_admin_value|php_admin_flag)' \
      /etc/apache2/sites-enabled/ /etc/apache2/conf-enabled/ 2>/dev/null && need=1
    # ^[^#]* on both patterns: the stock conf-enabled files are full of COMMENTED
    # SetHandler/AddHandler examples, and matching those would keep a useless
    # interpreter loaded forever on the strength of documentation.
    grep -RqE '^[^#]*(SetHandler[[:space:]]+application/x-httpd-php|AddHandler[[:space:]]+[^[:space:]]*php)' \
      /etc/apache2/sites-enabled/ /etc/apache2/conf-enabled/ 2>/dev/null && need=1
    # .htaccess under any enabled DocumentRoot may carry php_value too.
    for root in $(grep -RhoE '^[[:space:]]*DocumentRoot[[:space:]]+\S+' /etc/apache2/sites-enabled/ 2>/dev/null | awk '{print $2}' | tr -d '"'); do
      [ -d "$root" ] || continue
      grep -rqE '(php_value|php_flag|php_admin_value|php_admin_flag)' "$root" --include=.htaccess 2>/dev/null && need=1
    done
    if [ "$need" = 0 ]; then
      MODPHP_DROPPED="$MODPHP"
      APACHE_PLAN="$APACHE_PLAN
  apache2  disable $MODPHP — every vhost routes PHP to FPM and nothing uses php_value/php_flag
           (a whole PHP interpreter was loaded into all $(pgrep -c apache2 2>/dev/null || echo '?') children; reversible with a2enmod $MODPHP)"
      if [ "$DRY" = 0 ]; then a2dismod -f "$MODPHP" >/dev/null 2>&1 && APACHE_CHANGED=1 || MODPHP_DROPPED=""; fi
    else
      NOTES="$NOTES; kept $MODPHP (a vhost or .htaccess uses php_value/php_flag)"
    fi
  fi
fi

# ---- report / apply ---------------------------------------------------------
HEAD="RAM ${RAM_MB} MB · PHP-FPM budget ${BUDGET_MB} MB (${BUDGET_PCT}%)"
if [ "$DRY" = 1 ]; then
  echo "PLAN: $HEAD"
  printf '%s\n' "$PLAN"
  [ -n "$APACHE_PLAN" ] && printf '%s\n' "$APACHE_PLAN"
  [ -n "$NOTES" ] && echo "  notes:$NOTES"
  echo "  (dry run — nothing written, nothing reloaded)"
  exit 0
fi

# ---- validate every changed config with the real binary ---------------------
BAD=""
for u in $RELOAD_UNITS; do
  v="${u#php}"; v="${v%-fpm}"
  if command -v "php-fpm$v" >/dev/null 2>&1; then
    if ! "php-fpm$v" -t >/dev/null 2>&1; then BAD="$BAD php$v"; fi
  fi
done
if [ -n "$BAD" ]; then
  echo "INVALID_FPM_CONFIG:$BAD — not reloading" >&2
  for v in $BAD; do "php-fpm${v#php}" -t 2>&1 | tail -3 >&2; done
  exit 4
fi
if [ "$APACHE_CHANGED" = 1 ]; then
  a2enconf alpha-mpm >/dev/null 2>&1 || true
  if ! apache2ctl configtest >/dev/null 2>&1; then
    echo "INVALID_APACHE_CONFIG — reverting alpha-mpm.conf" >&2
    apache2ctl configtest 2>&1 | tail -3 >&2
    a2disconf alpha-mpm >/dev/null 2>&1 || true
    [ -n "$MODPHP_DROPPED" ] && a2enmod "$MODPHP_DROPPED" >/dev/null 2>&1 || true
    exit 5
  fi
fi

# ---- reload ------------------------------------------------------------------
# ⚠️ Detached and delayed: this script is reachable from the panel, so reloading
# the panel's own php-fpm inline would drop the response that reports success.
RELOADED=""
if [ "$RELOAD" = 1 ] && [ -n "$RELOAD_UNITS$( [ "$APACHE_CHANGED" = 1 ] && echo x )" ]; then
  UNITS="$RELOAD_UNITS"
  [ "$APACHE_CHANGED" = 1 ] && UNITS="$UNITS apache2"
  RELOADED="$UNITS"
  setsid bash -c "sleep 1; for s in $UNITS; do systemctl reload \$s 2>/dev/null || systemctl restart \$s 2>/dev/null; done" \
    >/dev/null 2>&1 </dev/null &
  # Record what is now going into effect, so the next run knows this exact
  # content is loaded and does not reload again for nothing.
  for u in $UNITS; do
    case "$u" in
      apache2) stamp_write "$APACHE_CONF" apache2 ;;
      php*-fpm) pv="${u#php}"; pv="${pv%-fpm}"; stamp_write "/etc/php/$pv/fpm/pool.d/www.conf" "php$pv" ;;
    esac
  done
fi

if [ "$JSON" = 1 ]; then
  printf '{"ok":true,"ram_mb":%d,"budget_pct":%d,"budget_mb":%d,"pools_changed":"%s","reloaded":"%s","modphp_dropped":"%s"}\n' \
    "$RAM_MB" "$BUDGET_PCT" "$BUDGET_MB" \
    "$(echo "$CHANGED_POOLS" | sed 's/^ *//')" "$(echo "$RELOADED" | sed 's/^ *//')" "$MODPHP_DROPPED"
else
  echo "OK: $HEAD"
  printf '%s\n' "$PLAN"
  [ -n "$APACHE_PLAN" ] && printf '%s\n' "$APACHE_PLAN"
  [ -n "$NOTES" ] && echo "  notes:$NOTES"
  if [ -n "$RELOADED" ]; then echo "  reloading (detached, 1s):$RELOADED"
  else echo "  nothing to reload — already tuned"; fi
fi
