- 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>
110 lines
5.3 KiB
Bash
Executable File
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
|