#!/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 } fix_owner() { # dir: the app runs as "node" (uid 1000) inside the image; hand it the folders it writes (no sudo needed) [ "$(stat -c %u "$1/data")" = "1000" ] && return 0 docker run --rm --user 0 --entrypoint chown -v "$1:/t" "$IMAGE" -R 1000:1000 /t/data /t/clients /t/team /t/reports /t/branding } 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" fix_owner "$dir" 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