ops(OPS66): deploy — migrate antes do build do runner (deadlock de migrations em caminho estático)

This commit is contained in:
Francisco Solla 2026-08-19 10:06:00 -03:00
parent 9504c7ccf6
commit 228f2217c7
8 changed files with 156 additions and 32 deletions

View file

@ -12,7 +12,7 @@ These are product/architecture constraints — not a ban on editing existing cod
- Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campaign)` internal campaign tool. No separate Rust service or frontend app for `/campanha`; it is modeled with Payload collections. The campaign roles are `coordinator` (label "Coordenador Geral"), `advisor` ("Assessor"), `leader` ("Liderança"), and `candidate` ("Candidato", unrestricted visibility), all on the separate auth collection `campaignUser` (see "Campaign auth" below). An advisor sees and manages ONLY the municipalities they administer; a leader is locked down to the supporter contact tool.
- **2026-07-23 remodel (DEPLOYED to production 2026-07-23): "Município" is the operational unit** (it replaced "Praça", which replaced "Núcleo Eleitoral"). Municípios are PREDEFINED (seeded, geography read-only): 435 rows — one per Bahia municipality (416), except Salvador (19 zone Municípios, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated); shared/leaky TSE zones are cut at the municipal boundary. Code identifier: `municipality`. Leaderships are one record per person (`contact` UNIQUE) linked to N municipalities and N organizations; vote pledges are per leadership×municipality with `declaredVotes` (staff-entered, leader-visible) vs `estimatedVotes` (three scenarios, staff-only, hidden from the leader). Campaign production data was reset by the remodel migrations (public-site data untouched). Master plan: `docs/plans/remodelagem-municipios.md` (supersedes `remodelagem-pracas.md`). Details: "Campaign Municípios model" below.
- Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). The CI (`ci.yml` on `main`) is the verifier — full suite green — **plus the deploy step (OPS53, 2026-08-17; windowed since OPS65, 2026-08-18)**: `ci.yml` no longer fires on every push — it runs in a **fixed 30-minute window** (`schedule */30` + `workflow_dispatch`), where a cheap gate job (`check`, host runner, no pnpm) reads the SHA deployed on the homeserver from `~/stack/docker-compose.yml` over SSH, diffs it against `main` HEAD and classifies "production change" using `.dockerignore` as the source of truth (docs/`.agents`/`.env*` changes skip; `scripts/`, `tests/`, `.forgejo/`, `AGENTS.md` count as production — conservative). No production change → suite + deploy skipped, run stays green; production change → full suite + deploy in the same window; dispatch always runs the full pipeline. The `deploy` job (runs-on `host` on the workstation runner) streams `scripts/deploy-homeserver.sh` to the homeserver over SSH; the remote script is idempotent ("already deployed" → skip), builds the standalone image at the pushed SHA (BuildKit secrets from `~/stack/teqo-1313.env`), pushes it to the local registry (`localhost:5000`), applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before** the rollout, and recreates the container. Merge em `main` com mudança de produção verde == site atualizado em até ~1 h (janela de 30 min + suite + deploy). Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. **The agent-pool supervisor workflow was killed in OPS65** (the `*/10` tick cost 144 full `pnpm install`/day on the workstation; `agent-pool.yml` is gone, pool scripts/skills stay dormant). **The production database lives on the homeserver Postgres (`teqo_1313`, migrated from Neon in OPS51, 2026-08-17)** — the full production content (contacts, signatures, subscriptions, campaign) was restored there via `pg_dump`/`pg_restore` (dump in `/srv/hdd/backups/teqo-neon-pre-migracao/`); Neon remains alive and untouched as the documented rollback (re-point `DATABASE_URL` in `~/stack/teqo-1313.env`). Media moved to the homeserver Garage S3 in OPS52 (2026-08-18): the `media` collection uploads via `@payloadcms/storage-s3` when the `S3_*` envs are set, and Payload proxies the files through `/api/media/file/...` (the bucket stays private). Production env lives in `~/stack/teqo-1313.env` (bucket `teqo-media`, endpoint `http://host.docker.internal:3900` — o container alcança o Garage no host via `extra_hosts: host-gateway` + regras ufw `allow from 10.0.11.0/24 to any port 3900` (INPUT e route/FWD); a workstation/tailnet usa `http://100.119.220.31:3900`, key `teqo-key`); a partial `S3_*` config aborts the boot, and no `S3_*` means local disk (dev/test). The 40 pre-existing media rows are RECOVERED (OPS52-media, 2026-08-18): 38/40 objects restaurados no bucket a partir do WordPress (38 capas servindo 200 em `/api/media/file/...`; exceções sem fonte: `fim-escala-6x1.jpg` e `jorgesolla.jpg`) — re-executar via `pnpm media:recover` (restaura objetos sem tocar no DB; runbook em `docs/plans/ops52-media-recuperar-arquivos-impl.md`; desde OPS52-media-guard o reconcile exige `MEDIA_RECOVER_CONFIRM=1` — guard de intenção explícita para escrita no bucket, `--dry-run`/`--verify` seguem sem flag); the URLs themselves are the same contract the proxy now serves. Never point dev/test databases at the homeserver prod DB either — the local guards (`guard-dev-db`, `assertTestDatabase`) still enforce `localhost`/`_test`-suffixed targets.
- Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). The CI (`ci.yml` on `main`) is the verifier — full suite green — **plus the deploy step (OPS53, 2026-08-17; windowed since OPS65, 2026-08-18)**: `ci.yml` no longer fires on every push — it runs in a **fixed 30-minute window** (`schedule */30` + `workflow_dispatch`), where a cheap gate job (`check`, host runner, no pnpm) reads the SHA deployed on the homeserver from `~/stack/docker-compose.yml` over SSH, diffs it against `main` HEAD and classifies "production change" using `.dockerignore` as the source of truth (docs/`.agents`/`.env*` changes skip; `scripts/`, `tests/`, `.forgejo/`, `AGENTS.md` count as production — conservative). No production change → suite + deploy skipped, run stays green; production change → full suite + deploy in the same window; dispatch always runs the full pipeline. The `deploy` job (runs-on `host` on the workstation runner) streams `scripts/deploy-homeserver.sh` to the homeserver over SSH; the remote script is idempotent ("already deployed" → skip), builds the migrator image first, applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before the runner build** (OPS66: the static build reads the new schema — build-after-migrate, never the reverse), builds the standalone image at the pushed SHA (BuildKit secrets from `~/stack/teqo-1313.env`), pushes it to the local registry (`localhost:5000`), and recreates the container. Merge em `main` com mudança de produção verde == site atualizado em até ~1 h (janela de 30 min + suite + deploy). Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. **The agent-pool supervisor workflow was killed in OPS65** (the `*/10` tick cost 144 full `pnpm install`/day on the workstation; `agent-pool.yml` is gone, pool scripts/skills stay dormant). **The production database lives on the homeserver Postgres (`teqo_1313`, migrated from Neon in OPS51, 2026-08-17)** — the full production content (contacts, signatures, subscriptions, campaign) was restored there via `pg_dump`/`pg_restore` (dump in `/srv/hdd/backups/teqo-neon-pre-migracao/`); Neon remains alive and untouched as the documented rollback (re-point `DATABASE_URL` in `~/stack/teqo-1313.env`). Media moved to the homeserver Garage S3 in OPS52 (2026-08-18): the `media` collection uploads via `@payloadcms/storage-s3` when the `S3_*` envs are set, and Payload proxies the files through `/api/media/file/...` (the bucket stays private). Production env lives in `~/stack/teqo-1313.env` (bucket `teqo-media`, endpoint `http://host.docker.internal:3900` — o container alcança o Garage no host via `extra_hosts: host-gateway` + regras ufw `allow from 10.0.11.0/24 to any port 3900` (INPUT e route/FWD); a workstation/tailnet usa `http://100.119.220.31:3900`, key `teqo-key`); a partial `S3_*` config aborts the boot, and no `S3_*` means local disk (dev/test). The 40 pre-existing media rows are RECOVERED (OPS52-media, 2026-08-18): 38/40 objects restaurados no bucket a partir do WordPress (38 capas servindo 200 em `/api/media/file/...`; exceções sem fonte: `fim-escala-6x1.jpg` e `jorgesolla.jpg`) — re-executar via `pnpm media:recover` (restaura objetos sem tocar no DB; runbook em `docs/plans/ops52-media-recuperar-arquivos-impl.md`; desde OPS52-media-guard o reconcile exige `MEDIA_RECOVER_CONFIRM=1` — guard de intenção explícita para escrita no bucket, `--dry-run`/`--verify` seguem sem flag); the URLs themselves are the same contract the proxy now serves. Never point dev/test databases at the homeserver prod DB either — the local guards (`guard-dev-db`, `assertTestDatabase`) still enforce `localhost`/`_test`-suffixed targets.
- Donations are NOT handled in this app — they run through QueroApoiar (`apoiar.me/jorgesolla`, TSE-homologated). The site only needs a "Doar" link/CTA out to that URL.
- **Embasamento de produto/domínio:** `docs/research/` guarda o compêndio de literatura (campanha de DF na Bahia, geografia do voto, cartografia eleitoral) e o relatório de discovery aprovado (2026-07-21) com o playbook dado→decisão. Para métricas, análises territoriais, mapa e priorização em `/campanha`, consulte-o antes de inventar — o kernel: a disputa de DF é conta de quociente fragmentada; leitura útil é relativa e local (captura, LQ, quantis, cobertura de meta por pledges), nunca % estadual absoluto.

View file

@ -32,7 +32,9 @@ ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NEXT_TELEMETRY_DISABLED=1
# Static generation reads Payload data. BuildKit mounts keep credentials out of
# layers and image history; migrations run separately through the migrator.
# layers and image history; migrations run separately through the migrator —
# the deploy script applies them BEFORE this build (OPS66: static routes read
# the new schema, so the build must see a migrated DB).
RUN --mount=type=secret,id=database_url,env=DATABASE_URL \
--mount=type=secret,id=payload_secret,env=PAYLOAD_SECRET \
NEXT_OUTPUT_STANDALONE=1 \

View file

@ -24,6 +24,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**PUB5** — slogan da campanha "Mais saúde mais futuro" no hero e no rodapé da home (jorgesolla1313.com.br), no lugar de "Um mandato do tamanho da Bahia" (discurso do mandato vigente; o novo é da campanha 2026 — saúde é o tema-âncora do candidato). Troca de copy cirúrgica, sem redesenho nem migration: h1 do hero em duas linhas uppercase "MAIS SAÚDE / MAIS FUTURO" com `aria-label` acompanhando ("MAIS SAÚDE MAIS FUTURO"), segunda linha já em `font-black` (anatomia existente), e o rodapé em sentence case com ponto ("Mais saúde mais futuro."). Testes que pinavam a string antiga atualizados (`campaignHero.unit`, `campaignHome.unit`, `frontend.e2e`). Fora de escopo decidido no gate: cabeçalho de artigos ("O mandato do tamanho da Bahia") e docs históricos. Verificação: unit 2269 + int 791 verdes, e2e frontend 8/8 em prod mode (`E2E_PROD=1`, `.next-e2e`), lint/format/typecheck/cycles/knip/build verdes; render conferido por screenshot + DOM (hero duas linhas, rodapé com ponto). `/simplify` 3 reviewers: sem P0/P1; P2 corrigidos no impl plan (redação + line refs); débitos descartados por serem convenções pré-existentes ou decisões do gate.
**Recently resolved (2026-08-19):** **OPS66** — deadlock de migrations em caminho estático: o deploy buildava o runner **antes** do migrate e, se uma migration criasse tabela lida em geração estática (caso S2, 2026-08-18: `social_feed_settings` na home), o `next build` falhava para sempre (`relation ... does not exist`) e o migrate nunca rodava — 4 deploys seguidos vermelhos até o migrator manual. Fix em `scripts/deploy-homeserver.sh` (reordenação de fases, sem custo novo — migrate e build do migrator já rodavam em todo deploy): **build do migrator** (o estágio não roda `next build` — builda contra o schema antigo) → push/tag do migrator → swap do compose (backup + trap na mesma posição) → **migrate** via `docker compose run teqo-1313-migrate` (caminho canônico do serviço de maintenance, agora com a imagem do SHA novo) → **build do runner** contra o banco JÁ migrado → push/tag → rollout → healthcheck → smoke. Alternativas rejeitadas: `payload migrate` dentro do build do runner (efeito colateral de escrita em prod a partir de um processo de build; contraria o desenho deliberado do Dockerfile) e `docker run` direto do migrator com env-file (duplicaria a definição do serviço de maintenance — caminho paralelo ao canônico). Caveat do rollback ajustado no runbook: migrate antes do build do runner deixa o banco à frente do código se o build falhar depois (mesma classe do caveat atual — migrations append-only e revisadas). Testes: `tests/unit/deployScript.unit.spec.ts` com pin de ordem completo (migrator build < swap < migrate < runner build < rollout) + invariante novo do Dockerfile (estágio migrator nunca roda `next build`). Runbook `docs/ops/teqo-1313-deploy.md` atualizado (fluxo, caveat, falha conhecida nova). Dockerfile/AGENTS.md com a precisão build-after-migrate.
**Recently resolved (2026-08-18):** **OPS52** — a media saiu do Vercel Blob para o Garage S3 do homeserver (bucket `teqo-media` + key `teqo-key`, já existentes no Garage v2.1.0). O plugin `@payloadcms/storage-vercel-blob` (e o `@vercel/blob` do seed) foi trocado por `@payloadcms/storage-s3` na config, no padrão R2 da doc do Payload (`endpoint` + `forcePathStyle: true`, region `garage`). **Decisão de arquitetura (revisão A→B durante a execução, gate com o humano):** o Garage v2.1.0 **não suporta acesso anônimo na API S3** (sem `--public-read`; source: "when we support anonymous access in the future"; testado: `anonymous`/`anon` → `NoSuchAccessKey`), então o bucket fica **privado** e a app **serve a media por proxy** — sem `disablePayloadAccessControl`, o `staticHandler` do plugin streama o objeto do Garage em `/api/media/file/<filename>` (range + etag/304 nativos) — o **mesmo contrato de URL relativa que as 40 rows pré-existentes já têm** (a recuperação dos arquivos é a Issue #10/OPS52-media). **Fail-closed:** `resolveS3StorageEnv` (`src/utilities/mediaStorage.ts`, unit-testado) — envs `S3_*` parciais → throw no boot com a lista exata de faltantes; nenhuma → storage local (dev/test). Envs novas: `S3_BUCKET`, `S3_ENDPOINT`, `S3_REGION` (default `garage`), `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` (`.env.example`, `.env.test`, worktree.mjs com cópia all-or-nothing). Seed de posts: pre-delete do Blob removido (o S3 sobrescreve o key determinístico nativamente — idempotência intacta). Prova de contrato real: PUT/GET/DELETE via SDK contra o Garage (tailnet `http://100.119.220.31:3900`), depois media criada pelo Payload local com S3 ativo → `media.url` relativa + `GET /api/media/file/...` → 200 image/png; bucket devolvido a 0 objects e `.env.local` do worktree limpo (sem credenciais de prod). Sem migration (config-only; importMap regenerado sem `VercelBlobClientUploadHandler`). Runbook de cutover registrado no impl plan: setar as 5 `S3_*` no `~/stack/teqo-1313.env` (endpoint tailnet) antes do próximo deploy (OPS53). Repo: `docs/plans/ops52-blob-vercel-para-garage-impl.md`; infra-solla `STATE.md` atualizado à parte.
**Recently resolved (2026-08-18):** **OPS52-media** — os objetos de media de produção (as ~40 capas dos posts, que existiam como rows com URL relativa `/api/media/file/<filename>` mas sem arquivo em lugar nenhum) são recuperáveis do WordPress de origem com `pnpm media:recover` (`scripts/recover-media.mjs`, novo): lê as rows de `media` + `post.coverImage` (mapeamento exato row→slug; fallback pelo filename determinístico `<slug>.<ext>`), resolve a capa com a MESMA lógica do seed (featured media do REST `_embed`, senão 1ª imagem inline do corpo — só 7/43 posts têm featured), baixa e faz `PutObject` no bucket das envs `S3_*` com key = `filename` da row, **sem nunca escrever no DB** (rows e URLs intocadas; o staticHandler do Payload passa a servir). Extraído para `scripts/lib/wpArticles.mjs` o fetch ao vivo do WordPress (seed-posts agora importa de lá — sem twin; unit-testado). Fail-closed: exige as 4 `S3_*` completas (`resolveS3StorageEnv` reusado; recuperação nunca vai pro storage local), `ALLOW_REMOTE_DB=true` explícito para DB remoto + echo do alvo (DB/bucket/endpoint), e timeouts curtos no SDK (`@smithy/node-http-handler`, 5s/15s) — endpoint inalcançável falha em segundos em vez de pendurar (a rota pública de media de prod pendurava >25s; evidência de possível falta de rota container→Garage, diagnosticada no runbook do impl plan). Modos: reconcile (padrão, upload idempotente por overwrite), `--dry-run` (plano + `headObject`), `--verify` (HEAD da URL pública por filename — o aceite da Issue, sem credencial). Runbook de execução no homeserver: `docs/plans/ops52-media-recuperar-arquivos-impl.md`.

View file

@ -0,0 +1 @@
**Recently resolved (2026-08-19):** **OPS66** — deadlock de migrations em caminho estático: o deploy buildava o runner **antes** do migrate e, se uma migration criasse tabela lida em geração estática (caso S2, 2026-08-18: `social_feed_settings` na home), o `next build` falhava para sempre (`relation ... does not exist`) e o migrate nunca rodava — 4 deploys seguidos vermelhos até o migrator manual. Fix em `scripts/deploy-homeserver.sh` (reordenação de fases, sem custo novo — migrate e build do migrator já rodavam em todo deploy): **build do migrator** (o estágio não roda `next build` — builda contra o schema antigo) → push/tag do migrator → swap do compose (backup + trap na mesma posição) → **migrate** via `docker compose run teqo-1313-migrate` (caminho canônico do serviço de maintenance, agora com a imagem do SHA novo) → **build do runner** contra o banco JÁ migrado → push/tag → rollout → healthcheck → smoke. Alternativas rejeitadas: `payload migrate` dentro do build do runner (efeito colateral de escrita em prod a partir de um processo de build; contraria o desenho deliberado do Dockerfile) e `docker run` direto do migrator com env-file (duplicaria a definição do serviço de maintenance — caminho paralelo ao canônico). Caveat do rollback ajustado no runbook: migrate antes do build do runner deixa o banco à frente do código se o build falhar depois (mesma classe do caveat atual — migrations append-only e revisadas). Testes: `tests/unit/deployScript.unit.spec.ts` com pin de ordem completo (migrator build < swap < migrate < runner build < rollout) + invariante novo do Dockerfile (estágio migrator nunca roda `next build`). Runbook `docs/ops/teqo-1313-deploy.md` atualizado (fluxo, caveat, falha conhecida nova). Dockerfile/AGENTS.md com a precisão build-after-migrate.

View file

@ -30,16 +30,19 @@ da suite/deploy (~15–25 min cada; tipicamente < 1 h no total).
(ex.: dispatch duplicado), sai verde "already deployed" sem rebuild.
5. O script, no **homeserver**: guarda de HEAD (`main` remoto == SHA do job,
senão skip) → `flock` (serializa) → guard "already deployed" (revision do
container) → build da imagem standalone + migrator (BuildKit, secrets do
`~/stack/teqo-1313.env`, `--network host` com proxy socat
`teqo-1313-build-proxy` na `stack_default` para o build alcançar o
`postgres` — o proxy é criado idempotentemente pelo script) → push em
`localhost:5000` → swap dos tags de imagem no `~/stack/docker-compose.yml`
(backup antes) → **migrations**
(`docker compose --profile maintenance run --rm teqo-1313-migrate </dev/null`) →
`docker compose up -d teqo-1313` → healthcheck → smoke (`/`,
`/campanha/login`, `/admin`, barreira 307, WebAuthn login-options,
`api/revalidate` com o secret real).
container) → **build do migrator** (o estágio migrator não roda `next
build` — builda mesmo contra o schema antigo; BuildKit, secrets do
`~/stack/teqo-1313.env`) → push/tag do migrator em `localhost:5000` →
swap dos tags de imagem no `~/stack/docker-compose.yml` (backup antes) →
**migrations**
(`docker compose --profile maintenance run --rm teqo-1313-migrate </dev/null`,
já com a imagem do SHA novo) → **build do runner** (contra o banco JÁ
migrado — o `next build` da geração estática lê o schema novo, OPS66;
`--network host` com proxy socat `teqo-1313-build-proxy` na `stack_default`
para alcançar o `postgres`; o proxy é criado idempotentemente pelo script)
→ push/tag do runner → `docker compose up -d teqo-1313` → healthcheck →
smoke (`/`, `/campanha/login`, `/admin`, barreira 307, WebAuthn
login-options, `api/revalidate` com o secret real).
6. Falha = job vermelho no commit; nada é publicado pela metade.
**Primeira janela (verificação ao vivo):** após o merge do OPS65, acompanhe um
@ -83,7 +86,11 @@ docker compose up -d teqo-1313
**Caveat:** uma migration de schema aplicada no passo de migrate **não** é
desfeita pelo rollback (Payload é append-only). Código velho sobre schema novo
pode se comportar mal — o caminho primário minimiza essa janela (migrate só
roda após build+push ok; rollout só após migrate ok).
roda após migrator build+push+swap ok; rollout só após runner build ok). Desde
o OPS66 o migrate roda **antes** do build do runner (o build precisa do schema
novo): um build do runner que falhe depois do migrate deixa o banco à frente
do código — migrations são append-only e revisadas antes do deploy (checklist);
a correção se re-mergeia e a próxima janela completa o deploy.
## Falhas conhecidas
@ -97,6 +104,7 @@ roda após build+push ok; rollout só após migrate ok).
| Deploy para logo após o migrate ("Done." e nada mais, EXIT=0) | `docker compose run` anexa stdin por padrão — o container consome o resto do script que vai no pipe do `bash -s`; o bash chega a EOF e termina sem rodar o rollout | O script já usa `< /dev/null` no `run --rm` do migrate (não remover); sintoma visto 2026-08-17 no primeiro deploy |
| Build falha: `network mode "stack_default" not supported by buildkit` | BuildKit (drivers docker e docker-container) recusa rede bridge custom no `--network` | O script builda com `--network host` + proxy socat `teqo-1313-build-proxy` (na `stack_default`, publicado em 127.0.0.1:5433, criado idempotentemente com `restart: unless-stopped`) + `DATABASE_URL` reescrita para o loopback |
| Build OOM no homeserver | Laptop 8c/16GB com o stack ativo (~12GB livres medidos) | Re-dispatch; se recorrente, item futuro: build na workstation com túnel |
| Build do runner falha: `relation "..." does not exist` no `next build` | Migration nova criou tabela lida em geração estática. Pré-OPS66 a ordem era build→migrate e o deploy morria aqui para sempre (incidente 2026-08-18, S2) | Pós-OPS66 não deve ocorrer: migrate roda antes do build do runner. Se reaparecer, confira no log se o passo migrate rodou; recovery manual: `docker build --target migrator` + `docker run --rm --network stack_default --env-file ~/stack/teqo-1313.env localhost:5000/teqo-1313-migrator:<sha>` e re-dispatch do workflow |
| `checkout@v5` falha no job `host` | Label host do act_runner | Fallback: baixar o script direto do Forgejo (`curl -s https://git.solla.dev/fsolla/teqo/raw/branch/main/scripts/deploy-homeserver.sh`) |
| Migrate falha | Drift/erro de schema | Job vermelho; site segue no container antigo; corrigir e re-mergear |
| Smoke falha pós-up | Regressão de runtime | Rollback automático (restore + `up -d`) + job vermelho; investigar |

View file

@ -0,0 +1,72 @@
# Impl: OPS66 — deploy: build de geração estática lê schema novo antes do passo migrate (deadlock de migrations em caminho estático)
Status: aprovado
Atualizado em: 2026-08-19
Issue: #68
Intenção: docs/plans/ops66-deploy-build-le-schema-novo-antes-da-migrate.md
Appetite restante: herdado (~0,5 dia)
## Leitura da intenção
- **Outcome:** nenhuma migration nova pode mais travar o deploy para sempre por criar tabela lida em geração estática — o schema novo chega ao build do runner antes do `next build`.
- **O que NÃO negociar:** ordem migrate antes do build; nada de auto-migrate dentro da imagem do runner (o Dockerfile chama `next build` direto de propósito); não tocar migrations já aplicadas; não mudar arquitetura de geração estática.
- **O que reavaliar:** COMO rodar o migrate antes do build — o serviço canônico `teqo-1313-migrate` do compose depende do swap dos tags de imagem, que hoje acontece só depois do build. A intenção sugere "rodar o migrate antes do build" sem fixar o mecanismo.
## Abordagem recomendada
Reordenar as fases do `scripts/deploy-homeserver.sh`: o migrator (estágio que NÃO roda `next build` — não depende do schema) builda primeiro; o swap do compose (com backup) e o migrate via serviço de maintenance acontecem antes do build do runner.
```mermaid
flowchart LR
G[HEAD guard + flock + already-deployed] --> B[Migrator: build + push + tag]
B --> S[compose swap com backup — trap de rollback]
S --> M[migrate: compose run teqo-1313-migrate — imagem nova]
M --> R[Runner: build + push + tag — contra o banco migrado]
R --> U[rollout + healthcheck + smoke]
```
**Opções consideradas:** A | B | C
**Recomendação:** A — reordenar o script movendo o migrate para antes do build do runner, mantendo o `compose run teqo-1313-migrate` como caminho canônico (o swap passa a ser feito logo após o build do migrator). É a opção 1 da própria intenção, sem custo adicional (o migrate e o build do migrator já rodavam em todo deploy — só mudam de posição) e sem caminho paralelo ao serviço de maintenance.
**Rejeitadas:**
- **B — `payload migrate` pré-check dentro do build do runner:** efeito colateral de escrita no banco de prod a partir de um processo de build; contradiz o desenho deliberado do Dockerfile ("migrations run separately through the migrator") e a decisão (B) do OPS53. Qualquer build CI em ambiente errado migraria o banco de prod.
- **C — `docker run --rm --network stack_default --env-file ~/stack/teqo-1313.env localhost:5000/teqo-1313-migrator:<sha>` antes do build do runner** (o comando manual de recuperação do incidente): evita o swap precoce, mas duplica a definição do serviço de maintenance do compose (envs/networks/labels) e cria caminho paralelo ao serviço canônico que OPS51/53 e o runbook documentam. O swap precoce (A) é inofensivo: nada referencia o tag do runner até o `compose up -d`, que só roda depois do push.
### Componentes / mudanças
- **`scripts/deploy-homeserver.sh`**: mover o bloco `build_image migrator` + push/tag do migrator para ANTES do swap; mover o bloco swap (backup + sed + greps + trap) para antes do migrate; mover `build_image runner` + push/tag do runner para DEPOIS do migrate. Comentário no passo migrate explicando o porquê (OPS66).
- **`tests/unit/deployScript.unit.spec.ts`**: pin de ordem completo — migrator build < swap < migrate < runner build < rollout (substitui o pin atual migrate < rollout).
- **`docs/ops/teqo-1313-deploy.md`**: fluxo do passo 5 reescrito; caveat do rollback ajustado ("migrate roda antes do build do runner"); nova linha em Falhas conhecidas: build falha com `relation ... does not exist` (sintoma do incidente S2).
- **`Dockerfile`**: estender o comentário do estágio `builder` (migrations são aplicadas pelo deploy antes deste build — OPS66).
- **`AGENTS.md`**: precisão de uma linha no parágrafo do deploy (migrations aplicadas antes do build do runner).
- **Migration:** sem migration.
- **Access / Consent:** sem mudança.
- **UI:** sem mudança.
### Dados → forma
n/a (ops script).
## Fases verificáveis
1. **Script + teste** — reordenar `deploy-homeserver.sh`; atualizar `deployScript.unit.spec.ts` com os pins de ordem; `bash -n` + unit verde.
2. **Docs** — runbook (fluxo + caveat + falha conhecida), comentário no Dockerfile, AGENTS.md, changelog (`pnpm changelog:build` + `changelog:check`).
3. **Gates** — `pnpm gate:fast` na iteração; `pnpm push` para a entrega.
## Rabbit holes / Não escopo (engenharia)
- Não alterar o Dockerfile além do comentário (estágio migrator/builder ficam como estão).
- Não mexer no `.forgejo/workflows/ci.yml` (job deploy continua streamando o script — a mudança é toda no script).
- Não criar infra nova no homeserver (compose, proxy socat, service de maintenance ficam como estão).
- Não reescrever o histórico de changelog/AGENTS além da entrada nova + precisão da linha do deploy.
## Riscos e mitigação
- **Forward migration em build falho:** migrate antes do build do runner — se o build falhar depois do migrate, o banco fica à frente do código rodando. Mesma classe do caveat atual (rollout falho pós-migrate); migrations são append-only e revisadas antes do deploy (checklist AGENTS). Documentado no runbook.
- **Compose órfão em kill duro entre swap e push:** o compose referencia `teqo-1313:<sha>` ainda não existente. Backup `docker-compose.yml.pre-$SHA` existe; o próximo deploy re-swapa; restore manual documentado no runbook. Mesma classe do hazard atual (kill entre swap e rollout).
- **Migrate sem migrations pendentes:** `payload migrate` vira no-op (~1–2 min) — custo que já existia em todo deploy, só deslocado.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (migrate antes do build do runner)
- [x] Invariantes AGENTS/engineering-standards (sem caminho paralelo: compose run segue canônico; sem auto-migrate no build)
- [x] Testes previstos (unit pin de ordem no deployScript.unit.spec.ts)

View file

@ -7,12 +7,14 @@
#
# Flow: HEAD guard (only the current main HEAD deploys) -> flock
# serialization -> workspace fetch at <sha> -> docker login (local registry)
# -> build of the runner + migrator stages (BuildKit secrets, compose
# network) -> push to localhost:5000 -> compose image-tag swap (with backup)
# -> migrate via the maintenance service (BEFORE the rollout) -> compose up
# -> healthcheck wait -> smoke. Any failure after the swap rolls back to the
# previous compose + image; failures before it leave the running site
# untouched and the job red.
# -> build of the MIGRATOR stage (it never runs `next build`, so it builds
# even against the old schema) -> push/tag of the migrator -> compose
# image-tag swap (with backup) -> migrate via the maintenance service
# (BEFORE the runner build — static generation reads the NEW schema, OPS66)
# -> build of the runner stage (BuildKit secrets, compose network) ->
# push/tag -> compose up -> healthcheck wait -> smoke. Any failure after the
# swap rolls back to the previous compose + image; failures before it leave
# the running site untouched and the job red.
#
# Environment defaults assume the homeserver layout: stack under
# $HOME/stack, repo cloned from the local Forgejo. Secrets are sourced from
@ -130,15 +132,14 @@ build_image() {
}
cd "$WORKSPACE_DIR"
build_image runner "localhost:5000/teqo-1313:$SHA"
# The migrator stage never runs `next build`, so it builds fine against the
# OLD schema — unlike the runner, whose static generation reads Payload data
# and therefore needs the migrations applied first (OPS66).
build_image migrator "localhost:5000/teqo-1313-migrator:$SHA"
say "pushing images to localhost:5000"
docker push "localhost:5000/teqo-1313:$SHA"
docker push "localhost:5000/teqo-1313-migrator:$SHA"
# The compose references bare `teqo-1313:<sha>` tags with pull_policy: never.
docker tag "localhost:5000/teqo-1313:$SHA" "teqo-1313:$SHA"
docker push "localhost:5000/teqo-1313-migrator:$SHA"
docker tag "localhost:5000/teqo-1313-migrator:$SHA" "teqo-1313-migrator:$SHA"
# --- compose swap (backup first; failures from here roll back) ----------
@ -163,7 +164,12 @@ rollback() {
}
trap 'rollback "unexpected failure"' ERR
# --- migrate (before the rollout) ---------------------------------------
# --- migrate (BEFORE the runner build — OPS66) --------------------------
# The runner build statically generates pages that read Payload data; a
# migration that creates a table a static route reads would otherwise
# deadlock the deploy (build fails -> migrate never runs -> build fails...).
# The migrator image above is already swapped into the compose, so this
# maintenance service runs the migrations of the NEW sha against prod.
cd "$STACK_DIR"
say "applying pending migrations (maintenance service teqo-1313-migrate)"
@ -172,8 +178,17 @@ say "applying pending migrations (maintenance service teqo-1313-migrate)"
# exits right after the migrate step, skipping the rollout).
docker compose --profile maintenance run --rm teqo-1313-migrate </dev/null || rollback "migrations failed"
# --- runner build (against the migrated schema) --------------------------
cd "$WORKSPACE_DIR"
say "building runner"
build_image runner "localhost:5000/teqo-1313:$SHA"
docker push "localhost:5000/teqo-1313:$SHA"
docker tag "localhost:5000/teqo-1313:$SHA" "teqo-1313:$SHA"
# --- rollout ------------------------------------------------------------
cd "$STACK_DIR"
say "rolling out teqo-1313"
docker compose up -d teqo-1313 || rollback "compose up failed"

View file

@ -46,14 +46,27 @@ describe('scripts/deploy-homeserver.sh (OPS53 deploy pipeline)', () => {
expect(script).toContain('127.0.0.1:5433')
})
it('applies migrations through the maintenance service before the rollout', () => {
it('applies migrations BEFORE the runner build — static generation reads the new schema (OPS66)', () => {
// OPS66: a migration that creates a table a static route reads used to
// deadlock the deploy (runner build failed -> migrate never ran -> build
// failed again). The migrator stage never runs `next build`, so it must
// build first; the compose swap feeds the new migrator image to the
// maintenance service; then migrate runs; only then the runner builds
// against the migrated DB.
const migratorBuildIndex = script.indexOf('build_image migrator')
const swapIndex = script.indexOf('compose swap')
const migrateIndex = script.indexOf('teqo-1313-migrate')
const runnerBuildIndex = script.indexOf('build_image runner')
// The rollback helper also contains a `compose up -d` (before the migrate
// step); the REAL rollout is the last occurrence.
const migrateIndex = script.indexOf('teqo-1313-migrate')
const upIndex = script.lastIndexOf('docker compose up -d teqo-1313')
expect(migrateIndex).toBeGreaterThan(-1)
expect(upIndex).toBeGreaterThan(-1)
expect(migrateIndex).toBeLessThan(upIndex)
for (const i of [migratorBuildIndex, swapIndex, migrateIndex, runnerBuildIndex, upIndex]) {
expect(i).toBeGreaterThan(-1)
}
expect(migratorBuildIndex).toBeLessThan(swapIndex)
expect(swapIndex).toBeLessThan(migrateIndex)
expect(migrateIndex).toBeLessThan(runnerBuildIndex)
expect(runnerBuildIndex).toBeLessThan(upIndex)
})
it('smokes the deployed surface with the prod revalidate secret', () => {
@ -76,4 +89,15 @@ describe('scripts/deploy-homeserver.sh (OPS53 deploy pipeline)', () => {
expect(script).toContain('echo "$REGISTRY_PASSWORD" | docker login')
expect(script).not.toContain('--password "$REGISTRY_PASSWORD"')
})
it('Dockerfile: the migrator stage never runs `next build` (it builds against the old schema)', () => {
// The invariant OPS66 depends on: the migrator image can always be built,
// even before the new migrations exist in the DB.
const dockerfile = readFileSync(join(repoRoot, 'Dockerfile'), 'utf8')
const migratorStage = dockerfile.slice(dockerfile.indexOf('AS migrator'), dockerfile.indexOf('AS builder'))
expect(migratorStage).toContain('CMD ["pnpm", "migrate"]')
expect(migratorStage).not.toContain('next build')
const builderStage = dockerfile.slice(dockerfile.indexOf('AS builder'))
expect(builderStage).toContain('next build')
})
})