Files
esh-pfi-infrastructure/scripts/add-digest-user.sh
T
vh d552e289cb news-digest: per-instance cron schedule via env
Hardcoded crontab → render at container start from
DIGEST_CRON_AM + DIGEST_CRON_PM. Defaults match the original
0800 / 2000 so existing deploys are no-ops.

scripts/add-digest-user.sh learns --am and --pm flags so each
teammate's stack can fire on their hours:

  scripts/add-digest-user.sh bob --am "0 6 * * *" --pm "0 17 * * *"
  scripts/add-digest-user.sh carol --pm "30 18 * * 1-5"   # weekdays only

Standard 5-field cron syntax; busybox crond honors the container's
\$TZ. Removed the now-unused stacks/news-digest/crontab file and
the matching COPY in the Dockerfile.
2026-04-28 14:29:55 -07:00

215 lines
8.4 KiB
Bash
Executable File

#!/usr/bin/env bash
# add-digest-user.sh — provision a per-user news-digest stack.
#
# Multi-tenant onboarding for the daily-digest applet. One miniflux
# instance, multiple miniflux users, one news-digest stack per user
# (own port, own output dir, own hide-state).
#
# Usage:
# scripts/add-digest-user.sh <username> # generate password
# scripts/add-digest-user.sh <username> <pass> # set explicit password
#
# What it does:
# 1. Reads miniflux admin creds from ana-docker:/opt/docker/compose/miniflux/.env
# 2. Allocates the next free NEWS_DIGEST_PORT above 8181
# 3. Creates a miniflux user via the admin API
# 4. Creates per-user dirs on ana-docker (sudo prompt expected once)
# 5. Materializes a per-user .env at /opt/docker/compose/digest-<user>/
# 6. Brings up the per-user stack (`docker compose -p digest-<user> up -d`)
# 7. Seeds default world/local/tech feeds in the new user's miniflux account
#
# Idempotent-ish: re-running for an existing user re-syncs config + feeds
# but won't recreate the miniflux user (409 from /v1/users is non-fatal).
set -euo pipefail
usage() {
cat <<EOF
usage: $0 <username> [password] [--am "MIN HR * * *"] [--pm "MIN HR * * *"]
Defaults: AM = "0 8 * * *", PM = "0 20 * * *". Cron syntax is
standard 5-field; busybox crond honors the container's \$TZ.
Examples:
$0 alice
$0 bob --am "0 6 * * *" --pm "0 17 * * *"
$0 carol s3cret --pm "30 18 * * 1-5" # weekdays only PM run
EOF
exit 1
}
USER_ARG=""
USER_PASS=""
CRON_AM="0 8 * * *"
CRON_PM="0 20 * * *"
while [ "$#" -gt 0 ]; do
case "$1" in
--am) CRON_AM="$2"; shift 2 ;;
--pm) CRON_PM="$2"; shift 2 ;;
--help|-h) usage ;;
--*) echo "unknown flag: $1" >&2; usage ;;
*)
if [ -z "$USER_ARG" ]; then
USER_ARG="$1"
elif [ -z "$USER_PASS" ]; then
USER_PASS="$1"
else
echo "unexpected positional arg: $1" >&2; usage
fi
shift
;;
esac
done
[ -n "$USER_ARG" ] || usage
HOST=ana-docker
WORKSTATION_STACK="$(dirname "$0")/../stacks/news-digest"
PROJECT="digest-$USER_ARG"
HOST_COMPOSE_DIR="/opt/docker/compose/$PROJECT"
HOST_DATA_DIR="/opt/docker/data/$PROJECT"
PORT_BASE=8181
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
info() { printf ' %s\n' "$*"; }
bold "→ provisioning news-digest for user '$USER_ARG'"
# 1. Pull miniflux admin creds from host
info "reading miniflux admin creds from $HOST"
admin_creds=$(ssh "$HOST" 'grep -E "^MINIFLUX_ADMIN_(USERNAME|PASSWORD)=" /opt/docker/compose/miniflux/.env')
admin_user=$(awk -F= '/^MINIFLUX_ADMIN_USERNAME=/ {sub(/^MINIFLUX_ADMIN_USERNAME=/, ""); print}' <<<"$admin_creds")
admin_pass=$(awk -F= '/^MINIFLUX_ADMIN_PASSWORD=/ {sub(/^MINIFLUX_ADMIN_PASSWORD=/, ""); print}' <<<"$admin_creds")
[ -n "$admin_user" ] && [ -n "$admin_pass" ] || { echo "FATAL: could not read miniflux admin creds" >&2; exit 1; }
# 2. Allocate next free port. Use `find` so the glob doesn't blow up
# when there are zero per-user digest-*/.env files yet.
info "scanning for used digest ports..."
used_ports=$(ssh "$HOST" 'find /opt/docker/compose -maxdepth 2 -mindepth 2 -name .env \( -path "*/news-digest/*" -o -path "*/digest-*/*" \) -exec grep -h "^NEWS_DIGEST_PORT=" {} + 2>/dev/null | cut -d= -f2 | sort -un' || true)
new_port=$PORT_BASE
while echo "$used_ports" | grep -qx "$new_port"; do
new_port=$((new_port + 1))
done
info "allocated port: $new_port (in use: ${used_ports//$'\n'/, })"
# 3. Generate password if not provided
if [ -z "$USER_PASS" ]; then
USER_PASS=$(openssl rand -base64 18 | tr -d '/+=')
fi
info "user password: $USER_PASS"
# 4. Create miniflux user via admin API (skip silently if 409)
info "creating miniflux user '$USER_ARG'..."
http_code=$(ssh "$HOST" "curl -s -o /dev/null -w '%{http_code}' \
-u '$admin_user:$admin_pass' \
-H 'Content-Type: application/json' \
-d '{\"username\":\"$USER_ARG\",\"password\":\"$USER_PASS\",\"is_admin\":false}' \
http://10.250.50.70:8080/v1/users")
case "$http_code" in
201) info " created" ;;
400|409) info " already exists (HTTP $http_code) — keeping existing user, password reset NOT performed" ;;
*) echo "FATAL: miniflux /v1/users returned HTTP $http_code" >&2; exit 1 ;;
esac
# 5. Provision per-user dirs. Sudo on the host needs a TTY for the
# password prompt; if we're being run non-interactively (piped, in
# a script), check whether the dirs already exist and bail with a
# manual command if they don't.
info "checking host dirs..."
if ssh "$HOST" "[ -w '$HOST_COMPOSE_DIR' ] && [ -w '$HOST_DATA_DIR' ]" 2>/dev/null; then
info " exist + writable, skipping sudo step"
elif [ -t 0 ] && [ -t 1 ]; then
info " creating (sudo prompt incoming)..."
ssh -t "$HOST" "sudo mkdir -p $HOST_COMPOSE_DIR $HOST_DATA_DIR && \
sudo chown -R lkraven:lkraven $HOST_COMPOSE_DIR $HOST_DATA_DIR"
else
cat >&2 <<EOF
Run this on your terminal first (this script can't drive sudo without a TTY):
ssh -t $HOST "sudo mkdir -p $HOST_COMPOSE_DIR $HOST_DATA_DIR && sudo chown -R lkraven:lkraven $HOST_COMPOSE_DIR $HOST_DATA_DIR"
Then re-run: scripts/add-digest-user.sh $USER_ARG $USER_PASS
EOF
exit 1
fi
# 6. Sync compose + materialize .env
info "syncing compose.yaml..."
scp -q "$WORKSTATION_STACK/compose.yaml" "$HOST:$HOST_COMPOSE_DIR/compose.yaml"
# Also need the build context (Dockerfile, *.py, templates/) so the
# image can build if it's not already cached. Use the existing tree.
scp -q "$WORKSTATION_STACK/Dockerfile" "$HOST:$HOST_COMPOSE_DIR/Dockerfile"
scp -q "$WORKSTATION_STACK"/{digest.py,seed-headlines.py,web.py,entrypoint.sh,run-digest.sh} "$HOST:$HOST_COMPOSE_DIR/"
ssh "$HOST" "mkdir -p $HOST_COMPOSE_DIR/templates"
scp -q "$WORKSTATION_STACK"/templates/* "$HOST:$HOST_COMPOSE_DIR/templates/"
info "materializing .env..."
# Inherit the image tag from the canonical news-digest stack so all
# per-user instances use the same built image (otherwise we'd accidentally
# pin to whatever was current when this script was last edited).
canonical_tag=$(ssh "$HOST" 'awk -F= "/^NEWS_DIGEST_TAG=/ {print \$2}" /opt/docker/compose/news-digest/.env' || echo "v1")
info "using image tag: $canonical_tag (inherited from canonical)"
ssh "$HOST" "cat > $HOST_COMPOSE_DIR/.env" <<EOF
# Auto-generated by scripts/add-digest-user.sh on $(date -Iseconds)
# User: $USER_ARG
NEWS_DIGEST_TAG=$canonical_tag
DIGEST_PROJECT=$PROJECT
DIGEST_HOMEPAGE_NAME=$USER_ARG's Digest
DIGEST_HOMEPAGE_DESC=Personal LLM-curated brief for $USER_ARG
NEWS_DIGEST_PORT=$new_port
NEWS_DIGEST_BIND=0.0.0.0
NEWS_DIGEST_TZ=America/Los_Angeles
LLAMA_SWAP_URL=http://10.250.50.54:9292
LLAMA_SWAP_MODEL=granite-4-small
LLAMA_SWAP_TIMEOUT=180
MINIFLUX_URL=http://miniflux:8080
MINIFLUX_USER=$USER_ARG
MINIFLUX_PASSWORD=$USER_PASS
DIGEST_REDDIT_HOURS=12
DIGEST_MIN_SCORE=50
DIGEST_MIN_RATIO=0.85
DIGEST_MAX_PER_SUB=8
DIGEST_MINIFLUX_HOURS=12
DIGEST_MINIFLUX_MAX=8
DIGEST_MINIFLUX_TECH_CATEGORY=Tech aggregators
DIGEST_MINIFLUX_WORLD_CATEGORY=World
DIGEST_MINIFLUX_LOCAL_CATEGORY=Local
DIGEST_MINIFLUX_HEADLINES_HOURS=8
DIGEST_MINIFLUX_HEADLINES_MAX=15
DIGEST_CRON_AM=$CRON_AM
DIGEST_CRON_PM=$CRON_PM
NEWS_DIGEST_OUTPUT_DIR=$HOST_DATA_DIR
EOF
# 7. Bring up the per-user stack
info "bringing stack up (compose project: $PROJECT)..."
ssh "$HOST" "cd $HOST_COMPOSE_DIR && docker compose -p $PROJECT up -d 2>&1 | sed 's/^/ /'"
# 8. Wait for worker to be alive then seed feeds
info "waiting for worker to be ready..."
for i in $(seq 1 30); do
if ssh "$HOST" "docker exec $PROJECT-worker test -f /app/seed-headlines.py" 2>/dev/null; then
break
fi
sleep 2
done
info "seeding default feeds in miniflux for $USER_ARG..."
ssh "$HOST" "docker exec $PROJECT-worker python3 /app/seed-headlines.py 2>&1 | sed 's/^/ /'"
# 9. Trigger first digest run so the page isn't blank
info "triggering first digest run (this can take ~90s)..."
ssh "$HOST" "docker exec $PROJECT-worker /usr/local/bin/run-digest.sh 2>&1 | tail -3 | sed 's/^/ /'" || true
bold ""
bold "✓ provisioned digest for $USER_ARG"
echo
echo " digest URL : http://10.250.50.70:$new_port/"
echo " miniflux UI : http://10.250.50.70:8080/ (login: $USER_ARG / $USER_PASS)"
echo " schedule : AM '$CRON_AM' / PM '$CRON_PM' (TZ from \$NEWS_DIGEST_TZ)"
echo " compose dir : $HOST:$HOST_COMPOSE_DIR/"
echo " output dir : $HOST:$HOST_DATA_DIR/"
echo
echo " hand the URL + miniflux creds to the user; they can manage their"
echo " feed subscriptions via the miniflux UI."