← back to Cncp Mcp

scripts/apply-pm2-caps.sh

210 lines

#!/usr/bin/env bash
# apply-pm2-caps.sh — generate ecosystem.config.cjs + apply memory caps for any
# pm2 proc that's currently uncapped. Per-project, idempotent, dry-run default.
#
# Why: Steve has 21+ pm2 procs without ecosystem.config files → no
# max_memory_restart → any leaking proc drags Mac2 load to 500+.
#
# Usage:
#   bash ~/Projects/cncp-mcp/scripts/apply-pm2-caps.sh              # dry-run (default)
#   bash ~/Projects/cncp-mcp/scripts/apply-pm2-caps.sh --write      # write ecosystem.config.cjs files only
#   bash ~/Projects/cncp-mcp/scripts/apply-pm2-caps.sh --cycle      # write + delete/restart each proc serially
#
# What it does:
#   --write : creates ecosystem.config.cjs in each proc's cwd (only if missing)
#             with the proc's current script + name + port env + cap. Doesn't restart.
#   --cycle : --write, then for each proc: pm2 delete → pm2 start ecosystem.config.cjs
#             with a 2s gap between procs to avoid load spike. pm2 save at end.
#
# Safety:
#   - Never overwrites an existing ecosystem.config.{js,cjs}.
#   - Default cap is 300M for sister-sites; can override via SISTER_CAP env.
#   - Skips procs whose script path can't be resolved.
#   - Logs every action to /tmp/pm2-caps-<ts>.log.

set -u

MODE="dry"
for arg in "$@"; do
  case "$arg" in
    --write) MODE="write" ;;
    --cycle) MODE="cycle" ;;
    --dry-run) MODE="dry" ;;
    *) echo "Unknown arg: $arg" >&2; exit 2 ;;
  esac
done

SISTER_CAP="${SISTER_CAP:-300M}"
LLM_CAP="${LLM_CAP:-1G}"
PM2_BIN="${PM2_BIN:-$HOME/.npm-global/bin/pm2}"
[[ -x "$PM2_BIN" ]] || PM2_BIN="$(/usr/bin/which pm2 2>/dev/null || echo pm2)"
TS="$(date +%Y%m%d-%H%M%S)"
LOG="/tmp/pm2-caps-${TS}.log"

log() { echo "[$(date -u +%H:%M:%S)] $*" | tee -a "$LOG"; }

log "mode=$MODE  sister-cap=$SISTER_CAP  llm-cap=$LLM_CAP"

# pm2 jlist contains ALL env vars including real API keys/secrets the shell had
# at proc-start time. NEVER write it to a long-lived path. Use a temp file and
# trap-unlink on exit so it's nuked even if the script crashes.
JLIST_TMP="$(mktemp -t pm2-jlist.XXXXXX)"
trap 'rm -f "$JLIST_TMP"' EXIT
"$PM2_BIN" jlist 2>/dev/null > "$JLIST_TMP" || {
  log "ERROR: pm2 jlist failed"
  exit 3
}

CAPS_MODE="$MODE" \
CAPS_SISTER="$SISTER_CAP" \
CAPS_LLM="$LLM_CAP" \
CAPS_LOG="$LOG" \
CAPS_PM2="$PM2_BIN" \
CAPS_JLIST="$JLIST_TMP" \
/usr/bin/python3 <<'PYEOF'
import json, os, sys, subprocess, time

MODE = os.environ["CAPS_MODE"]
SISTER_CAP = os.environ["CAPS_SISTER"]
LLM_CAP = os.environ["CAPS_LLM"]
LOG = os.environ["CAPS_LOG"]
PM2 = os.environ["CAPS_PM2"]
JLIST = os.environ["CAPS_JLIST"]

def logmsg(s):
    line = f"[{time.strftime('%H:%M:%S', time.gmtime())}] {s}"
    print(line)
    with open(LOG, 'a') as f: f.write(line + "\n")

procs = json.load(open(JLIST))

# Identify candidates: no max_memory_restart, has a valid pm_cwd + pm_exec_path
todo = []
for p in procs:
    env = p.get('pm2_env', {})
    name = p.get('name', '?')
    if env.get('max_memory_restart'): continue
    pm_cwd = env.get('pm_cwd', '') or ''
    pm_exec_path = env.get('pm_exec_path', '') or ''
    if not pm_cwd or not pm_exec_path:
        logmsg(f"SKIP {name}: missing cwd or script path")
        continue
    if not os.path.isfile(pm_exec_path):
        logmsg(f"SKIP {name}: script path doesn't exist: {pm_exec_path}")
        continue
    # Try to extract PORT from env
    port = env.get('PORT') or env.get('port') or ''
    # Decide cap: LLM agents get more, sister-sites less
    name_low = name.lower()
    is_llm = any(k in name_low for k in ('agent', 'gpt', 'llm', 'codex', 'claude', 'orchestrator', 'factory', 'debrief'))
    cap = LLM_CAP if is_llm else SISTER_CAP
    todo.append({'name': name, 'cwd': pm_cwd, 'script': pm_exec_path, 'port': port, 'cap': cap})

logmsg(f"candidates: {len(todo)}")
for t in todo:
    logmsg(f"  {t['name']:<32} cwd={t['cwd']} script={os.path.basename(t['script'])} cap={t['cap']}")

if MODE == "dry":
    logmsg("DRY-RUN — no files written, no pm2 changes. Re-run with --write or --cycle.")
    sys.exit(0)

# --write : create ecosystem.config.cjs in each cwd if missing
created = 0
already = 0
for t in todo:
    eco_cjs = os.path.join(t['cwd'], 'ecosystem.config.cjs')
    eco_js = os.path.join(t['cwd'], 'ecosystem.config.js')
    if os.path.isfile(eco_cjs) or os.path.isfile(eco_js):
        logmsg(f"already-has-ecosystem {t['name']}")
        already += 1
        continue
    # Build the script body — STRICT WHITELIST env approach.
    # We do NOT capture arbitrary env vars from pm2_env because that pool
    # contains real secrets (API keys, DB passwords) inherited from the shell
    # that started pm2. Writing those into ecosystem.config.cjs and committing
    # to git is a security leak. Instead: capture ONLY known-safe keys.
    proc = [p for p in procs if p.get('name') == t['name']][0]
    full_env = proc.get('pm2_env', {})

    SAFE_KEYS = {'PORT', 'port', 'NODE_ENV', 'HOST', 'BIND', 'HOSTNAME',
                 'FLASK_RUN_HOST', 'UVICORN_HOST', 'GUNICORN_BIND', 'VITE_HOST',
                 'NEXT_TELEMETRY_DISABLED'}
    # Any *_URL is allowed only if it doesn't contain '@' (which signals creds in URL)
    SECRET_PATTERNS = ('_KEY', '_SECRET', '_TOKEN', '_PASSWORD', '_PASS',
                       '_AUTH', '_CREDENTIAL', 'API_', 'PRIVATE_', 'COOKIE',
                       'SESSION_SECRET', 'JWT')

    user_env = {}
    for k, v in full_env.items():
        if not isinstance(v, (str, int, bool)): continue
        sv = str(v)
        if len(sv) > 500: continue
        # Direct allowlist
        if k in SAFE_KEYS:
            user_env[k] = v
            continue
        # *_URL allowed only if no embedded creds
        if k.endswith('_URL') and '@' not in sv:
            # Also exclude obviously-secret URL keys
            if any(p in k.upper() for p in SECRET_PATTERNS): continue
            user_env[k] = v
            continue
        # Everything else: SKIP. Secrets, pm2 internals, macOS env, claude-session
        # state, etc. all stay out of the ecosystem file.
        continue

    env_lines = []
    for k, v in user_env.items():
        if isinstance(v, bool):
            env_lines.append(f"      {k}: {str(v).lower()},")
        elif isinstance(v, int):
            env_lines.append(f"      {k}: {v},")
        else:
            esc = str(v).replace("\\", "\\\\").replace('"', '\\"')
            env_lines.append(f'      {k}: "{esc}",')
    env_block = "\n".join(env_lines) + "\n" if env_lines else ""
    script_basename = os.path.basename(t['script'])
    content = f"""module.exports = {{
  apps: [{{
    name: "{t['name']}",
    script: "{script_basename}",
    cwd: __dirname,
    env: {{
{env_block}    }},
    autorestart: true,
    max_memory_restart: "{t['cap']}",
  }}],
}};
"""
    try:
        with open(eco_cjs, 'w') as f: f.write(content)
        logmsg(f"WROTE {eco_cjs}")
        created += 1
    except Exception as e:
        logmsg(f"WRITE-FAIL {t['name']}: {e}")

logmsg(f"--write done: {created} created, {already} already had one.")

if MODE == "cycle":
    logmsg("--cycle: delete + restart each, 2s gap")
    for t in todo:
        eco = os.path.join(t['cwd'], 'ecosystem.config.cjs')
        if not os.path.isfile(eco):
            logmsg(f"  skip {t['name']}: no ecosystem.config.cjs")
            continue
        logmsg(f"  cycle {t['name']}")
        subprocess.run([PM2, "delete", t['name']], capture_output=True)
        r = subprocess.run([PM2, "start", eco], capture_output=True, text=True)
        if r.returncode != 0:
            logmsg(f"    START FAIL: {r.stderr[:200]}")
        else:
            logmsg(f"    started")
        time.sleep(2)
    logmsg("running pm2 save")
    subprocess.run([PM2, "save"], capture_output=True)
    logmsg("--cycle done.")
PYEOF

echo ""
echo "Log: $LOG"