quorlasocial/deploy/provision-tenants.sh
metatroncubeswdev 075b79f6b2 Hosted Quorla: sign-up, Stripe billing, one workspace per agency
- Control centre (QUORLA_CONTROL=true on app.quorlasocial.com): public /signup → Stripe Checkout,
  verified Stripe webhook (paid, updated, cancelled, payment failed), Admin → Agencies
  (approve without Stripe, suspend, resume, billing portal, resend welcome, remove)
- deploy/provision-tenants.sh (cron): one container per agency with its own .env and data,
  nginx map for <agency>.quorlasocial.com, upgrades agencies after a rebuild, archives removed data
- New workspaces: first admin only via the emailed one-time setup link (SETUP_TOKEN)
- Meta system user token can be saved in Settings (encrypted) for workspaces that can't edit .env
- docs/multi-tenant.md: DNS, wildcard cert, nginx, sudoers, cron, Stripe setup; tests for the control centre

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:35:23 -04:00

104 lines
5.0 KiB
Bash

#!/usr/bin/env bash
# Hosted Quorla: create / start / stop / archive one container per agency, as the control centre asks.
# Runs on the server as the deploy user (needs docker; sudo only for reloading nginx). Schedule every 5 minutes:
# */5 * * * * /home/dev/APPS/quorlasocial/deploy/provision-tenants.sh >> /home/dev/APPS/quorla-tenants/provision.log 2>&1
# Settings come from deploy/provision.env (copy provision.env.example). See docs/multi-tenant.md.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
REPO="$(dirname "$HERE")"
# shellcheck disable=SC1091
source "$HERE/provision.env"
: "${CONTROL_URL:?set CONTROL_URL in provision.env}" "${PROVISION_TOKEN:?set PROVISION_TOKEN}" "${TENANTS_DIR:?set TENANTS_DIR}"
IMAGE="${IMAGE:-quorla:latest}"
BASE_DOMAIN="${TENANT_BASE_DOMAIN:-quorlasocial.com}"
SHARED_ENV="${SHARED_ENV:-$HERE/tenant-shared.env}" # SMTP_*, optional ANTHROPIC_API_KEY, platform app keys
MAP="$TENANTS_DIR/nginx-tenants.map"
mkdir -p "$TENANTS_DIR/_archive"
log() { echo "$(date -u +%FT%TZ) $*"; }
json() { python3 -c "import json,sys; d=json.load(sys.stdin); $1"; }
report() { # slug json
curl -fsS -m 30 -X POST -H "Authorization: Bearer $PROVISION_TOKEN" -H "Content-Type: application/json" \
--data "$2" "$CONTROL_URL/control/provision/$1/report" >/dev/null || log "report failed for $1"
}
write_env() { # slug name setup_token
local dir="$TENANTS_DIR/$1"
[ -f "$dir/.env" ] && return 0
umask 077
{
echo "DASHBOARD_SECRET=$(openssl rand -hex 32)"
echo "AGENCY_NAME=$2"
echo "PUBLIC_URL=https://$1.$BASE_DOMAIN"
echo "APP_DOMAIN=$1.$BASE_DOMAIN"
echo "SETUP_TOKEN=$3"
echo "ALLOW_PUBLISH=true"
echo "SCHEDULER_ENABLED=true"
[ -f "$SHARED_ENV" ] && grep -E '^[A-Z0-9_]+=' "$SHARED_ENV"
} > "$dir/.env"
}
run_container() { # slug port
local slug="$1" port="$2" dir="$TENANTS_DIR/$1"
docker run -d --name "quorla-$slug" --restart unless-stopped \
-p "127.0.0.1:$port:4310" --env-file "$dir/.env" \
-e DASHBOARD_HOST=0.0.0.0 -e DASHBOARD_COOKIE_SECURE=true -e TRUST_PROXY=uniquelocal \
-v "$dir/data:/app/data" -v "$dir/clients:/app/clients" -v "$dir/team:/app/team" \
-v "$dir/reports:/app/reports" -v "$dir/branding:/app/branding" -v "$dir/backups:/app/backups:ro" \
--label quorla.tenant="$slug" --label quorla.port="$port" "$IMAGE" >/dev/null
}
wait_healthy() { # port
for _ in $(seq 1 30); do curl -fsS -m 3 "http://127.0.0.1:$1/api/auth/me" >/dev/null 2>&1 && return 0; sleep 2; done
return 1
}
rebuild_map() {
local tmp; tmp="$(mktemp)"
docker ps --filter label=quorla.tenant --format '{{.Label "quorla.tenant"}} {{.Label "quorla.port"}}' \
| while read -r slug port; do echo "$slug.$BASE_DOMAIN 127.0.0.1:$port;"; done | sort > "$tmp"
if ! cmp -s "$tmp" "$MAP" 2>/dev/null; then
mv "$tmp" "$MAP"
if sudo -n nginx -t >/dev/null 2>&1; then sudo -n systemctl reload nginx && log "nginx reloaded"; else log "nginx -t failed or sudo not allowed; map updated but not reloaded"; fi
else rm -f "$tmp"; fi
}
tasks="$(curl -fsS -m 30 -H "Authorization: Bearer $PROVISION_TOKEN" "$CONTROL_URL/control/provision/tasks")"
echo "$tasks" | json 'print("\n".join("\t".join([t["slug"], t["desired"], str(t.get("port") or ""), t.get("agencyName") or "", t.get("setupToken") or ""]) for t in d["tasks"]))' |
while IFS=$'\t' read -r slug desired port name token; do
[ -z "$slug" ] && continue
dir="$TENANTS_DIR/$slug"
case "$desired" in
running)
mkdir -p "$dir"/{data,clients,team,reports,backups}
[ -d "$dir/branding" ] || cp -r "$REPO/branding" "$dir/branding"
write_env "$slug" "$name" "$token"
if docker inspect "quorla-$slug" >/dev/null 2>&1; then docker start "quorla-$slug" >/dev/null; else run_container "$slug" "$port"; fi
if wait_healthy "$port"; then log "$slug running on $port"; report "$slug" '{"actual":"running"}';
else log "$slug did not start"; report "$slug" "{\"error\":\"container did not become healthy on port $port\"}"; fi
;;
stopped)
docker stop "quorla-$slug" >/dev/null 2>&1 || true
log "$slug stopped"; report "$slug" '{"actual":"stopped"}'
;;
removed)
docker rm -f "quorla-$slug" >/dev/null 2>&1 || true
if [ -d "$dir" ]; then tar -czf "$TENANTS_DIR/_archive/$slug-$(date +%Y%m%d-%H%M).tar.gz" -C "$dir" . && rm -rf "$dir"; fi
log "$slug removed (data archived)"; report "$slug" '{"actual":"removed"}'
;;
esac
done
# Upgrade: after `docker compose up -d --build` in the main app, move every agency onto the new image.
current="$(docker image inspect -f '{{.Id}}' "$IMAGE")"
docker ps -a --filter label=quorla.tenant --format '{{.Names}} {{.Image}} {{.Label "quorla.tenant"}} {{.Label "quorla.port"}} {{.State}}' |
while read -r cname _ slug port state; do
used="$(docker inspect -f '{{.Image}}' "$cname")"
[ "$used" = "$current" ] && continue
[ "$state" = "running" ] || continue
docker rm -f "$cname" >/dev/null && run_container "$slug" "$port" && wait_healthy "$port" && log "$slug upgraded" || log "$slug upgrade FAILED"
done
rebuild_map