The Operaciones panel (backup, restore, sync, re-import) shelled out to mysqldump as the application user, parsed straight out of DATABASE_URL. `--single-transaction` issues FLUSH TABLES, which needs the global RELOAD privilege, and the app user is granted only ALL ON jorgecuadros.* plus USAGE ON *.*. BACKUP failed outright; SYNC and REIMPORT failed with it, since both take a safety backup first. An admin credential is now supplied out of band via OPS_DB_ADMIN_USER / OPS_DB_ADMIN_PASSWORD, mirroring what deploy/scripts/pre-migrate-backup.mjs already does, rather than permanently elevating the user the API serves requests as. Host, port and database still come from DATABASE_URL, so the override can only change who logs in, never which server. Unset, it falls back to the DATABASE_URL credentials and warns — local development is unaffected. Two defects in the dumps themselves, both shared with the deploy backup before it was rewritten: - No --set-gtid-purged=OFF. The production server is the replication source with GTID on, so every dump embedded SET @@GLOBAL.GTID_PURGED and was unrestorable onto the server it came from — the one thing the restore screen is for. - The pipeline's exit status was gzip's, and gzip succeeded. A mysqldump that died on its first statement left a small, perfectly valid archive that the job recorded as SUCCESS and the restore screen listed as an ordinary restore point. Dumps now run under `set -o pipefail`, assert a CREATE TABLE count, and delete their own output on failure. Verified with a stubbed mysqldump: a failing dump exits 1, surfaces the real error, removes the partial file, and — critically — stops SYNC/REIMPORT before the ETL touches anything. Restores gained pipefail too: a corrupt archive made gunzip fail while mysql, fed a truncated stream, could still exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
112 lines
4.3 KiB
YAML
112 lines
4.3 KiB
YAML
# Application stack for the Jorge Cuadros platform: the NestJS API + the Next.js
|
|
# web front-end. The two images are built + pushed by .gitea/workflows/build.yml:
|
|
# git.mancinas.io/rmancinas/jorgecuadros-api
|
|
# git.mancinas.io/rmancinas/jorgecuadros-web
|
|
#
|
|
# This stack does NOT ship MySQL or MinIO — those are their own stacks
|
|
# (deploy/jorgecuadros-db.stack.yml, deploy/jorgecuadros-minio.stack.yml). The
|
|
# API reaches them over the network via DATABASE_URL / S3_ENDPOINT, which point
|
|
# at the db + minio stacks' published ingress ports on the swarm host.
|
|
#
|
|
# Target: Portainer local endpoint on cubex (3-node Swarm). PROD only.
|
|
# Deploy with a stack env that supplies every ${VAR:?...} below — see
|
|
# deploy/jorgecuadros-app.env.example for the full list.
|
|
#
|
|
# Statefulness: the API keeps uploaded Access files (ingest) and DB backups on
|
|
# named volumes, which are node-local. So the API is pinned to the same node as
|
|
# the db/minio stacks (node label jorgecuadros_db == true) — a reschedule would
|
|
# otherwise start against empty ingest/backup volumes. The web tier is
|
|
# stateless and floats freely.
|
|
#
|
|
# The web image is NOT URL-baked: the browser's API origin is injected at
|
|
# runtime from API_ORIGIN (see apps/web/src/app/layout.tsx), so this same image
|
|
# works for any deployment — set the URL here, not at build time.
|
|
|
|
version: "3.8"
|
|
|
|
services:
|
|
api:
|
|
image: git.mancinas.io/rmancinas/jorgecuadros-api:${APP_TAG:-latest}
|
|
# Container label (not `deploy.labels`, which labels the swarm SERVICE).
|
|
# deploy/scripts/pre-migrate-backup.mjs finds the container by this label to
|
|
# run its pre-migrate mysqldump into the backup volume.
|
|
labels:
|
|
io.jorgecuadros.role: "api"
|
|
environment:
|
|
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL must be set}
|
|
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
|
# CORS: the public origin the browser loads the web app from.
|
|
WEB_ORIGIN: ${WEB_ORIGIN:?WEB_ORIGIN must be set}
|
|
PORT: "3001"
|
|
INGEST_DIR: /data/ingest
|
|
BACKUP_DIR: /data/backups
|
|
MIGRATION_ENV: prod
|
|
# Credentials the "Operaciones" screen runs mysqldump/mysql as. NOT the
|
|
# application user: --single-transaction needs the global RELOAD privilege
|
|
# and the app user has only ALL ON jorgecuadros.*, so every backup, sync
|
|
# and re-import fails without this. Host/port/database still come from
|
|
# DATABASE_URL — this only changes who logs in. See opsConn() in
|
|
# apps/api/src/ops/ops.service.ts.
|
|
OPS_DB_ADMIN_USER: ${OPS_DB_ADMIN_USER:-root}
|
|
OPS_DB_ADMIN_PASSWORD: ${OPS_DB_ADMIN_PASSWORD:?OPS_DB_ADMIN_PASSWORD must be set}
|
|
# Object storage — internal endpoint the API (server-side) uses to reach
|
|
# the minio stack. Not browser-facing (downloads proxy through the API).
|
|
S3_ENDPOINT: ${S3_ENDPOINT:?S3_ENDPOINT must be set}
|
|
S3_BUCKET: ${S3_BUCKET:-jorgecuadros-documents}
|
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
|
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
|
|
ports:
|
|
- target: 3001
|
|
published: ${API_PORT:-3001}
|
|
protocol: tcp
|
|
mode: ingress
|
|
volumes:
|
|
- ingest_data:/data/ingest
|
|
- backup_data:/data/backups
|
|
deploy:
|
|
replicas: 1
|
|
placement:
|
|
constraints:
|
|
- node.labels.jorgecuadros_db == true
|
|
restart_policy:
|
|
condition: any
|
|
update_config:
|
|
order: stop-first
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health || exit 1"]
|
|
interval: 15s
|
|
timeout: 5s
|
|
retries: 10
|
|
start_period: 30s
|
|
|
|
web:
|
|
image: git.mancinas.io/rmancinas/jorgecuadros-web:${APP_TAG:-latest}
|
|
labels:
|
|
io.jorgecuadros.role: "web"
|
|
environment:
|
|
# Public API URL the browser calls (injected at runtime, see layout.tsx).
|
|
API_ORIGIN: ${API_ORIGIN:?API_ORIGIN must be set}
|
|
ports:
|
|
- target: 3000
|
|
published: ${WEB_PORT:-3000}
|
|
protocol: tcp
|
|
mode: ingress
|
|
depends_on:
|
|
- api
|
|
deploy:
|
|
replicas: 1
|
|
restart_policy:
|
|
condition: any
|
|
update_config:
|
|
order: start-first
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/ >/dev/null 2>&1 || exit 1"]
|
|
interval: 15s
|
|
timeout: 5s
|
|
retries: 10
|
|
start_period: 30s
|
|
|
|
volumes:
|
|
ingest_data:
|
|
backup_data:
|