quorlasocial/deploy/provision-tenants.sh
metatroncubeswdev 7c596ec6d5 Settings on the site instead of .env; Odoo in Connections
- Server settings (Settings page): SMTP, AI, publishing, Meta, platform keys and,
  on the control centre only, Stripe/billing. Stored sealed in data/settings.json and
  applied over .env in every process; hosted agency workspaces don't see our email,
  platform keys or billing.
- Odoo connection per workspace in Connections (login checked before saving).
- provision-tenants.sh marks agency containers with QUORLA_HOSTED_TENANT=true.
- docs/wildcard-subdomains.html: one-time setup for automatic agency subdomains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:49:15 -04:00

110 lines
5.3 KiB
Bash
Executable File

#!/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 -e QUORLA_HOSTED_TENANT=true \
-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