d552e289cb
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.
215 lines
8.4 KiB
Bash
Executable File
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."
|