feat(email): wire listmonk-hc into deploy + dev override + hc ramp-cap

- deploy.sh/deploy-dev.sh: bring up listmonk-hc (upstream image, excluded from
  build); document the one-time listmonk_hc DB create + --install.
- docker-compose.dev.override.yml: dev-only override (committed) that drops the
  prod host-port bindings and pins dev's own postgres volume (dev-pgdata) via
  compose !override tags. deploy-dev ships it as docker-compose.override.yml so
  syncing the canonical compose to the shared host no longer breaks dev's
  api-postgres (port :5432 clash + volume switch). Discovered + fixed while
  validating listmonk-hc on dev.
- pw-hc-rampcap.sh: healthcare analogue of pw-listmonk-rampcap, ramps the
  listmonk_hc cap 100->1000/h off /etc/postfix/hc-warmup-start, fully
  independent of the trucking ramp/cap.
This commit is contained in:
justin 2026-06-05 19:19:45 -05:00
parent 08d5132459
commit 90d8b94f3f
4 changed files with 82 additions and 5 deletions

View file

@ -8,11 +8,17 @@
set -euo pipefail
cd /opt/performancewest
SERVICES="${@:-site api workers proxy-relay}"
SERVICES="${@:-site api workers proxy-relay listmonk-hc}"
# proxy-relay is an upstream image (no build context). Build everything else,
# but always include it in the `up` set so the healthcare proxy sidecar runs.
BUILD_SERVICES="$(echo "$SERVICES" | tr ' ' '\n' | grep -v '^proxy-relay$' | tr '\n' ' ')"
# proxy-relay and listmonk-hc are upstream images (no build context). Build
# everything else, but always include them in the `up` set so the healthcare
# proxy sidecar and the healthcare-stream Listmonk run.
# NB: listmonk-hc needs a one-time DB setup the first time it is deployed:
# docker compose exec api-postgres psql -U pw -d postgres -c 'CREATE DATABASE listmonk_hc OWNER pw;'
# docker compose run --rm --entrypoint /bin/sh listmonk-hc -c './listmonk --install --idempotent --yes --config /listmonk/config.toml'
# then configure its 3 SMTP servers (hc ports 2526/2527/2528). See
# docs/healthcare-email-stream-plan.md.
BUILD_SERVICES="$(echo "$SERVICES" | tr ' ' '\n' | grep -vE '^(proxy-relay|listmonk-hc)$' | tr '\n' ' ')"
echo "=== Pulling latest from git ==="
git pull origin main

View file

@ -0,0 +1,17 @@
# Dev-only overrides. Dev shares the host with prod and historically used its
# own postgres data volume (dev-pgdata) and no host-port bindings. The canonical
# (prod) compose uses api-pgdata + host ports; pin dev back to its own data and
# drop the clashing host ports. !override replaces base lists.
services:
api-postgres:
ports: !override []
volumes: !override
- dev-pgdata:/var/lib/postgresql/data
listmonk:
ports: !override []
listmonk-hc:
ports: !override []
volumes:
dev-pgdata:
external: true
name: performancewest-dev_dev-pgdata

View file

@ -0,0 +1,46 @@
#!/bin/bash
# Ramp the listmonk-hc hourly send cap in lockstep with the healthcare IP warmup.
#
# The HEALTHCARE HOT stream sends from fresh dedicated IPs (.107/.108/.109), so
# even though institutional B2B mail tolerates far more volume than consumer
# cold mail, we still warm these IPs before hitting the 10k/day ceiling. This is
# the hc analogue of /usr/local/bin/pw-listmonk-rampcap, driven off a SEPARATE
# warmup stamp (/etc/postfix/hc-warmup-start) and writing a SEPARATE Listmonk
# DB (listmonk_hc), so the trucking ramp/cap and the healthcare ramp/cap are
# fully independent.
#
# Steady-state target (institutional, 3 IPs):
# day 0-1 : ~1,000/day -> 100/h
# day 2-4 : ~3,000/day -> 300/h
# day 5-9 : ~6,000/day -> 600/h
# day 10+ : ~10,000/day -> 1000/h (the chosen ceiling)
set -euo pipefail
STATE=/etc/postfix/hc-warmup-start
COMPOSE_DIR=/opt/performancewest
DB=listmonk_hc
PGPASSWORD=${DB_PASSWORD:-pw_dev_2026}
[ -f "$STATE" ] || { echo "no hc warmup stamp ($STATE); run hc_stream_setup.sh first"; exit 1; }
START=$(cat "$STATE"); NOW=$(date +%s); DAYS=$(( (NOW - START) / 86400 ))
if [ "$DAYS" -le 1 ]; then RATE=100
elif [ "$DAYS" -le 4 ]; then RATE=300
elif [ "$DAYS" -le 9 ]; then RATE=600
else RATE=1000; fi
cd "$COMPOSE_DIR"
psql() { PGPASSWORD=$PGPASSWORD docker compose exec -T -e PGPASSWORD=$PGPASSWORD api-postgres \
psql -U pw -d "$DB" -tAc "$1"; }
CUR=$(psql "SELECT value FROM settings WHERE key='app.message_sliding_window_rate';" 2>/dev/null || echo "")
if [ "$CUR" != "$RATE" ]; then
psql "UPDATE settings SET value='$RATE' WHERE key='app.message_sliding_window_rate';
UPDATE settings SET value='\"1h\"' WHERE key='app.message_sliding_window_duration';
UPDATE settings SET value='true' WHERE key='app.message_sliding_window';" >/dev/null
docker compose restart listmonk-hc >/dev/null 2>&1 || true
logger -t pw-hc-rampcap "day $DAYS -> listmonk-hc cap ${RATE}/h (was ${CUR}/h)"
echo "$(date '+%F %T') hc-rampcap: day=$DAYS cap=${RATE}/h (changed from ${CUR}/h, listmonk-hc restarted)"
else
echo "$(date '+%F %T') hc-rampcap: day=$DAYS cap=${RATE}/h (no change)"
fi

View file

@ -60,12 +60,20 @@ done
# and not overwritten.
rsync -avz -e "$SSH" docker-compose.yml "$REMOTE:$DEV_DIR/docker-compose.yml"
# Dev-only compose override: dev shares the host with prod, so it must NOT bind
# the prod host ports (5432/9100/9101/...) and must use its own postgres data
# volume (dev-pgdata). Shipped as docker-compose.override.yml (compose auto-loads
# it). Without this, syncing the canonical compose makes dev's api-postgres try
# to rebind prod's :5432 and switch volumes -> stack breakage. See
# docs/healthcare-email-stream-plan.md.
rsync -avz -e "$SSH" docker-compose.dev.override.yml "$REMOTE:$DEV_DIR/docker-compose.override.yml"
echo ""
echo "=== Rebuilding containers ==="
# proxy-relay is an upstream image (no build context); `up --build` skips the
# build for it but still (re)creates it from the compose definition.
$SSH "$REMOTE" "cd $DEV_DIR && sudo docker compose up -d --build api site workers proxy-relay"
$SSH "$REMOTE" "cd $DEV_DIR && sudo docker compose up -d --build api site workers proxy-relay listmonk-hc"
echo ""
echo "=== Deploy complete ==="