All checks were successful
Build and deploy / Validate source (push) Successful in 8s
Build and deploy / Integration suite on a real stack (push) Successful in 3m38s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m2s
A backup service runs pg_dump every day at 03:00 Brasília, checks the archive, encrypts it with age to a public key and uploads it with a token for that bucket only. The server cannot read or delete backups: the private key stays with the owner, the bucket's lifecycle rule expires copies and its lock stops early deletion. Each run is recorded and shown on the Kanban's Integrations tab. tests/backup_test.py backs up, restores into a scratch database and compares the rows in CI. Setup and restore: docs/BACKUP.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
282 lines
12 KiB
YAML
282 lines
12 KiB
YAML
version: "3.8"
|
|
|
|
x-app-environment: &app-environment
|
|
APP_ENV: production
|
|
DATABASE_HOST: db
|
|
DATABASE_NAME: dtf
|
|
DATABASE_USER: dtf_app
|
|
# Must differ from POSTGRES_PASSWORD: the runtime role is DML-only, and reusing
|
|
# the administrator credential would make that restriction meaningless.
|
|
DATABASE_PASSWORD: ${APP_DB_PASSWORD:?set APP_DB_PASSWORD}
|
|
S3_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
|
|
S3_PUBLIC_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
|
|
S3_BUCKET: ${R2_BUCKET:?set R2_BUCKET}
|
|
AWS_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:?set R2_ACCESS_KEY_ID}
|
|
AWS_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:?set R2_SECRET_ACCESS_KEY}
|
|
AWS_DEFAULT_REGION: auto
|
|
# Optional at deploy time so a missing Kanban credential cannot make the
|
|
# entire Swarm stack invalid. The Kanban login endpoint fails closed until
|
|
# this value is configured.
|
|
OPERATOR_EMAIL: ${OPERATOR_EMAIL:-}
|
|
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD}
|
|
# fake until Mercado Pago is configured; mercadopago requires MP_ACCESS_TOKEN
|
|
# and MP_WEBHOOK_SECRET or the API and worker refuse to start. Test
|
|
# credentials (TEST-...) until the sandbox flows have passed. The card form
|
|
# appears only with MP_PUBLIC_KEY, and then needs PAYMENT_CSP_SOURCES too.
|
|
PAYMENT_ADAPTER: ${PAYMENT_ADAPTER:-fake}
|
|
MP_ACCESS_TOKEN: ${MP_ACCESS_TOKEN:-}
|
|
# The "assinatura secreta" from the webhook settings in Mercado Pago.
|
|
MP_WEBHOOK_SECRET: ${MP_WEBHOOK_SECRET:-}
|
|
# https://<SITE_DOMAIN>/api/payments/webhook, sent with every payment.
|
|
MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-}
|
|
MP_PUBLIC_KEY: ${MP_PUBLIC_KEY:-}
|
|
# The fake adapter's secret. Optional: without it the webhook verifies
|
|
# nothing and therefore accepts nothing, which is the correct state until a
|
|
# provider is connected. Never set it to a value anyone could guess.
|
|
PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-}
|
|
# fake offers pickup only. jadlog prices delivery with Jadlog and needs the
|
|
# credentials below and the package weight from the client; it refuses to
|
|
# start without them. The credentials alone are enough for the console
|
|
# check, python -m app.jadlog_probe, on the worker.
|
|
FREIGHT_ADAPTER: ${FREIGHT_ADAPTER:-fake}
|
|
JADLOG_TOKEN: ${JADLOG_TOKEN:-}
|
|
# The "Usuário" Jadlog issued: the CNPJ that contracts the freight.
|
|
JADLOG_CNPJ: ${JADLOG_CNPJ:-}
|
|
JADLOG_CONTA: ${JADLOG_CONTA:-}
|
|
JADLOG_CONTRATO: ${JADLOG_CONTRATO:-}
|
|
# Package weight in kg: a base plus each billed metre of film.
|
|
JADLOG_PESO_BASE_KG: ${JADLOG_PESO_BASE_KG:-}
|
|
JADLOG_PESO_POR_METRO_KG: ${JADLOG_PESO_POR_METRO_KG:-}
|
|
# Working days of production added to Jadlog's delivery time.
|
|
FREIGHT_PRODUCTION_DAYS: ${FREIGHT_PRODUCTION_DAYS:-0}
|
|
# A cart the Site priced is approved at checkout and can be paid at once;
|
|
# orders above QUOTE_AUTO_MAX_METRES, or with a grade the Site did not
|
|
# compute, wait for an operator on the Kanban (app/quote_review.py).
|
|
QUOTE_AUTO_APPROVE: ${QUOTE_AUTO_APPROVE:-true}
|
|
QUOTE_AUTO_MAX_METRES: ${QUOTE_AUTO_MAX_METRES:-50}
|
|
# Order creation in Tiny stays off until it has been tested against the
|
|
# client's account (Tiny has no sandbox). The application credentials can be
|
|
# set now: they let an operator connect Tiny from the Kanban, and the worker
|
|
# keeps that connection alive. Callback: https://<KANBAN_DOMAIN>/api/operator/tiny/callback
|
|
TINY_ADAPTER: fake
|
|
TINY_CLIENT_ID: ${TINY_CLIENT_ID:-}
|
|
TINY_CLIENT_SECRET: ${TINY_CLIENT_SECRET:-}
|
|
TINY_REDIRECT_URI: ${TINY_REDIRECT_URI:-}
|
|
TINY_PRODUCT_TEXTIL_FOLHA: ${TINY_PRODUCT_TEXTIL_FOLHA:-}
|
|
TINY_PRODUCT_TEXTIL_AVULSA: ${TINY_PRODUCT_TEXTIL_AVULSA:-}
|
|
TINY_PRODUCT_UV_FOLHA: ${TINY_PRODUCT_UV_FOLHA:-}
|
|
TINY_PRODUCT_UV_AVULSA: ${TINY_PRODUCT_UV_AVULSA:-}
|
|
# The client's "retirar pessoalmente" forma de envio id, on pickup orders.
|
|
TINY_FORMA_ENVIO_RETIRADA: ${TINY_FORMA_ENVIO_RETIRADA:-}
|
|
# Sets "Aprovada" on paid orders and "Pronto para envio" on finished pickup
|
|
# orders, which the client's Tiny -> n8n notices send to customers. Turn on
|
|
# only together with TINY_ADAPTER=tiny and after n8n stops sending the
|
|
# designer message for DTFIMP products.
|
|
TINY_STATUS_UPDATES: ${TINY_STATUS_UPDATES:-false}
|
|
WHATSAPP_ADAPTER: fake
|
|
STORAGE_ADAPTER: s3-r2
|
|
PUBLIC_ORIGIN: https://${SITE_DOMAIN:?set SITE_DOMAIN}
|
|
PUBLIC_HOST: ${SITE_DOMAIN:?set SITE_DOMAIN}
|
|
ALLOWED_HOSTS: ${SITE_DOMAIN:?set SITE_DOMAIN},${KANBAN_DOMAIN:?set KANBAN_DOMAIN}
|
|
ALLOWED_ORIGINS: https://${SITE_DOMAIN:?set SITE_DOMAIN},https://${KANBAN_DOMAIN:?set KANBAN_DOMAIN}
|
|
COOKIE_SECURE: "true"
|
|
MAX_UPLOAD_BYTES: "5368709120"
|
|
UPLOAD_PART_BYTES: "8388608"
|
|
# Sheets of several GB are the normal order, so room for many of them.
|
|
STORAGE_QUOTA_BYTES: "${STORAGE_QUOTA_BYTES:-536870912000}"
|
|
OWNER_UPLOAD_QUOTA_BYTES: "${OWNER_UPLOAD_QUOTA_BYTES:-53687091200}"
|
|
MAX_PENDING_UPLOADS: "10"
|
|
# ClamAV scans up to this; larger files (up to MAX_UPLOAD_BYTES) are released
|
|
# after a file-format check instead (app/scanning.py).
|
|
SCAN_MAX_BYTES: "2097152000"
|
|
|
|
services:
|
|
db:
|
|
image: postgres:17-alpine
|
|
environment:
|
|
POSTGRES_DB: dtf
|
|
POSTGRES_USER: dtf_admin
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
|
volumes: [postgres-data:/var/lib/postgresql/data]
|
|
networks: [backend]
|
|
healthcheck:
|
|
test: [CMD-SHELL, 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"']
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 12
|
|
start_period: 20s
|
|
deploy:
|
|
replicas: 1
|
|
restart_policy: {condition: on-failure, delay: 10s}
|
|
|
|
db-init:
|
|
image: ${API_IMAGE:-gitea.blyzer.com.br/blyzer/dtf-api}:${IMAGE_TAG:-latest}
|
|
command: python -m app.bootstrap
|
|
environment:
|
|
DATABASE_ADMIN_HOST: db
|
|
DATABASE_ADMIN_NAME: dtf
|
|
DATABASE_ADMIN_USER: dtf_admin
|
|
DATABASE_ADMIN_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
|
APP_DB_USER: dtf_app
|
|
APP_DB_PASSWORD: ${APP_DB_PASSWORD:?set APP_DB_PASSWORD}
|
|
# The migration job seeds the first operator account from these, so an
|
|
# existing deployment keeps its Kanban login after the accounts table
|
|
# lands. Without them there would be no account at all and login would
|
|
# fail closed with 503.
|
|
OPERATOR_EMAIL: ${OPERATOR_EMAIL:-}
|
|
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD}
|
|
networks: [backend]
|
|
deploy:
|
|
replicas: 1
|
|
restart_policy: {condition: on-failure, delay: 5s, max_attempts: 20}
|
|
|
|
scanner:
|
|
image: clamav/clamav@sha256:9cb27d7660bdf66e9878c832cb433dd8aa152cfbe16f3c2c0084c80b04ae22b4
|
|
entrypoint: [clamd, --foreground=true, --config-file=/etc/clamav/clamd.conf]
|
|
configs:
|
|
- source: clamd_config_2gb
|
|
target: /etc/clamav/clamd.conf
|
|
mode: 0444
|
|
networks: [backend]
|
|
healthcheck:
|
|
test: [CMD, clamdscan, --config-file=/etc/clamav/clamd.conf, --ping, "3"]
|
|
interval: 15s
|
|
timeout: 5s
|
|
retries: 20
|
|
start_period: 90s
|
|
deploy:
|
|
replicas: 1
|
|
restart_policy: {condition: on-failure, delay: 10s}
|
|
resources:
|
|
limits: {memory: 3G}
|
|
|
|
api:
|
|
image: ${API_IMAGE:-gitea.blyzer.com.br/blyzer/dtf-api}:${IMAGE_TAG:-latest}
|
|
environment: *app-environment
|
|
networks: [backend, egress]
|
|
read_only: true
|
|
tmpfs: [/tmp]
|
|
healthcheck:
|
|
test: [CMD-SHELL, 'python -c "import os,urllib.request; r=urllib.request.Request(\"http://localhost:8000/health\",headers={\"Host\":os.environ[\"PUBLIC_HOST\"]}); urllib.request.urlopen(r,timeout=3)"']
|
|
interval: 15s
|
|
timeout: 5s
|
|
retries: 12
|
|
start_period: 30s
|
|
deploy:
|
|
replicas: 1
|
|
restart_policy: {condition: on-failure, delay: 5s}
|
|
|
|
worker:
|
|
image: ${API_IMAGE:-gitea.blyzer.com.br/blyzer/dtf-api}:${IMAGE_TAG:-latest}
|
|
command: python -m app.worker
|
|
environment: *app-environment
|
|
networks: [backend, egress]
|
|
read_only: true
|
|
tmpfs: [/tmp]
|
|
healthcheck:
|
|
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://localhost:8002/health',timeout=3)"]
|
|
interval: 15s
|
|
timeout: 5s
|
|
retries: 12
|
|
start_period: 90s
|
|
deploy:
|
|
replicas: 1
|
|
restart_policy: {condition: on-failure, delay: 5s}
|
|
|
|
# Daily encrypted copy of the database to a bucket of its own, off this
|
|
# server (ops/db_backup.py). Idle until the BACKUP_* settings are set. The
|
|
# bucket's lifecycle rule removes old copies; this credential never deletes.
|
|
backup:
|
|
image: ${API_IMAGE:-gitea.blyzer.com.br/blyzer/dtf-api}:${IMAGE_TAG:-latest}
|
|
command: python -m ops.db_backup serve
|
|
environment:
|
|
DATABASE_ADMIN_HOST: db
|
|
DATABASE_ADMIN_NAME: dtf
|
|
DATABASE_ADMIN_USER: dtf_admin
|
|
DATABASE_ADMIN_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
|
# The R2 account endpoint (the same as R2_ENDPOINT) and a bucket and API
|
|
# token for backups only, never the artwork bucket's.
|
|
BACKUP_S3_ENDPOINT: ${BACKUP_S3_ENDPOINT:-}
|
|
BACKUP_BUCKET: ${BACKUP_BUCKET:-}
|
|
BACKUP_ACCESS_KEY_ID: ${BACKUP_ACCESS_KEY_ID:-}
|
|
BACKUP_SECRET_ACCESS_KEY: ${BACKUP_SECRET_ACCESS_KEY:-}
|
|
# The public key (age1...). Its private key stays off this server.
|
|
BACKUP_AGE_RECIPIENT: ${BACKUP_AGE_RECIPIENT:-}
|
|
# Hour of day in Brasília.
|
|
BACKUP_HOUR: ${BACKUP_HOUR:-3}
|
|
networks: [backend, egress]
|
|
read_only: true
|
|
tmpfs: [/tmp]
|
|
deploy:
|
|
replicas: 1
|
|
restart_policy: {condition: on-failure, delay: 30s}
|
|
|
|
site:
|
|
image: ${WEB_IMAGE:-gitea.blyzer.com.br/blyzer/dtf-web}:${IMAGE_TAG:-latest}
|
|
environment:
|
|
WEB_INDEX: index.html
|
|
PUBLIC_HOST: ${SITE_DOMAIN:?set SITE_DOMAIN}
|
|
S3_PUBLIC_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
|
|
# Mercado Pago's card form loads from these origins; empty keeps the
|
|
# Site at script-src 'self'. Set together with the Mercado Pago adapter.
|
|
PAYMENT_CSP_SOURCES: ${PAYMENT_CSP_SOURCES:-}
|
|
# The bank's confirmation page for debit and other 3-D Secure cards is
|
|
# on the issuer's own domain, so it cannot be listed: "https:" lets
|
|
# frames and form posts reach it (never scripts). Empty turns it off.
|
|
PAYMENT_CHALLENGE_SOURCES: ${PAYMENT_CHALLENGE_SOURCES:-}
|
|
networks: [backend]
|
|
ports:
|
|
- target: 8080
|
|
published: ${SITE_PORT:-18080}
|
|
protocol: tcp
|
|
mode: ingress
|
|
healthcheck:
|
|
test: [CMD-SHELL, 'wget -q --header="Host: $$PUBLIC_HOST" -O /dev/null http://127.0.0.1:8080/health']
|
|
interval: 15s
|
|
timeout: 5s
|
|
retries: 12
|
|
start_period: 15s
|
|
deploy:
|
|
replicas: 1
|
|
restart_policy: {condition: on-failure, delay: 5s}
|
|
|
|
kanban:
|
|
image: ${WEB_IMAGE:-gitea.blyzer.com.br/blyzer/dtf-web}:${IMAGE_TAG:-latest}
|
|
environment:
|
|
WEB_INDEX: kanban.html
|
|
PUBLIC_HOST: ${KANBAN_DOMAIN:?set KANBAN_DOMAIN}
|
|
S3_PUBLIC_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
|
|
PAYMENT_CSP_SOURCES: ""
|
|
PAYMENT_CHALLENGE_SOURCES: ""
|
|
networks: [backend]
|
|
ports:
|
|
- target: 8080
|
|
published: ${KANBAN_PORT:-18081}
|
|
protocol: tcp
|
|
mode: ingress
|
|
healthcheck:
|
|
test: [CMD-SHELL, 'wget -q --header="Host: $$PUBLIC_HOST" -O /dev/null http://127.0.0.1:8080/health']
|
|
interval: 15s
|
|
timeout: 5s
|
|
retries: 12
|
|
start_period: 15s
|
|
deploy:
|
|
replicas: 1
|
|
restart_policy: {condition: on-failure, delay: 5s}
|
|
|
|
configs:
|
|
# Renamed whenever infra/clamd.conf changes: Swarm cannot update a deployed
|
|
# config in place, and a redeploy with new content under the old name fails.
|
|
clamd_config_2gb:
|
|
file: ./infra/clamd.conf
|
|
|
|
volumes:
|
|
postgres-data:
|
|
|
|
networks:
|
|
backend:
|
|
driver: overlay
|
|
internal: true
|
|
egress:
|
|
driver: overlay
|