Files
sapl/docker-compose.uat.yml
T
Hermes Agent 9b26094a9a promo(uat): wire RL + scraper-lane env + MAIL_DRY_RUN into uat compose
- docker-compose.uat.yml: map the 11 keys dev compose gained since the Oct-16 promo (TREG_TOKEN, GOOGLE_VISION_API_KEY, REVERSE_LOOKUP_PUBLIC_BASE_URL, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN, SCRAPER_PROXY_URL, SCRAPER_PROXY_BRANDS, BD_SCRAPE_WSS, BD_SCRAPER_WSS, BRIGHTDATA_API_KEY, BD_UNLOCKER_ZONE) into shared x-backend-env (backend + worker).

- MAIL_DRY_RUN mapped with safe default true (was unmapped; backend silently fell back to code default False).

- .env.example: document the new keys (empty values only). Values come from host .env.uat (gitignored), filled pre-deploy.
2026-10-23 11:55:49 +05:30

259 lines
12 KiB
YAML

# SAPL Garment Intelligence — UAT Docker Compose
# Same services as production but with:
# - UAT-specific volume (sapl_uat_data)
# - UAT-specific subdomain for Traefik
# - UAT-specific env vars
# - UAT-specific Docker Compose project name
# - PostgreSQL database (postgres service) instead of SQLite
#
# Deploy: auto-deployed via Gitea Actions on push to uat branch
# Access: https://uat.ai-automation-hub.tech
#
# Security model (reviewed 2026-08-03):
# * NO secret values are committed. Credentials (incl. DATABASE_URL and
# POSTGRES_PASSWORD) are injected at deploy time via ${VAR:?...}
# (required) or ${VAR:-} (optional, empty) — from Dokploy environment
# variables, or `--env-file .env.uat` for local/CI deploys. A missing
# required secret fails `docker compose` loudly instead of silently
# deploying with a hardcoded value.
# * Networking: only `frontend` attaches to the external dokploy-network
# (Traefik). `backend`, `worker` and `postgres` stay on the private
# project network — never directly reachable from Traefik or the
# internet. No host ports are published; nginx proxies /api etc. to the
# backend over the private network.
#
# Background sync loops (Stitch/WFX/Gmail) run in the dedicated `worker`
# service (backend/sync_worker.py), NOT in the web backend. The web backend
# sets RUN_SYNC_LOOPS=false so gunicorn can safely scale to multiple workers
# without duplicating sync loops. Local dev (uvicorn --reload) still runs
# loops in-process via the config default RUN_SYNC_LOOPS=true.
# Shared environment for the Python services (backend web + worker).
# Anchored so both services see the exact same configuration.
x-backend-env: &backend-env
# ── Database — must point at the `postgres` service below with
# matching POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB. ──
DATABASE_URL: "${DATABASE_URL:?DATABASE_URL is required (postgresql URL for the postgres service)}"
# ── Paths / non-secret config ──
CHROMADB_PATH: "/app/data/chromadb"
IMAGES_PATH: "/app/data/images"
CORS_ORIGINS: "https://uat.ai-automation-hub.tech"
QR_BASE_URL: "https://uat.ai-automation-hub.tech"
# Google client ID is a PUBLIC identifier (exposed to the browser via the
# "Sign in with Google" button) — safe to commit.
GOOGLE_CLIENT_ID: "800260541735-6o4rechdhoufgjann9scs57gi8a73fas.apps.googleusercontent.com"
GOOGLE_ALLOWED_DOMAIN: "saplind.com"
# Web Push (issue #524): optional - empty disables push gracefully.
VAPID_PRIVATE_KEY: "${VAPID_PRIVATE_KEY:-}"
VAPID_PUBLIC_KEY: "${VAPID_PUBLIC_KEY:-}"
VAPID_SUBJECT: "${VAPID_SUBJECT:-}"
# ── Release notes / "What's new" notifications ──
# Token the deploy workflow presents as X-Release-Token. EMPTY = publishing
# DISABLED (the endpoint answers 503) — set it in .env.uat.
RELEASE_PUBLISH_TOKEN: "${RELEASE_PUBLISH_TOKEN:-}"
# Which tier's release notes this deployment serves. Must be explicit: an
# unset value falls back to "dev" and would label these releases dev.
SAPL_TIER: "${SAPL_TIER:-uat}"
# ── Required secrets — no defaults (Dokploy env or --env-file .env.uat) ──
# Auth token signing secret; the app's built-in dev default is forgeable.
SECRET_KEY: "${SECRET_KEY:?SECRET_KEY is required (Dokploy env or .env.uat)}"
MISTRAL_API_KEY: "${MISTRAL_API_KEY:?MISTRAL_API_KEY is required}"
STITCH_USERNAME: "${STITCH_USERNAME:?STITCH_USERNAME is required}"
STITCH_PASSWORD: "${STITCH_PASSWORD:?STITCH_PASSWORD is required}"
STITCH_API_KEY: "${STITCH_API_KEY:-}"
WFX_USERNAME: "${WFX_USERNAME:?WFX_USERNAME is required}"
WFX_PASSWORD: "${WFX_PASSWORD:?WFX_PASSWORD is required}"
WFX_Token_ID: "${WFX_Token_ID:-}"
WFX_Token_Secret: "${WFX_Token_Secret:-}"
Mail_Auth_User: "${Mail_Auth_User:?Mail_Auth_User is required}"
Mail_App_Password: "${Mail_App_Password:-}"
# Send-as alias for ALL outbound email (trigger_mail From header). UAT uses
# the BOM alias so recipients see bom@saplind.com, never the SMTP auth account.
Mail_From: "${Mail_From:?Mail_From is required (BOM send-as alias, e.g. bom@saplind.com)}"
# UAT never sends real SMTP mail: dry-run logs instead of delivering.
# Belt-and-braces alongside the allowlist (unmapped before promo 20261023,
# which silently fell back to the code default False).
MAIL_DRY_RUN: "${MAIL_DRY_RUN:-true}"
# ── Optional secrets — empty default (inject only if the feature is used) ──
# Backend treats these as optional: Groq/Gemini are AI-provider fallbacks,
# Claude is not read by backend/config.py, and the Google client secret is
# not used by the ID-token SSO flow.
GROQ_API_KEY: "${GROQ_API_KEY:-}"
CLAUDE_API_KEY: "${CLAUDE_API_KEY:-}"
GEMINI_API_KEY: "${GEMINI_API_KEY:-}"
# Reverse Lookup S2+S3 (#1525): Lens lookup via treg + Vision fallback,
# then PDP trace + extract via Cloudflare Browser Rendering.
# Empty = that half fails/skips cleanly with the setup message (never mock rows).
TREG_TOKEN: "${TREG_TOKEN:-}"
GOOGLE_VISION_API_KEY: "${GOOGLE_VISION_API_KEY:-}"
REVERSE_LOOKUP_PUBLIC_BASE_URL: "${REVERSE_LOOKUP_PUBLIC_BASE_URL:-}"
CLOUDFLARE_ACCOUNT_ID: "${CLOUDFLARE_ACCOUNT_ID:-}"
CLOUDFLARE_API_TOKEN: "${CLOUDFLARE_API_TOKEN:-}"
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}"
# OpenCode Go — per-feature model routing (moodboard → deepseek-v4-flash,
# scraper → minimax-m3). Without this key, route() degrades to Gemini.
OPENCODE_GO_API_KEY: "${OPENCODE_GO_API_KEY:-}"
# UAT email anti-spam: only these mailboxes receive SMTP mail from UAT.
# Production compose does NOT set this — prod sends to all recipients.
MAIL_ALLOWLIST: "shridhar.sabat@saplind.com,ajay.sable@saplind.com,ajay@sonalapparels.in,dhvani.jagasia@saplind.com,pratik@sonalapparels.in"
# Optional JSON override of per-feature model routes (see services/llm/routing.py).
FEATURE_MODEL_ROUTES: "${FEATURE_MODEL_ROUTES:-}"
# Scraper egress proxy (Bright Data; refs #1780) — empty = direct, walled
# brands fail-soft with a clear skip reason instead of burning renders.
SCRAPER_PROXY_URL: "${SCRAPER_PROXY_URL:-}"
SCRAPER_PROXY_BRANDS: "${SCRAPER_PROXY_BRANDS:-}"
# Scraping-browser lane (H&M): Playwright connect_over_cdp wss endpoint.
# Primary + alias, first non-empty wins; empty = lane fail-soft skips.
BD_SCRAPE_WSS: "${BD_SCRAPE_WSS:-}"
BD_SCRAPER_WSS: "${BD_SCRAPER_WSS:-}"
# Bright Data Web Unlocker lane (Sezane + other DataDome-walled brands) —
# optional; empty = the lane skips with a clear reason. Token + zone are
# injected at deploy time (.env.uat, never committed).
BRIGHTDATA_API_KEY: "${BRIGHTDATA_API_KEY:-}"
BD_UNLOCKER_ZONE: "${BD_UNLOCKER_ZONE:-}"
# UAT-only: skip the built-in Fabric Library seed at boot (frozen-clean UAT)
FABRIC_SEED_DISABLED: "1"
# ── Google Workspace Directory API (org chart) — optional; empty = feature off ──
# GOOGLE_DIRECTORY_SA_JSON accepts a file path, inline JSON, or base64-encoded
# service-account key (see backend/services/google_directory_service.py).
# Never commit a real key value here.
GOOGLE_DIRECTORY_SA_JSON: "${GOOGLE_DIRECTORY_SA_JSON:-}"
GOOGLE_DIRECTORY_IMPERSONATED_ADMIN: "${GOOGLE_DIRECTORY_IMPERSONATED_ADMIN:-}"
# Org-chart-driven approval escalation — dev/uat validation
ORG_CHART_ENABLED: "${ORG_CHART_ENABLED:-false}"
# Google Calendar mirror (org calendars) — empty = falls back to
# GOOGLE_DIRECTORY_SA_JSON above; the same DWD client ID needs the
# calendar.readonly scope in Admin console for live data.
GOOGLE_CALENDAR_SA_JSON: "${GOOGLE_CALENDAR_SA_JSON:-}"
GOOGLE_CALENDAR_SYNC_INTERVAL_MINUTES: "${GOOGLE_CALENDAR_SYNC_INTERVAL_MINUTES:-30}"
# ── Non-secret, overridable ──
STITCH_API_HOST: "${STITCH_API_HOST:-https://portal2.stitcherp.com/}"
WFX_COMPANYID: "${WFX_COMPANYID:-sapl}"
WFX_Token_Name: "${WFX_Token_Name:-API Acces}"
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
depends_on:
postgres:
condition: service_healthy
environment:
<<: *backend-env
# Web container: HTTP only. The sync loops run in the `worker` service.
RUN_SYNC_LOOPS: "false"
volumes:
- sapl_uat_data:/app/data
expose:
- "8000"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=5)"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
restart: unless-stopped
deploy:
resources:
limits:
cpus: '1.0'
# Background integration syncs (Stitch TNA 2-min, WFX mirror 30s, Gmail
# IMAP IDLE + 2-min poll, TNA template bootstrap). Runs sync_worker.py —
# a long-lived process, no HTTP. RUN_SYNC_LOOPS stays unset → config
# default true, so the loops start HERE, exactly once.
worker:
build:
context: ./backend
dockerfile: Dockerfile
command: ["python", "sync_worker.py"]
depends_on:
postgres:
condition: service_healthy
environment:
<<: *backend-env
volumes:
- sapl_uat_data:/app/data
restart: unless-stopped
deploy:
resources:
limits:
cpus: '0.75'
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
VITE_APP_VERSION: "${VITE_APP_VERSION:-uat-${GIT_SHA:-dev}}"
depends_on:
backend:
condition: service_healthy
restart: unless-stopped
labels:
# HTTP → HTTPS redirect (DNS: uat.ai-automation-hub.tech)
- traefik.http.routers.sapl-uat-web.rule=Host(`uat.ai-automation-hub.tech`)
- traefik.http.routers.sapl-uat-web.entrypoints=web
- traefik.http.services.sapl-uat-web.loadbalancer.server.port=80
- traefik.http.routers.sapl-uat-web.service=sapl-uat-web
- traefik.http.routers.sapl-uat-web.middlewares=redirect-to-https@file
# HTTPS — custom domain with Let's Encrypt
- traefik.http.routers.sapl-uat-websecure.rule=Host(`uat.ai-automation-hub.tech`)
- traefik.http.routers.sapl-uat-websecure.entrypoints=websecure
- traefik.http.services.sapl-uat-websecure.loadbalancer.server.port=80
- traefik.http.routers.sapl-uat-websecure.service=sapl-uat-websecure
- traefik.http.routers.sapl-uat-websecure.tls.certresolver=letsencrypt
- traefik.enable=true
- traefik.docker.network=dokploy-network
networks:
- dokploy-network
- default
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
deploy:
resources:
limits:
cpus: '0.25'
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: "${POSTGRES_USER:-sapl}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
POSTGRES_DB: "${POSTGRES_DB:-sapl}"
healthcheck:
# $$ escapes compose interpolation so the shell expands the container's
# own POSTGRES_USER/POSTGRES_DB at runtime.
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
volumes:
- sapl_uat_postgres_data:/var/lib/postgresql/data
restart: unless-stopped
deploy:
resources:
limits:
cpus: '0.75'
volumes:
# Explicit, project-independent names so local/CI and Dokploy share the same
# UAT data volumes (app data + postgres). Distinct from prod's sapl_data.
sapl_uat_data:
name: sapl_uat_data
sapl_uat_postgres_data:
name: sapl_uat_postgres_data
networks:
# Frontend-only attachment to Dokploy's Traefik network. backend, worker and
# postgres are intentionally NOT listed here — they stay on the private
# `default` network.
dokploy-network:
external: true