10 Commits
Author SHA1 Message Date
rmancinasandClaude Opus 5 5a277f4885 feat(deploy): apply migrations at api container start
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m3s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m32s
`prisma migrate deploy` ran in one place only: a workflow step on the Gitea
runner, which has to reach the target host's MySQL on 3306 directly. Two
paths went around it:

  - `skip_migrate=true`, the documented answer for when the runner cannot
    reach 3306, left the schema a release behind with nothing to catch it.
    The mismatch surfaced later as a column-not-found at runtime rather than
    as a failed deploy.
  - A container brought back by `restart: unless-stopped` after a host
    reboot, or a stack re-applied by hand in Portainer, never runs the
    workflow at all.

docker/api-entrypoint.sh becomes the api image's ENTRYPOINT: migrate, then
exec node. If the migration fails the container exits non-zero and the API
never listens — serving against a schema that does not match the code is
worse than being down, because the failures are partial and silent (a write
to a missing column breaks one feature while the rest looks healthy).

This does not replace the workflow step and is not a substitute for it. That
step still runs FIRST, while the old code is serving, which is the order
expand/contract migrations are designed around. `migrate deploy` is
idempotent, so on the normal path the container's run is a no-op query.

Behaviour:

  RUN_MIGRATIONS=false     skip and start anyway; plumbed through both app
                           stack files, for a schema moved by hand
  DATABASE_URL unset       refuse to start, and say why
  P1001 (unreachable)      retry, default 20 x 3s -- a cold db container, and
                           galactus's MagicDNS lookup right after a reboot
  anything else            exit at once; retrying a broken migration only
                           delays the same error. P3005 prints the
                           `migrate resolve --applied 0000_init` hint the
                           workflow step already printed.

Only P1001 retries, so a genuinely broken migration is not buried under a
minute of noise.

Both stacks are replicas: 1 and must stay so for an unrelated reason (the
servicios email sweep has no DB lock). The old comment claiming migrations
must not run per-container because "N replicas would race" is dropped: they
would not corrupt anything, since Prisma takes a database advisory lock and
the losers find nothing pending -- they would only each pay the wait.

The prisma CLI is already in the runtime layer (the image copies
/repo/node_modules wholesale), but which of the two plausible .bin paths
carries it is an implementation detail of pnpm's hoisted linker, so the
entrypoint accepts either and the Dockerfile asserts one exists at BUILD
time. A missing CLI breaks the image build, not a production boot.

Verified by running the entrypoint against stubbed prisma binaries: clean
run, P3005, P1001-to-exhaustion, P1001-then-recovery, RUN_MIGRATIONS=false,
missing DATABASE_URL, missing CLI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:17:01 -07:00
rmancinasandClaude Opus 5 14c6183aa2 feat(deploy): derive the API origin from the page, not from a pinned env var
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m55s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m35s
The browser hard-required API_ORIGIN, so every move of the server — tailnet
today, the 192.168.1.0 office LAN later, a temporary demo domain in between —
meant editing the deploy env and redeploying. Worse, an http:// API origin on a
page served over TLS is blocked outright as mixed active content, which is what
broke the demo on https://jorgecuadros.freakma.com.

The browser now derives the origin from window.location the way a PHP app
would: same host on port 3001 over plain HTTP, or the same-origin /api path
under https (the reverse proxy strips the prefix). API_ORIGIN survives as an
optional override for a deployment that genuinely splits the two hosts, and SSR
still reads process.env because a derived origin is browser-only.

WEB_ORIGIN becomes a comma-separated list to match: one deployment is now
reached under several origins, and a credentialed fetch from an unlisted one
gets no CORS headers and fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 12:55:32 -07:00
rmancinasandClaude Opus 5 dac1f1982f feat(ops): show read-replica health on the Operaciones screen
my.jorgecuadros.com serves customer balances from the Oracle VPS replica.
A replica whose SQL thread has stopped does not error — it keeps answering,
with data frozen at the moment it stopped — so nothing on the customer site
looks wrong and the only signal is a customer complaining about a stale
balance. This puts the failure somewhere a human sees it.

Deliberately does not trust the two fields an operator reaches for first.
Replica_IO_Running reports Yes while the SQL thread is stopped, because the
network thread keeps downloading binlog it will never apply; verified by
stopping SQL_THREAD and watching IO stay Yes. Seconds_Behind_Source reads
NULL whenever EITHER thread is down, so the card renders "sin dato" rather
than "0 s" — showing zero there would report an outage as perfect health.
The problem string is resolved most-specific-first for the same reason.

Shells out to the mysql client because the API has no MySQL driver and the
image already ships one. --ssl is required (the replica sets
require_secure_transport); --ssl-verify-server-cert=0 is deliberate and is
NOT the trade-off the website makes: this hop never leaves Tailscale and the
replica's firewall admits only this host, so WireGuard authenticates the
peer, whereas the DreamHost leg crosses the public internet and pins the CA.

The account behind it holds REPLICATION CLIENT and nothing else — it cannot
read a single row. REPLICA_DB_* unset is a supported state and renders "no
configurada", which is correct in dev and before cutover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:22:15 -07:00
rmancinasandClaude Opus 5 f4b92fa7a5 fix(deploy): pass SES config through to the app stack
The stack env is assembled from Gitea repo secrets by the deploy
workflows' `env_data` block — there is no .env file on the host for the
app stack. SES was in neither, so `MailService` came up unconfigured on
every deployment and, with NODE_ENV=production killing the stdout dev
fallback, every notification and renewal aviso failed.

Wire SES_REGION / SES_FROM / SES_FROM_NAME / SES_ACCESS_KEY /
SES_SECRET_KEY / SES_CONFIGURATION_SET / NOTIFICATION_ADMIN_EMAILS
through both galactus and cubex. No `_GALACTUS` suffix: one SES identity
serves every deployment.

Kept out of the required-secrets preflight — mail is not needed to boot,
and failing a deploy over it would be wrong. Preflight warns instead,
since the failure is otherwise invisible until someone clicks "Ejecutar".

Also corrects the comments added in the previous commit, which claimed
these belonged in a host env file rather than in CI secrets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 10:59:45 -07:00
rmancinasandClaude Opus 5 33833c3af9 feat(notificaciones): one send log across servicios and pólizas
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m30s
Build and Push Images / Build jorgecuadros-api (push) Failing after 3h13m42s
Renewal avisos left behind only a `RenewalNotice` row, whose sole job is
gating: a row with `sentAt` drops the policy off the pending list. It
cannot represent a failed send or a customer with no address, so the
Pólizas tab had no "Registro de envíos" to show and a sent notice simply
vanished from the list.

Renewals now write `email_notification_log` — the same table the four
bulk jobs write — as `RENEWAL_NOTICE` / `POLICIES`, with rows for
failures and no-email skips too. `RenewalNotice` keeps its gating role
unchanged; the two are complementary, not redundant.

- extend `EmailNotificationType` (+RENEWAL_NOTICE) and
  `EmailNotificationServicio` (+POLICIES); `level` now carries the aviso
  generation on renewal rows, so every reader must branch on the type
  first (`notificationLevelLabel()` is the one place that lives)
- backfill emailed notices (`channel = 'EMAIL'`) into the log; MAIL-channel
  rows are legacy printed letters and are deliberately left out
- extract `NotificationLogService`/`NotificationLogModule` as the single
  writer, so a feature that sends mail records it without pulling the
  bulk-job pipelines into its module
- `GET /notifications/log` and `/stats` take a comma-separated `servicio`
  list; each tab reads its own slice. This also fixes the "Omitidos"
  view, which mapped to no filter at all and showed every row
- share one `NotificationLogPanel` between both tabs
- pass SES_* / NOTIFICATION_ADMIN_EMAILS through the galactus compose,
  which was missing them entirely — mail is runtime config, not a CI
  secret, and the prod image sets NODE_ENV=production so a blank config
  fails loudly instead of falling back to stdout

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 03:01:03 -07:00
rmancinasandClaude Opus 5 30dfc7dc3e fix(ops): run backups as an admin login, and stop recording failed dumps as good
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>
2026-07-30 16:21:00 -07:00
rmancinasandClaude Opus 5 19f03198d6 fix(docker): install the MySQL 8.4 auth plugin; report why a dump fails
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m56s
Build and Push Images / Build jorgecuadros-api (push) Successful in 3m3s
The pre-migrate backup failed with "mysqldump exited 2" and nothing else.
Reproduced on the host with stderr captured:

  ERROR 1045: Plugin caching_sha2_password could not be loaded:
    /usr/lib/mariadb/plugin/caching_sha2_password.so: No such file or directory

Alpine's `mysql-client` is MariaDB's client and ships an EMPTY plugin
directory, so it cannot perform caching_sha2_password — MySQL 8.4's default and
effectively only auth method. `mariadb-connector-c` provides the plugin.

This was never about the deploy backup alone. Every mysqldump/mysql call from
the API container was broken, which means the whole Operaciones panel — backup,
restore, sync, re-import — could not work in a container. It went unnoticed
because that feature had only ever been run with the API on a developer
machine, where the Oracle client is installed. Verified after the fix: dump
exits 0, gzip valid, 31 CREATE TABLEs.

Also fixed, both found while chasing the above:

- The backup script reported an exit code and nothing else, because a detached
  exec captures no output — which is precisely why this needed a manual
  reproduction. mysqldump's stderr is now redirected to a file and read back
  through a short attached exec on failure, so the deploy log states the cause.
  Verified against live prod: the log now carries the 1045 line itself.

- Listing ONLY 100.100.100.100 as the containers' resolver costs them public
  DNS, since MagicDNS does not forward upstream unless the tailnet defines
  global nameservers. Nothing at runtime needed it, but `apk` inside the
  container stopped resolving, and anything outbound would have too. A public
  fallback resolver is now listed after MagicDNS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:52:03 -07:00
rmancinasandClaude Opus 5 b2cdcbe2cd fix(api): session cookie never issued over HTTP; ship the seed script
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m41s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m5s
Prod came up with nobody able to log in, in two separate ways.

1. No sign-in account exists. `prisma migrate deploy` creates tables, never
   rows, and nothing in the deploy path seeds one — deliberately, since making
   an administrator should not be a side effect of shipping code. But
   apps/api/scripts was not in the runtime image either, so the only way to
   create the first account was to run the script from a developer machine
   against a production DATABASE_URL. Ship scripts/ in the image so it can be
   run on the host with docker exec. Still never run automatically.

2. Login could not establish a session at all. cookie.secure followed NODE_ENV,
   the image sets NODE_ENV=production, and the app is served over plain HTTP —
   express-session then silently emits NO Set-Cookie header. POST /auth/login
   still answered 200 with the full user object, no session was created, every
   later request 403'd, and the UI would have looped back to /login. It reads
   as an auth bug and is really a transport mismatch.

   The flag is now driven by SESSION_COOKIE_SECURE, still defaulting to
   NODE_ENV. An EMPTY value counts as unset rather than false, because compose
   turns an absent `${SESSION_COOKIE_SECURE:-}` into the empty string and the
   naive check would have quietly dropped Secure on any deployment that merely
   passed the variable through.

   galactus sets it to "false". That is acceptable ONLY because the host is
   reachable exclusively over Tailscale, so WireGuard already encrypts the
   wire. It must go back to "true" when the app is served over TLS or exposed
   off-tailnet; behind a TLS-terminating proxy, set trust proxy instead.

Verified against live prod: seeded an admin, POST /auth/login returns 200 with
full ADMIN abilities, a wrong password is rejected with 401, and no Set-Cookie
was present before this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:41:55 -07:00
rmancinasandClaude Opus 5 1cba9bfc32 fix(galactus): give containers Tailscale's resolver so MagicDNS names resolve
With the image fixed, the API got as far as connecting and then died with
Prisma P1001 "can't reach database server". The cause is DNS, not routing.

galactus runs systemd-resolved, whose 127.0.0.53 stub is unreachable from
inside a container, so Docker falls back to the upstream resolver in
/run/systemd/resolve/resolv.conf — the LAN router, which knows nothing about
the tailnet. Verified from a probe container on galactus: resolving
galactus.tail01aa2.ts.net fails outright, while `nc 100.103.77.46 3306` is
OPEN. Only the lookup was broken.

Pin the api and web services to Tailscale's own resolver (100.100.100.100,
the same anycast address on every tailnet) with this tailnet's search suffix.
Both are overridable via TAILSCALE_DNS / TAILNET_SUFFIX. db and minio need
nothing — they make no outbound calls.

Verified end to end: the published image, unmodified, with only these DNS
settings, boots on galactus against the real database and serves
  /health   {"status":"ok"}
  /version  {"service":"api","version":"master","gitSha":"3ff56e6b..."}

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:47:19 -07:00
rmancinasandClaude Opus 5 4ee7ec71f0 feat(deploy): prisma migration history, /version, galactus standalone deploy
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m49s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m2s
Closes the gap between "what tag did I deploy" and "what is actually running",
and gives the schema a history that can be reasoned about across releases.

Migrations
- Baseline the existing schema as 0000_init (migrate diff --from-empty). The
  schema had only ever been applied with `prisma db push`, so no history
  existed and schema state was disconnected from app version. Existing
  databases must be baselined once with `migrate resolve --applied 0000_init`;
  the workflows print this remedy on P3005.
- Run `prisma migrate deploy` as a deploy STEP, not the container CMD — as a
  CMD, N replicas would race each other applying the same migration.

Version reporting
- GET /version on the API reports the APP_VERSION / GIT_SHA / BUILD_DATE that
  build.yml already baked into both images but nothing ever read.
- The web footer shows the web build and flags an api/web mismatch. The two
  cannot drift at build time (one matrix run) but can at deploy time.
- Both deploy workflows now fail if the running API does not report the tag
  that was dispatched — a stack naming a tag is not proof of what is running.
- scripts/set-version.mjs stamps every package.json, which had all sat at
  0.1.0 while real releases shipped as v1.x.

Pre-migrate backup
- deploy/scripts/pre-migrate-backup.mjs dumps the database from INSIDE the
  still-running old API container over Portainer's Docker API, so the file
  lands in the volume the Operaciones restore screen reads. A dump taken on
  the CI runner would be unreachable by the only restore path we have.
  Verifies the artefact with `gzip -t` before letting the migration proceed.

galactus
- deploy/galactus/*.compose.yml: standalone-Docker ports of the Swarm stacks.
  Plain compose silently ignores `deploy:`, so restart_policy becomes
  `restart: unless-stopped` — without it nothing returns after a host reboot.
- .gitea/workflows/deploy-galactus.yml drives endpoint 3 with its own secrets.

Fixes
- deploy.yml passed `endpoint_id` and `pull_image` to
  cssnr/portainer-stack-deploy-action, which has no such inputs (they are
  `endpoint` and `pull`). The endpoint was silently never set.

docs/DEPLOY_AND_MIGRATIONS.md documents expand/contract as the rule for schema
changes: Prisma has no down-migrations, so a code rollback never rolls the
schema back, and restoring the replication master from a dump diverges every
replica.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:41:12 -07:00