mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 17:43:04 +00:00
Some checks failed
Deploy / preflight (push) Has been cancelled
Deploy / deploy staging (teqo-staging) (push) Has been cancelled
Deploy / requeue when main advanced (push) Has been cancelled
Deploy / deploy production (teqo-1313) (push) Has been cancelled
Deploy / verify (push) Has been cancelled
Decisão A (site público + /campanha no domínio novo, passkeys re-registradas uma vez) refletida no plano; runbook de cutover/rollback no docs/ops/teqo-1313-deploy.md com pré-flight, ingress/DNS, env, verificação e janela read-only do WordPress; changelog da entrega.
2034 lines
133 KiB
Markdown
2034 lines
133 KiB
Markdown
# Runbook: deploy do site 1313 no homeserver (OPS53 + OPS71 + OPS103 + OPS104 + OPS106 + OPS107 — início automático, produção com review)
|
||
|
||
Desde a OPS104 o deploy **começa sozinho**: todo merge em `main` (evento
|
||
`push`) inicia o run do `deploy.yml` no head, e um segundo gatilho re-despacha
|
||
um run novo se `main` andar durante o verify/staging. O que continua manual é
|
||
a **publicação em produção**: o job `deploy-production` espera a aprovação do
|
||
environment `production`. Antes de tocar o homeserver, o job `verify` (hosted)
|
||
roda a suíte **full** (incl. e2e full) **uma vez** e gateia os dois alvos:
|
||
`deploy-staging` → aprovação do environment `production` →
|
||
`deploy-production` (OPS103).
|
||
|
||
Desde a OPS107 o approval de produção **não ocupa a lane compartilhada**
|
||
(`concurrency` só no `deploy-staging`): runs novos seguem fazendo verify e
|
||
staging e ficam aprováveis independentemente — o operador aprova **só o run
|
||
escolhido** (tipicamente o último com staging verde) e rejeita os demais.
|
||
|
||
## Gatilho e fluxo
|
||
|
||
1. **Início automático (OPS104):** merge em `main` → o run nasce no head; o
|
||
job `preflight` (hosted) pula o run quando já existe outro deploy
|
||
`queued`/`in_progress`. Um run aguardando aprovação de produção (`waiting`)
|
||
**não** conta: um merge nessa janela começa um run novo e os dois ficam
|
||
pendentes (o humano aprova qual promover — e o staging do run novo **não**
|
||
espera: desde a OPS107 o approval pendente não segura o grupo).
|
||
`workflow_dispatch` (GitHub →
|
||
Actions → **Deploy** → Run workflow, ref: `main`) segue como escape manual e
|
||
**não** passa pelo guard.
|
||
2. Job `preflight` (hosted, `scripts/deploy-preflight.mjs`): consulta os runs
|
||
do próprio workflow; sem deploy ativo → segue. Falha de API é fail-open
|
||
(o guard é dedupe, não gate de segurança).
|
||
3. Job `verify` (hosted `ubuntu-latest`, ~50 min): suíte full sem skips —
|
||
check-test-locations → lint → format → typecheck → knip → cycles → unit →
|
||
int (migrate+seed nos services) → build → e2e full. **Intocado pelas
|
||
OPS103/OPS104**: verifica o commit, não o alvo.
|
||
4. Se `verify` verde, o job `deploy-staging` (`needs: [verify]`,
|
||
`environment: staging`, sem reviewer) roda no runner self-hosted e executa
|
||
`TEQO_ENV=staging bash scripts/deploy-homeserver.sh <sha>` — publica o SHA
|
||
em `staging.jorgesolla1313.com.br` (container `teqo-staging`,
|
||
`127.0.0.1:1314`).
|
||
5. Com staging verde, o job `deploy-production` (`needs: [deploy-staging]`,
|
||
`environment: production`) **aguarda a aprovação do reviewer** e então
|
||
executa `TEQO_ENV=production bash scripts/deploy-homeserver.sh <sha>` —
|
||
publica o **mesmo SHA** em produção (`teqo-1313`, `127.0.0.1:1313`).
|
||
Staging vermelho = produção nunca roda. O job de produção **não tem**
|
||
`concurrency` desde a OPS107: a avaliação de concurrency acontece antes do
|
||
gate do environment e um run `waiting` segurava o grupo `deploy-homeserver`,
|
||
travando o staging (e a aprovação) dos runs novos. O grupo vive **só** no
|
||
`deploy-staging`, que não espera approval (só a vez no próprio grupo) e
|
||
sempre drena; a execução
|
||
entre runs é serializada pela cadeia `needs`, pelo runner self-hosted
|
||
único e pelo `flock` do host. **Operação:** aprove **só o run escolhido**
|
||
(tipicamente o último com staging verde) e **rejeite os demais** — runs
|
||
não aprovados não publicam nada; se dois forem aprovados, o último deploy
|
||
executado vence.
|
||
6. **Requeue (OPS104):** com o staging verde, o job hosted `requeue`
|
||
(`scripts/deploy-requeue.mjs`, **fora** do grupo `deploy-homeserver` —
|
||
um run aguardando aprovação não pode bloquear o requeue) compara o head de
|
||
`main` com o SHA do run; se `main` andou durante o verify/staging, dispara
|
||
um run novo no head novo. Falha aqui é fail-red (job vermelho): staging
|
||
velho nunca fica silencioso.
|
||
7. O script, no **homeserver**: `flock` único (compose, workspace e registry
|
||
são compartilhados — um deploy por vez no host; desde a OPS107 o
|
||
`concurrency` do workflow só existe no staging, então o lock ÚNICO é a
|
||
serialização real entre runs e cobre invocação manual; desde a OPS102
|
||
o run vai até o fim com o SHA do dispatch mesmo se `main` avançar durante o
|
||
verify; o workspace é clonado/atualizado de `TEQO_REPO_URL` — default
|
||
`https://github.com/fsolla/teqo.git`, público) →
|
||
guard "already deployed" (revision do container do ambiente) → **build do
|
||
migrator** (o estágio migrator não roda `next build` — builda mesmo contra o
|
||
schema antigo; BuildKit, secrets do env file do ambiente) → push do migrator
|
||
em `localhost:5000` (tag **qualificada de registry** — INF13: a única ref
|
||
que o compose referencia) → swap dos tags de imagem no
|
||
`~/stack/docker-compose.yml` (backup antes; sed ancorado ao serviço do
|
||
ambiente — a linha do outro ambiente não é tocada) → **migrations**
|
||
(`docker compose --profile maintenance run --rm teqo-<env>-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 por ambiente na `stack_default` para
|
||
alcançar o `postgres`; o proxy é criado idempotentemente pelo script) →
|
||
push do runner (tag qualificada de registry) → `docker compose up -d
|
||
teqo-<env>` → healthcheck → smoke (`/`, `/campanha/login`, `/admin`,
|
||
barreira 307, WebAuthn login-options, `api/revalidate` com o secret real do
|
||
ambiente).
|
||
8. Falha = job vermelho; nada é publicado pela metade (rollback automático
|
||
após o swap, **só do ambiente que falhou**).
|
||
9. **`verify` vermelho (OPS106):** o workflow separado `auto-unblock.yml`
|
||
(`workflow_run` no Deploy, só `push` + `main` + `failure`) dispara **um**
|
||
agente autônomo de desbloqueio no homeserver — launcher síncrono de ~1 min
|
||
que cria/atualiza a Issue token `auto-unblock` e destaca o agente
|
||
(`scripts/auto-unblock-agent.mjs`, cap de 4h, flock `/tmp/teqo-unblock.lock`);
|
||
o agente provisiona um worktree `fix/*` com banco **local** `teqo_wt*`
|
||
(guard fail-closed), roda a skill `/bug-fix` headless e entrega PR Ready
|
||
(auto-merge nativo). Agente ativo + nova falha = só comentário no token
|
||
(nunca enfileira); sem PR = token `blocked` para o humano. O `deploy.yml` e
|
||
o `verify` não mudam, e não há auto-retry do deploy.
|
||
|
||
**Primeiro deploy (verificação ao vivo):** o caminho de estreia do OPS104 é
|
||
mergear em `main` e observar o run nascer sozinho: log do `preflight`
|
||
(`should_deploy=true`), `verify` verde, staging publicado e `deploy-production`
|
||
aguardando aprovação. Para validar a OPS107, deixe esse run `waiting`, dispare
|
||
um segundo (merge/dispatch) e confirme que o `deploy-staging` dele roda com o
|
||
grupo livre; aprove só o escolhido e rejeite o outro. Para o cutover OPS71, confira no log do job
|
||
`deploy-production` o `set-url` do workspace (origem Forgejo → GitHub), o
|
||
`deployed_sha` lido do container e o guard "already deployed" num segundo run
|
||
do mesmo SHA. Na OPS103, staging primeiro (bootstrap abaixo), inspeção humana,
|
||
aprovação, produção.
|
||
|
||
## Onde roda cada coisa
|
||
|
||
| Máquina | Papel |
|
||
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| **GitHub hosted** (ubuntu) | CI de PR (`ci-pr.yml`, job `checks`) e job `verify` do `deploy.yml` (suíte full). Nunca toca o homeserver. |
|
||
| **homeserver** (8c/16GB) | todo o stack (`~/stack/docker-compose.yml`): postgres `teqo_1313`, forgejo, registry `localhost:5000`, cloudflared, `teqo-1313` na 127.0.0.1:1313 e `teqo-staging` na 127.0.0.1:1314 (OPS103; DB `teqo_staging`, env `~/stack/teqo-staging.env`, bucket `teqo-media-staging`); **runner self-hosted do GitHub** (labels `self-hosted`, `homeserver`; instalado 2026-08-19 — Issue #113 OPS71-INFRA; `~/actions-runner` v2.336.0, systemd user `actions.runner.fsolla-teqo.teqo-1313-runner.service` — `systemctl --user status/restart`); workspace `~/teqo-deploy` (clone de `github.com/fsolla/teqo`, público); o clone operacional da C225 `~/teqo-falas-web` é separado e guarda somente código/dependências, com estado durável em `~/falas-web` |
|
||
| **workstation** | dev/agentes apenas — o runner do Forgejo foi **desligado** no cutover (o schedule do `ci.yml` antigo não tem mais razão; religar é reversível) |
|
||
|
||
Segredos **não** ficam no GitHub: o script sourceia o env file do ambiente
|
||
(`~/stack/teqo-1313.env` em produção, `~/stack/teqo-staging.env` em staging) e
|
||
`~/stack/.env` (chmod 600) no próprio homeserver. O repo GitHub é público — o
|
||
clone do workspace não precisa de credencial. No GitHub ficam apenas os
|
||
secrets de integração: `FORGEJO_API_TOKEN` (flips pós-merge) e
|
||
`CURSOR_API_KEY` (archive helper, dormente).
|
||
|
||
## Garage e uploads de mídia
|
||
|
||
O runtime usa o endpoint S3 do arquivo de ambiente. O endpoint documentado
|
||
`http://host.docker.internal:3900` depende do gateway da rede Docker; depois de
|
||
uma alteração nas redes, o alias `host-gateway` pode resolver para um gateway
|
||
que não alcança o Garage. Nesse caso, o upload do Payload fica esperando o
|
||
`PutObject` e a interface permanece em **Enviando...**, embora o PostgreSQL e o
|
||
resto do site estejam saudáveis.
|
||
|
||
O `scripts/deploy-homeserver.sh` trata esse caminho antes das migrations: ele
|
||
sonda o endpoint a partir da imagem migrator, testa os gateways IPv4 da rede
|
||
`stack_default` quando o alias falha e gera um override operacional por
|
||
ambiente (`~/stack/docker-compose.media-production.yml` ou
|
||
`~/stack/docker-compose.media-staging.yml`). O override altera somente
|
||
`S3_ENDPOINT` nos dois serviços do ambiente selecionado; credenciais e arquivos
|
||
de ambiente não são reescritos. Depois do rollout, o mesmo endpoint é sondado de
|
||
dentro do container e uma falha executa o rollback.
|
||
|
||
O probe é fail-closed: se nenhum gateway responder, o deploy para antes de
|
||
aplicar migrations. O override é um artefato operacional do stack, não um
|
||
arquivo versionado. Para diagnosticar o estado atual:
|
||
|
||
```bash
|
||
ssh homeserver
|
||
cd ~/stack
|
||
set -a; source ~/stack/teqo-1313.env; set +a
|
||
docker network inspect -f '{{range .IPAM.Config}}{{println .Gateway}}{{end}}' stack_default
|
||
docker exec teqo-1313 node -e "fetch(process.env.S3_ENDPOINT, {signal: AbortSignal.timeout(5000)}).then(r => console.log(r.status)).catch(e => { console.error(e); process.exit(1) })"
|
||
```
|
||
|
||
O comando `docker exec` deve concluir em poucos segundos, mesmo que o Garage
|
||
responda `403` à raiz; timeout ou `ENETUNREACH` significa que o caminho do
|
||
container ainda não alcança o bucket.
|
||
|
||
## Staging (OPS103)
|
||
|
||
Alvo real e descartável para validar o SHA (migração/build/rollout/smoke) antes
|
||
de produção. Mesmo run, mesmo `verify`, mesmo script — o que muda é
|
||
`TEQO_ENV=staging` e o environment `staging` (sem reviewer; a aprovação que
|
||
importa é a de `production`).
|
||
|
||
| Item | Staging | Produção |
|
||
| ----------- | ------------------------------------------------------------------------------ | ------------------------------------------ |
|
||
| Container | `teqo-staging` (127.0.0.1:1314) | `teqo-1313` (127.0.0.1:1313) |
|
||
| Migrator | `teqo-staging-migrate` | `teqo-1313-migrate` |
|
||
| DB | `teqo_staging` | `teqo_1313` |
|
||
| Env file | `~/stack/teqo-staging.env` | `~/stack/teqo-1313.env` |
|
||
| Imagens | `localhost:5000/teqo-staging(-migrator):$SHA` | `localhost:5000/teqo-1313(-migrator):$SHA` |
|
||
| Bucket | `teqo-media-staging` | `teqo-media` |
|
||
| URL | `https://staging.jorgesolla1313.com.br` (noindex) | `https://jorgesolla1313.com.br` |
|
||
| Build proxy | `teqo-staging-build-proxy` (127.0.0.1:5434) | `teqo-1313-build-proxy` (127.0.0.1:5433) |
|
||
| Lock | `/tmp/teqo-deploy.lock` (compartilhado — compose/workspace/registry são um só) | idem |
|
||
|
||
### Bootstrap do homeserver (uma vez, manual — fora do repo)
|
||
|
||
1. **DB:** criar `teqo_staging` no Postgres do stack
|
||
(`docker compose exec postgres createdb -U teqo teqo_staging`).
|
||
2. **Env file:** `~/stack/teqo-staging.env` (chmod 600) com `DATABASE_URL`
|
||
apontando para `teqo_staging`, `PAYLOAD_SECRET` próprio,
|
||
`NEXT_PUBLIC_SITE_URL=https://staging.jorgesolla1313.com.br`,
|
||
`REVALIDATE_SECRET` próprio, `S3_*` do bucket `teqo-media-staging` e
|
||
`STAGING_TEST_ACCOUNT_PASSWORD` (senha sintética da conta de teste dos
|
||
agentes — OPS125; nunca commitada).
|
||
3. **Compose:** adicionar `teqo-staging` e `teqo-staging-migrate` ao
|
||
`~/stack/docker-compose.yml`, espelhando os serviços de produção
|
||
(`pull_policy: never`, label `org.opencontainers.image.revision` **por
|
||
serviço**, healthcheck, `extra_hosts: host-gateway`) e a porta
|
||
`127.0.0.1:1314`.
|
||
4. **Bucket:** criar `teqo-media-staging` no Garage com key própria — nunca
|
||
reutilizar a key/bucket de produção.
|
||
5. **Tunnel/DNS:** ingress `staging.jorgesolla1313.com.br` →
|
||
`http://localhost:1314` no cloudflared + DNS no Cloudflare. **FEITO em
|
||
2026-09-15** (túnel `home` remotely-managed, `network_mode: host`): no
|
||
ingress (config API, versão 7) foi inserido `staging.jorgesolla1313.com.br
|
||
→ http://localhost:1314` antes do catch-all `http_status:404`; CNAME
|
||
`staging` → `<tunnel-id>.cfargotunnel.com` (proxied) criado na zona
|
||
`jorgesolla1313.com.br` (`2ae7416abc4ea930db5ce6782504c578`) via
|
||
`CLOUDFLARE_API_TOKEN` em `~/Code/infra-solla/.env`; `docker restart
|
||
cloudflared` aplicou. Validação: `https://staging.jorgesolla1313.com.br`
|
||
responde 200 (`/`, `/campanha/login`, `/admin`) pela edge com o staging
|
||
container. Opcional (defesa em profundidade): header `X-Robots-Tag:
|
||
noindex` no ingress — o noindex versionado (robots.ts + metadata, OPS103)
|
||
já cobre crawlers.
|
||
6. **GitHub Environments:** criar `staging` (sem reviewer) e `production`
|
||
(required reviewer `fsolla`) em Settings → Environments — **antes do
|
||
primeiro dispatch** (environment `production` sem reviewer é fail-open).
|
||
7. **Migrate + seed sintético (antes do primeiro dispatch):** no homeserver,
|
||
com o workspace em `~/teqo-deploy`, criar o proxy de staging manualmente
|
||
(o deploy também cria, idempotente), migrar e semear. O guard local passa
|
||
porque o alvo é `127.0.0.1` — **sem** `ALLOW_REMOTE_DB`:
|
||
|
||
```bash
|
||
ssh homeserver
|
||
cd ~/teqo-deploy && pnpm install
|
||
docker inspect teqo-staging-build-proxy >/dev/null 2>&1 || docker run -d \
|
||
--name teqo-staging-build-proxy --network stack_default \
|
||
--restart unless-stopped -p 127.0.0.1:5434:5434 \
|
||
alpine/socat TCP-LISTEN:5434,fork TCP:postgres:5432
|
||
set -a; source ~/stack/teqo-staging.env; set +a
|
||
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5434}"
|
||
pnpm migrate
|
||
pnpm db:seed:minimal
|
||
```
|
||
|
||
Nunca copiar dados de produção (PII/LGPD): o seed mínimo é sintético.
|
||
|
||
8. **Conta de teste dos agentes (OPS125):** criar/atualizar a conta
|
||
`coordinator` sintética reutilizável para testes manuais em staging (o passo
|
||
obrigatório de verificação pós-deploy do `work-issue` saiu na OPS128; a infra
|
||
fica). Com o env de staging exportado e o `DATABASE_URL` reescrito
|
||
para o proxy do build (`127.0.0.1:5434`):
|
||
|
||
```bash
|
||
ssh homeserver
|
||
cd ~/teqo-deploy && pnpm install
|
||
set -a; source ~/stack/teqo-staging.env; set +a
|
||
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5434}"
|
||
STAGING_TEST_ACCOUNT_CONFIRM=1 pnpm campaign:staging:test-account
|
||
```
|
||
|
||
Idempotente (upsert por email) — re-rodar após recriar o banco de staging.
|
||
O guard recusa qualquer alvo que não seja `teqo_staging`: `ALLOW_REMOTE_DB`,
|
||
`TEQO_ENV` ≠ `staging` e host fora do allowlist local falham fechado (o nome
|
||
do banco é o discriminador honesto — o host é reescrito para `127.0.0.1` pelo
|
||
proxy). A credencial (`agente-teste@teqo.invalid` + a senha em
|
||
`STAGING_TEST_ACCOUNT_PASSWORD`) é fornecida pelo humano que opera o
|
||
homeserver **no momento do teste**; nunca entra no PR/Issue nem no env do
|
||
agente, e o agente nunca recebe `DATABASE_URL` de staging/produção.
|
||
Nota: o `teqo_staging` deste homeserver é **cópia da produção** (com PII
|
||
real) — a linha "o seed mínimo é sintético" do passo 7 acima está
|
||
desatualizada; a correção dos docs é débito do OPS103, dono do ambiente.
|
||
|
||
### Limites do staging (o que ele NÃO valida)
|
||
|
||
- e2e automatizado (o full continua no `verify`, que valida o commit);
|
||
- carga, caos, multi-região;
|
||
- integrações origin-bound sem configuração própria: WebAuthn/passkeys,
|
||
Google OAuth e envio de e-mail (Resend). "Staging verde" ≠ "produção
|
||
garantida" — é validação de migração/build/rollout/smoke + inspeção humana;
|
||
- dados são sintéticos; nunca PII real.
|
||
|
||
## Cutover OPS71 (passos manuais, ordem)
|
||
|
||
1. Remotes locais (config compartilhada pelos worktrees):
|
||
`git remote set-url origin git@github.com:fsolla/teqo.git` e
|
||
`git remote add forgejo ssh://git@192.168.15.142:2222/fsolla/teqo.git`
|
||
(ou `https://git.solla.dev/fsolla/teqo.git`) — o repo Forgejo congela em
|
||
main; o tracker (Issues) segue vivo por API.
|
||
2. GitHub repo: secrets `FORGEJO_API_TOKEN` + `CURSOR_API_KEY`
|
||
(Settings → Secrets and variables → Actions) e **`Allow auto-merge`**
|
||
(Settings → General → Pull Requests; ou `PATCH /repos/...` com
|
||
`allow_auto_merge: true`). Repo é público: em Settings → Actions →
|
||
General, defina fork PR workflows como não executar (ou exigir aprovação).
|
||
3. PAT local: criar token GitHub com escopo `repo` e exportar `GITHUB_TOKEN`
|
||
(para `node scripts/github-pr.mjs` e `pnpm configure:branch-protection`).
|
||
4. `GITHUB_TOKEN=… pnpm configure:branch-protection` — required check
|
||
`CI (PR) / checks` (literal de match: `checks` — o GitHub casa pelo nome do
|
||
check-run; a UI exibe workflow/job; pin PR #742), 0 reviews,
|
||
`enforce_admins: true`. **Antes do primeiro push** (senão o PR mergearia
|
||
sem required check).
|
||
5. **Desligar o runner do Forgejo** na workstation (ex.:
|
||
`systemctl --user stop forgejo-runner`) — após o primeiro PR GitHub
|
||
validado; para o schedule do `ci.yml` antigo.
|
||
6. Instalar o runner self-hosted do GitHub no homeserver (labels
|
||
`self-hosted` + `homeserver`; usuário com docker + acesso a `~/stack`;
|
||
registrar em infra-solla: `STATE.md`).
|
||
7. Push do PR do OPS71 → CI verde → auto-merge → flip da Issue no Forgejo →
|
||
deploy manual de validação.
|
||
|
||
Rollback do cutover (se o GitHub falhar cedo): religar o runner do Forgejo e
|
||
abrir PRs no Forgejo a partir do main congelado — os workflows de
|
||
`.forgejo/workflows/` foram removidos na OPS71 Fase 2, mas restauram do git
|
||
history (`git revert` do PR da remoção, ou clone do main congelado do Forgejo)
|
||
— remotes: `git remote set-url origin ssh://git@192.168.15.142:2222/fsolla/teqo.git`.
|
||
|
||
## Rollback (do deploy)
|
||
|
||
A imagem anterior **não** fica mais local após a limpeza pós-deploy (INF3/F2:
|
||
`docker builder prune` + remoção das tags locais antigas, exceto o SHA em uso)
|
||
— o registry `localhost:5000` **nunca deleta** e é a fonte do rollback. O
|
||
compose anterior fica em `~/stack/docker-compose.yml.pre-<sha-do-deploy-que-falhou>`
|
||
(um por deploy; como o staging roda antes no mesmo SHA, o backup de produção é
|
||
o compose já com a linha do staging — o restore devolve o arquivo inteiro, o
|
||
que é o correto).
|
||
|
||
```bash
|
||
ssh homeserver
|
||
cd ~/stack
|
||
# 1. apontar o compose de volta para o SHA anterior (o que estava rodando):
|
||
# o backup do deploy que falhou é o compose com os tags antigos
|
||
ls docker-compose.yml.pre-* # escolha o anterior ao deploy ruim
|
||
cp docker-compose.yml.pre-<sha-anterior> docker-compose.yml
|
||
# 2. puxar do registry (a imagem antiga já não fica local após a limpeza;
|
||
# o compose referencia a tag qualificada — INF13 — e o pull basta):
|
||
docker pull localhost:5000/teqo-1313:<sha-anterior>
|
||
# Cuidado (janela de transição pós-INF13): backups gerados ANTES do fix
|
||
# usam tags bare (`image: teqo-1313:<sha>`); se o backup escolhido tiver
|
||
# tag sem prefixo, recrie o alias antes de subir:
|
||
# docker tag localhost:5000/teqo-1313:<sha-anterior> teqo-1313:<sha-anterior>
|
||
# 3. subir:
|
||
docker compose up -d teqo-1313
|
||
```
|
||
|
||
Para **staging**, o mesmo procedimento com as identidades do ambiente
|
||
(`teqo-staging`, `localhost:5000/teqo-staging:<sha-anterior>`,
|
||
`docker compose up -d teqo-staging`) — o rollback de um ambiente não toca o
|
||
container do outro (só restaura as linhas de imagem do arquivo compartilhado).
|
||
|
||
**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 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 o próximo deploy completo publica.
|
||
|
||
## Falhas conhecidas
|
||
|
||
| Sintoma | Causa | Tratamento |
|
||
| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| Prod regride para um SHA antigo após re-dispatch fora de ordem | O `flock` serializa mas não ordena; o "already deployed" não pega revision diferente | Evitar re-dispatchar SHA antigo (o requeue da OPS104 sempre despacha o head atual); se acontecer, re-dispatchar o `main` atual (rollback manual disponível na seção Rollback). Revisitar se uma regressão fora-de-ordem acontecer mesmo com o requeue |
|
||
| Job verde sem deploy ("already deployed") | O container rodando já tem a revision do SHA do job (ex.: run duplicado da mesma HEAD — `workflow_dispatch`, ou push + requeue da OPS104) | Nada a fazer — o site já roda esse SHA. Forçar rebuild da MESMA HEAD não é suportado via dispatch: edite o tag da imagem no compose (`image: localhost:5000/teqo-1313:<sha>-rebuild`) e `docker compose up -d`, ou espere um commit novo |
|
||
| Merge durante aprovação pendente de produção: staging do run novo ficava `pending` (resolvido) | O job `waiting` de approval **segurava o grupo** `deploy-homeserver` (a avaliação de concurrency acontece antes do approval — comportamento do GitHub) | **Resolvido na OPS107:** o `concurrency` vive só no `deploy-staging` (que nunca espera approval); o `deploy-production` não ocupa lane, então runs novos fazem staging e ficam aprováveis. Aprove só o run escolhido e rejeite os demais — runs não aprovados não publicam; multi-approve = o último deploy executado vence (rollback manual se um SHA indesejado publicar) |
|
||
| Merge durante `deploy-production` `in_progress` não gera requeue | O push nasce, o `preflight` vê o deploy ativo e pula; o requeue daquele run já rodou (logo após o staging) | Nada nasce até o próximo merge ou `workflow_dispatch`. Revisitar na primeira ocorrência real → 2º requeue após produção verde, ou refinar o guard |
|
||
| Job de deploy nunca roda / runner offline | Runner self-hosted do homeserver não instalado ou parado (passo manual do cutover OPS71) | Instalar/religar o runner (labels `self-hosted`, `homeserver`); os PRs e o `verify` não dependem dele — só os deploys |
|
||
| Deploy dispara mas o job `verify` falha | Regressão real na suíte full | Corrigir e re-dispatchar — o homeserver nunca é tocado |
|
||
| `No such image` ao recriar o container fora de deploy (ex.: `compose up -d` pós-reboot/migração) | O compose referenciava uma tag local bare (`teqo-1313:<sha>`) que desapareceu sem recriação (achado INF8/INF13, 24/08: só restava a ref do registry) | Corrigido no INF13: o compose passa a referenciar a tag **qualificada** (`localhost:5000/teqo-1313:<sha>`) a partir do primeiro deploy pós-fix — até lá, produção ainda roda o compose bare (recrie o alias via `docker tag` se necessário). Recovery geral: `docker pull localhost:5000/teqo-1313:<sha>` (+ `docker tag` se o compose em uso tiver tag bare) e `docker compose up -d` |
|
||
| 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; 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 do ambiente (prod `teqo-1313-build-proxy` em 127.0.0.1:5433; staging `teqo-staging-build-proxy` em 127.0.0.1:5434, na `stack_default`, 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 |
|
||
| Workspace clone sem o SHA novo | O workspace antigo apontava para o Forgejo local (pré-OPS71) e o fetch não achou o SHA | O script agora re-aponta `origin` para `TEQO_REPO_URL` (idempotente) antes do fetch — se ainda falhar, `git -C ~/teqo-deploy remote -v` e corrigir manualmente |
|
||
| 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 |
|
||
| `deploy-staging` falha cedo (`teqo-staging.env`/serviço ausente) | Bootstrap do staging incompleto (DB, env file, serviços no compose ou environment `staging`) | Rodar o bootstrap da seção Staging; produção nunca é tocada (staging vermelho fail-closa o job `deploy-production`) |
|
||
| `verify` vermelho | Regressão real na suíte full (ou flake — #882/#906) | O `auto-unblock.yml` dispara o agente sozinho (OPS106): acompanhe a Issue `auto-unblock` (token) e o PR `fix/*`. Sem PR, o token fica `blocked` — inspecione o log em `~/teqo-unblock/logs/`. Correção manual segue possível (re-merge ou `workflow_dispatch`) |
|
||
| Agente de desbloqueio não disparou (token inexistente) | Bootstrap do homeserver incompleto — opencode sem auth, Node/pnpm fora do PATH do runner, PAT ausente/sem push, Docker/porta 5432, label ausente | Rodar `pnpm unblock:check` no homeserver (com `GITHUB_TOKEN` e `AUTOMERGE_PAT` exportados) e seguir a seção Auto-unblock (OPS106) abaixo |
|
||
| Launcher `auto-unblock` morre em `fetch failed` (nenhum token criado) | Node fetch ignora HTTP(S)\_PROXY sem NODE_USE_ENV_PROXY (Node ≥24); no homeserver o GitHub só sai pelo proxy CONNECT do runner | OPS126: `scripts/auto-unblock.sh` liga o `NODE_USE_ENV_PROXY` quando há proxy no env; manter `HTTPS_PROXY/HTTP_PROXY` no `~/actions-runner/.env`. Re-dispatch ou aguarde a próxima falha do `verify` |
|
||
| Agente morre com exit 127 (`opencode`: No such file or directory) | Serviço do runner não carrega o profile → `~/.opencode/bin` fora do PATH; ou opencode nem instalado | OPS126: `resolveOpenCodeBinary` (`OPENCODE_BIN` → `$HOME/.opencode/bin` → PATH); o launcher falha fechado (token `blocked`) se faltar. Instale/autentique via `pnpm unblock:check` |
|
||
| Token `auto-unblock` preso (agente morto, sem PR) | Wrapper morreu (OOM/timeout/erro) — lock livre, token < TTL se torna `skip_comment` na próxima falha | O TTL de 3h reclaima o token automaticamente (comenta + fecha + novo agente). Antes disso, inspecione o log e a branch `fix/*`; para liberar na hora, feche a Issue token com um comentário |
|
||
| Capa do Instagram na home em `500` / link do próprio Instagram na Central sempre "Instagram indisponível no momento" | O container é IPv4-only e a rota IPv4 até o CDN do Instagram (`instagram.fssa2-1.fna.fbcdn.net`) é instável nesta rede; a API (`graph.instagram.com`) funciona, mas o arquivo não chega (`media_url` presente e download estourando) | A rede `teqo-ipv6` (`enable_ipv6`; sub-redes **fixadas** `10.0.13.0/24` + ULA `fd00:1313:1313::/64`) está anexada a `teqo-1313`/`teqo-staging` no compose (backup `docker-compose.yml.pre-ipv6-*`). Com duas redes, o `host-gateway`/default route saem pela sub-rede nova: o Garage (3900) precisa do par de regras de ufw IN + FWD para `10.0.13.0/24` (espelha o allow do `10.0.11.0/24`) — sem elas o upload da peça falha com `connect ETIMEDOUT 10.0.0.1:3900`. Se voltar: confirme IPv6 no container (`/proc/net/if_inet6`), o fetch do CDN e o TCP/HTTP do Garage de dentro dele; o resolver agora faz retry com teto de conexão. Post-mortem: `docs/postmortems/2026-09-25-instagram-cdn-ipv4-instavel.md` |
|
||
|
||
## Auto-unblock (OPS106)
|
||
|
||
Uma falha do job `verify` dispara **um** agente autônomo de desbloqueio no
|
||
homeserver (workflow `.github/workflows/auto-unblock.yml`, runner self-hosted),
|
||
com o mesmo contexto de `pnpm worktree fix`: worktree `fix/*` provisionado pelo
|
||
dono (`scripts/worktree.mjs fix --headless`), bancos **locais** `teqo_wt*`/
|
||
`*_test`, skill `/bug-fix` headless (`opencode run --command bug-fix`), PR Ready
|
||
com auto-merge nativo. O registro visível é a Issue token `auto-unblock`, que
|
||
linka o run vermelho; o token aberto + o `flock /tmp/teqo-unblock.lock` são o
|
||
single-flight (falha com agente ativo só comenta — nunca enfileira).
|
||
|
||
Guardrails: banco alvo tem de ser local e casar `teqo_wt*`/`*_test` (senão o
|
||
agente nem lança; nunca `teqo_1313`/`teqo_staging`, nunca `~/stack/*.env`);
|
||
`DATABASE_URL`/`ALLOW_REMOTE_DB`/`TEQO_ENV` são removidos do env do agente; o
|
||
agente roda com o PAT (`AUTOMERGE_PAT`) só no processo destacado, via credential
|
||
helper não-persistente; cap de 4h; produção continua só com aprovação humana e
|
||
o deploy não tem auto-retry.
|
||
|
||
### Bootstrap (uma vez, manual — fora do repo)
|
||
|
||
1. **opencode no homeserver:** instalado e autenticado no usuário do runner
|
||
(`opencode auth login`; o provider do modelo default — `deepseek/deepseek-flash`
|
||
via env `OPENCODE_WORKTREE_MODEL` para override — precisa estar presente).
|
||
O serviço do runner **não** carrega o profile interativo, então
|
||
`~/.opencode/bin` fica fora do PATH: o OPS126 resolve o binário
|
||
(`OPENCODE_BIN` → `$HOME/.opencode/bin/opencode` → PATH) e o launcher falha
|
||
fechado se não achar. Rede: o homeserver só alcança o GitHub pelo proxy
|
||
CONNECT local (`github-tunnel` + `http-proxy-socks`; `~/actions-runner/.env`
|
||
com `HTTPS_PROXY/HTTP_PROXY`) — o `scripts/auto-unblock.sh` liga o
|
||
`NODE_USE_ENV_PROXY` para o `fetch` do Node respeitá-lo.
|
||
2. **PAT:** secret `AUTOMERGE_PAT` no repo com os escopos `contents: write`,
|
||
`pull requests: write` e `issues: write` (o agente faz push/PR e o outcome
|
||
comenta/fecha a Issue; o built-in token do job não sobrevive ao processo
|
||
destacado). O armar do auto-merge continua no `agent-pr-ready-automerge.yml`.
|
||
3. **Docker/Postgres dev:** o runner user precisa de Docker; o agente sobe o
|
||
container dev do repo (`docker compose -p teqo` com o `docker-compose.yml`
|
||
da raiz — serviço `postgres`, init em `docker/postgres/init`; porta 5432) —
|
||
**não** é o Postgres do stack (`stack_default`), então nenhum banco de
|
||
prod/staging é alcançável pelo env do agente.
|
||
4. **Deps do checkout:** o workflow roda `pnpm install --frozen-lockfile` no
|
||
checkout do runner **antes** do launcher — o wrapper destacado carrega
|
||
`scripts/worktree.mjs`, que importa `dotenv`/`pg` no load, e o
|
||
`actions/checkout` limpa `node_modules` no runner self-hosted. O `--check`
|
||
valida `node_modules` (rode-o de um checkout com deps instaladas).
|
||
5. **Dir do agente:** `$HOME/teqo-unblock/{logs,state,worktrees}` (criado pelo
|
||
próprio launcher; `WORKTREES_ROOT=$HOME/teqo-unblock/worktrees`).
|
||
6. **Label:** `auto-unblock` (o launcher cria idempotentemente; o token nunca
|
||
recebe `ready`/`in-progress` e não entra na fila de claim).
|
||
7. **Smoke:** com `GITHUB_TOKEN` (built-in/PAT) e `AUTOMERGE_PAT` exportados,
|
||
`pnpm unblock:check` valida node/git/pnpm/flock/timeout/docker (binário +
|
||
daemon)/opencode/auth, porta 5432, dirs, deps do repo, label e
|
||
`permissions.push` do PAT — fail-closed com a mensagem acionável.
|
||
|
||
### Operação
|
||
|
||
- **Token `auto-unblock` aberto** = agente ativo (ou órfão dentro do TTL de 3h).
|
||
Falha nova com token aberto: comenta no token, não dispara.
|
||
- **Sem PR ao fim:** token recebe `blocked` e fica aberto (gate humano) — o log
|
||
está em `~/teqo-unblock/logs/unblock-<run-id>.log`.
|
||
- **Limpeza:** worktrees/databases acumulam um punhado por falha; apague com
|
||
`pnpm worktree kill` de dentro do worktree (dropa os bancos do worktree).
|
||
Gatilho para automatizar retention: **≥3 worktrees/DBs `teqo-unblock`
|
||
acumulados** (`ls ~/teqo-unblock/worktrees`).
|
||
|
||
## Backup passivo no Forgejo (OPS76-FOLLOWUP, 2026-08-21)
|
||
|
||
O GitHub é a única fonte (main, branches, PRs, CI, tracker). O Forgejo mantém
|
||
um **backup passivo** via **pull mirror nativo**: o repo `teqo-backup` no
|
||
Forgejo (`https://git.solla.dev/fsolla/teqo-backup`) espelha o GitHub
|
||
automaticamente — **não serve push, não recebe PRs, é somente leitura**.
|
||
|
||
- **Repo:** `fsolla/teqo-backup` no Forgejo (`mirror: true`), criado via
|
||
`POST /api/v1/repos/migrate` com `clone_addr=https://github.com/fsolla/teqo.git`,
|
||
`service=github`, `mirror=true`, intervalo **8h** (`mirror_interval`).
|
||
- **Automação:** o próprio Forgejo sincroniza no intervalo (sem workflow).
|
||
Force sync manual: `POST /api/v1/repos/fsolla/teqo-backup/mirror-sync`
|
||
(o "Synchronize now" da UI).
|
||
- **Cobertura:** espelha todas as branches (54/54 verificadas) e tags; o
|
||
histórico do mirror reflete o do GitHub (que é a convergência OPS75 +
|
||
OPS76). Não espelha issues/PRs/labels (GitHub é o tracker).
|
||
- **Validação:** `git ls-remote` do `teqo-backup` deve retornar o mesmo SHA
|
||
de `refs/heads/main` do GitHub; conferir em `mirror_updated` o último sync.
|
||
- **O repo `fsolla/teqo` original do Forgejo** fica **congelado** no ponto da
|
||
convergência (OPS75) — arquivo histórico, sem mais uso.
|
||
|
||
## Segurança (decisão deliberada)
|
||
|
||
- Os jobs `deploy-staging`/`deploy-production` usam
|
||
`runs-on: [self-hosted, homeserver]` — executam como o usuário do runner no
|
||
homeserver. Estão **apenas** no `deploy.yml` (início automático por `push`
|
||
em `main` ou `workflow_dispatch`; ambos passam pelo mesmo `verify` full),
|
||
nunca no `ci-pr.yml`; o hosted (`preflight`/`verify`/`requeue`) não tem
|
||
acesso ao homeserver e o self-hosted não recebe secrets de produção (as
|
||
envs vivem no próprio homeserver, inclusive as de staging — nunca como
|
||
GitHub secrets). `main` só anda por PR mergeado com CI verde; o deploy só
|
||
roda com `verify` full verde (**exceção OPS106**: o agente de desbloqueio é
|
||
um job self-hosted disparado por `verify` vermelho — launcher fino + agente
|
||
destacado com guardrails fail-closed; ver seção Auto-unblock). Fork-PRs não
|
||
rodam CI (same-repo gate + setting do repo).
|
||
- **Agente de desbloqueio (OPS106):** o `auto-unblock.yml` roda código na
|
||
máquina de produção justamente quando o `verify` falha. Mitigações: gatilho
|
||
restrito (push em `main` + job `verify` falho conferido pela jobs API),
|
||
launcher fino (só decide/spawna e sai), `flock` + Issue token
|
||
(single-flight), agente sem `RUNNER_TRACKING_ID` com cap de 4h, banco local
|
||
`teqo_wt*`/`*_test` fail-closed, env sem
|
||
`DATABASE_URL`/`ALLOW_REMOTE_DB`/`TEQO_ENV`, PAT por credential helper
|
||
não-persistente, entrega só por PR Ready com auto-merge (nunca push direto
|
||
em `main`) e produção ainda com approval humano. Risco residual aceito: o
|
||
agente tem Docker e o PAT no homeserver — sem sandbox/usuário dedicado
|
||
nesta fatia (revisitar só com evidência de abuso).
|
||
- Segredos nunca ecoados: sem `set -x`, senhas via `--password-stdin` /
|
||
build-secrets; envs só no homeserver.
|
||
- Staging sem PII real (seed sintético), bucket/DB/env próprios e noindex —
|
||
nunca compartilha credencial, bucket ou dados com produção.
|
||
|
||
## C149 — Google OAuth da agenda (envs novas + GCP)
|
||
|
||
O botão **"Conectar com o Google"** da agenda usa um OAuth client do GCP. Sem
|
||
as envs, o diálogo cai no runbook antigo da service account — que segue como
|
||
fallback e não foi removida por esta entrega.
|
||
|
||
1. No GCP (mesmo projeto da service account), crie um OAuth client **Web
|
||
application** com os redirect URIs:
|
||
- `https://jorgesolla1313.com.br/campanha/agenda/google-oauth/callback`
|
||
- `http://localhost:<porta>/campanha/agenda/google-oauth/callback` (dev)
|
||
2. Publique o app em produção — **unverified** é aceito (uso interno). Em
|
||
"Testing" o refresh token expira em ~7 dias e o estado vira `erro` na UI.
|
||
3. Adicione ao `~/stack/teqo-1313.env` (homeserver, chmod 600):
|
||
`GOOGLE_CALENDAR_OAUTH_CLIENT_ID` e `GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET`.
|
||
4. Deploy normal (`deploy.yml` — o merge em `main` já dispara; `workflow_dispatch`
|
||
segue como escape). O callback monta o redirect a partir de
|
||
`NEXT_PUBLIC_SITE_URL` — precisa bater exatamente com o URI registrado no
|
||
passo 1.
|
||
|
||
Desconectar no Teqo apaga o refresh token daquele lado; para revogar o acesso
|
||
de fato, revogue o app em `myaccount.google.com/permissions` (a UI orienta o
|
||
passo).
|
||
|
||
## Busca por tema e demais capacidades de IA — envs do app
|
||
|
||
As chamadas de IA do app (busca por tema do acervo e da Central, título de
|
||
demanda, metadados de corte, catalogação de peças, reranking do Sollinha e
|
||
transcrição de voz) rodam no **runtime do container** e leem as chaves do
|
||
`env_file` do compose (`~/stack/teqo-1313.env` / `~/stack/teqo-staging.env`).
|
||
O `~/stack/.env` do homeserver (que o `deploy-homeserver.sh` sourceia para
|
||
registry e BuildKit secrets) **não** chega ao runtime. Chave ausente não
|
||
derruba o boot: a capacidade degrada em silêncio — a busca por tema mostra
|
||
"indisponível agora" e o título cai no fallback determinístico (incidente
|
||
2026-09-24; post-mortem em
|
||
`docs/postmortems/2026-09-24-busca-por-tema-indisponivel.md`).
|
||
|
||
Pré-requisitos de produção e staging: `DEEPSEEK_API_KEY` (busca por tema,
|
||
Sollinha, título/metadados) e `DEEPINFRA_API_KEY` (transcrição de voz). O
|
||
`~/stack/.env` do homeserver já tem as duas; copie as linhas para o env file
|
||
do app (idempotente):
|
||
|
||
```bash
|
||
ssh homeserver
|
||
for f in ~/stack/teqo-1313.env ~/stack/teqo-staging.env; do
|
||
grep -q '^DEEPSEEK_API_KEY=' "$f" || grep '^DEEPSEEK_API_KEY=' ~/stack/.env >> "$f"
|
||
grep -q '^DEEPINFRA_API_KEY=' "$f" || grep '^DEEPINFRA_API_KEY=' ~/stack/.env >> "$f"
|
||
done
|
||
```
|
||
|
||
O deploy seguinte recria o container com as chaves. Desde o incidente, o
|
||
`deploy-homeserver.sh` audita o env do container recém-saído do healthcheck e
|
||
anota no run (`::warning::`) qualquer chave de feature ausente — não falha o
|
||
deploy (quais chaves existem é decisão humana), mas a perda de capacidade
|
||
aparece no próprio run.
|
||
|
||
### Egress até o provedor — proxy na tailnet
|
||
|
||
A rota do homeserver (Vivo) até `api.deepseek.com` (CloudFront) é
|
||
intermitente — medido em 2026-09-25: 1/3 de connect, enquanto a estação de
|
||
trabalho (que sai pelo exit node `mail-relay` da tailnet) dava 3/3. Para não
|
||
depender dessa rota, o egress do app passa por um **proxy HTTP na estação**
|
||
(`100.94.122.26:3128`), que usa o exit node:
|
||
|
||
- **Onde roda:** container Docker `teqo-egress-proxy` na estação
|
||
(`--network host`, `--restart unless-stopped`, imagem `node:24-alpine`),
|
||
com o script `scripts/egress-proxy.mjs` deste repo (cópia operacional em
|
||
`~/teqo-egress-proxy/proxy.mjs`). Bind só na IP da tailnet e allowlist de
|
||
cliente = o homeserver (`100.119.220.31`); só túneis `CONNECT` para 443.
|
||
- **Como o app usa:** `NODE_USE_ENV_PROXY=1` + `HTTPS_PROXY=http://100.94.122.26:3128`
|
||
- `NO_PROXY` (internos) nos env files de produção e staging. O Node 24 honra
|
||
o proxy no `fetch()` — zero mudança de código.
|
||
- **Subir/atualizar:**
|
||
`docker run -d --name teqo-egress-proxy --restart unless-stopped --network host -e PROXY_HOST=100.94.122.26 -e PROXY_PORT=3128 -e PROXY_ALLOWED_CLIENTS=100.119.220.31,127.0.0.1,::1 -v ~/teqo-egress-proxy:/app:ro node:24-alpine node /app/proxy.mjs`
|
||
- **Limite conhecido:** a estação é ponto único — desligada, as capacidades
|
||
de IA degradam (fail-safe) e o probe de egress do deploy avisa. Se um dia o
|
||
`mail-relay` (sempre ligado) tiver acesso SSH, mover o proxy para lá é a
|
||
evolução natural.
|
||
- **Diagnóstico:** `docker logs teqo-egress-proxy`; do container,
|
||
`docker exec teqo-1313 node -e "fetch('https://api.deepseek.com/').then(r=>console.log(r.status)).catch(e=>console.log(e.cause?.code))"`.
|
||
|
||
Desde 2026-09-25 o `deploy-homeserver.sh` também **probe o egress de IA de
|
||
dentro do container** (após o healthcheck, antes do smoke) e emite
|
||
`::warning::` se `api.deepseek.com` não responder — não falha o deploy.
|
||
|
||
## OPS79 — última migração da plataforma antiga → nova (vertical campanha)
|
||
|
||
Operação de dados executada em 2026-08-23. Ver assistência lógica completa:
|
||
`docs/plans/ops79-ultima-migracao-dados-campanha.md` (intenção) e
|
||
`…campanha-impl.md` (impl). **A fonte (Neon, plataforma antiga) está congelada
|
||
desde o OPS80** — zero escritas desde 2026-08-23 22:30 UTC; a URL antiga segue
|
||
viva na Vercel apenas tecnicamente (desligamento de infra é o OPS81).
|
||
|
||
**Descoberta-chave da reconciliação:** a vertical campanha JÁ estava 100% íntegra
|
||
no `teo_1313` após o OPS51 (dump Neon→homeserver) — nenhum dado de operação de
|
||
campanha ficou órfão; o time de campanha não escreveu na URL antiga depois do
|
||
OPS51. O único residual real entre as plataformas era a `signature` pública
|
||
1486 (e seu contato/subscription), cujo contato originário **não existia** no
|
||
target (o id 2221 do target é OUTRA pessoa, Jorge Solla).
|
||
|
||
### Reconciliador (read-only, repetível)
|
||
|
||
Compara contagens + junta de IDs das 13 coleções da vertical e o conteúdo
|
||
semântico das join-tables de relações contra a fonte Neon.
|
||
|
||
```bash
|
||
ssh homeserver
|
||
cd ~/teqo-deploy
|
||
git fetch origin main && git checkout main && git pull --ff-only origin main
|
||
pnpm install
|
||
set -a; source ~/stack/.env; set +a # fornece NEON_DATABASE_URL
|
||
set -a; source ~/stack/teqo-1313.env; set +a # fornece DATABASE_URL (teqo_1313)
|
||
# o host `postgres` só resolve dentro da rede stack_default; do host usa-se o
|
||
# proxy socat do build (127.0.0.1:5433):
|
||
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
|
||
NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" pnpm ops79:reconcile
|
||
```
|
||
|
||
Esperado: `19/19 PASS` (a `municipality_rels` reporta `+3 absorvida pós-OPS51` —
|
||
edições de portfólio na plataforma nova, não-dado). Exit `1` em qualquer nova
|
||
divergência. Nunca imprime PII.
|
||
|
||
### Migração do residual (one-off, com guard de confirmação)
|
||
|
||
Copia a `signature` 1486 + seu contato + subscription (a transação que o
|
||
`submitPetitionSignature` faz junto), reusando petition/consent já presentes e
|
||
criando o contato com **id novo** (nunca o 2221, ocupado por outra pessoa).
|
||
|
||
```bash
|
||
# dry-run (valida referentes e imprime o plano sem escrever):
|
||
NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" pnpm ops79:migrate --dry-run
|
||
# aplica (ESCREVE em prod — exige a flag de intenção explícita):
|
||
OPS79_MIGRATE_CONFIRM=1 NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" pnpm ops79:migrate --apply
|
||
```
|
||
|
||
Resultado registrado em 2026-08-23: contact novo **2225** (Juares Lagimar de
|
||
Souza), signature **1486** preservada (petition `fim-escala-6x1`, consent 2),
|
||
subscription **1492**; sequences ajustadas (`setval` com `true` → próximo id =
|
||
último+1). Sequências conferidas após: contact 2225 / signature 1486 /
|
||
subscription 1492. Pós-verificação: `signature` 1485=1485 (Neon×target) e
|
||
reconcile 19/19; smoke `/`, `/campanha`, `/admin` 200.
|
||
|
||
### Rollback
|
||
|
||
A migração é idempotente (fail-closed: aborta se referentes/ids divergirem). Se
|
||
precisar desfazer, DELETar as 3 linhas + ajustar sequences de volta:
|
||
`DELETE FROM signature WHERE id=1486; DELETE FROM subscription WHERE id=1492;
|
||
DELETE FROM contact_phones WHERE _parent_id=2225; DELETE FROM contact WHERE id=2225;`
|
||
e `setval('contact_id_seq',2224)`, `setval('signature_id_seq',1485)`,
|
||
`setval('subscription_id_seq',1491)`.
|
||
|
||
## OPS84 — Verificação de valores (não só entidades) da migração
|
||
|
||
Extensão do reconciliador OPS79: compara TODAS as colunas de conteúdo (não só
|
||
ids/contagens) das 13 tabelas da vertical campanha entre a fonte congelada
|
||
(Neon) e a base nova (`teqo_1313`). Foco confirmado em `vote_pledge` (votos
|
||
declarados + 3 cenários de estimativa).
|
||
|
||
**Resultado registrado em 2026-08-24:** 13/13 tabelas PASS — **0 divergências
|
||
de conteúdo**. A migração de valores está 100% íntegra. Neon e target com
|
||
dados idênticos em todas as 13 tabelas da vertical.
|
||
|
||
### Verificador de valores (read-only, repetível)
|
||
|
||
```bash
|
||
ssh homeserver
|
||
cd ~/teqo-deploy && git fetch origin main && git checkout main && git pull --ff-only origin main
|
||
pnpm install
|
||
set -a; source ~/stack/.env; set +a # fornece NEON_DATABASE_URL
|
||
set -a; source ~/stack/teqo-1313.env; set +a # fornece DATABASE_URL (teqo_1313)
|
||
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
|
||
|
||
# Todas as 13 tabelas (foco em vote_pledge):
|
||
NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" \
|
||
pnpm ops84:reconcile-values
|
||
|
||
# Tabela isolada (debug/iteração):
|
||
NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" \
|
||
pnpm ops84:reconcile-values --table vote_pledge
|
||
|
||
# Com relatório markdown (anexável ao issue):
|
||
NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" \
|
||
pnpm ops84:reconcile-values --report /tmp/ops84-report.md
|
||
```
|
||
|
||
**Esperado:** `13/13 PASS` com 0 content diffs. Exit `1` se houver divergência
|
||
de conteúdo. Colunas classified em 3 buckets:
|
||
|
||
- **content** — falha o run (aceite: "0 divergência" ou "N listadas")
|
||
- **derived** — hook-derivadas (`declared_at/by`, `estimated_at/by`,
|
||
`created_at`, `updated_at`); informativas, não falham
|
||
- **sensitive** — PII; valores nunca impressos, só contados
|
||
|
||
Colunas jsonb comparadas via `col::text` (preserva `1` vs `1.0` como
|
||
divergência; SQL NULL ≠ JSON null). Zero normalização (trim/round/coalesce).
|
||
|
||
### Fallback: Neon inacessível (OPS81 já executado)
|
||
|
||
Se o Neon estiver inacessível (OPS81 desligou a infra), usar o dump do OPS51
|
||
como base de comparação:
|
||
|
||
```bash
|
||
# Restaurar dump em scratch DB local:
|
||
createdb teqo_1313_compare_src
|
||
pg_restore -d teqo_1313_compare_src \
|
||
/srv/hdd/backups/teqo-neon-pre-migracao/teqo-neon-full-20260817-204800.dump
|
||
|
||
# Apontar NEON_DATABASE_URL para o scratch:
|
||
NEON_DATABASE_URL="postgresql://teqo:teqo@localhost:5432/teqo_1313_compare_src" \
|
||
DATABASE_URL="$DATABASE_URL" \
|
||
pnpm ops84:reconcile-values --report /tmp/ops84-report-dump.md
|
||
```
|
||
|
||
**Nota:** dump é pré-freeze (2026-08-17) — deltas legítimos entre 08-17 e
|
||
08-23 (quando o Neon foi congelado) aparecem como divergências derivadas. O
|
||
relatório declara qual base foi usada.
|
||
|
||
### Rollback
|
||
|
||
Não há escrita; "rollback" = rodar de novo (idempotente, read-only).
|
||
|
||
## C155 — backfill do acervo de falas em produção
|
||
|
||
Operação de dados do catálogo de falas (`speech`/`speechSegment`, C153/C154):
|
||
processa as 54ª–57ª legislaturas (~1.011 discursos) contra o `teqo_1313` e
|
||
imprime um relatório por legislatura + a cobertura do banco. O import é
|
||
idempotente por `sourceKey` (reexecutar atualiza metadados e pula VOD/ASR do
|
||
que já tem segmentos), então o run é resumível — falha de rede em uma
|
||
legislatura não derruba as outras.
|
||
|
||
**Pré-requisitos:** o deploy que aplicou as migrations do catálogo
|
||
(`20260913_001112_add_speech_catalog`, `20260913_001200_add_speech_segment_trgm_index`,
|
||
`20260913_145820_add_speech_search_text`, `20260913_150000_backfill_speech_search_text_trgm_index`)
|
||
já está em produção desde o deploy de `424ee311` (2026-09-13); `DEEPINFRA_API_KEY`
|
||
no `~/stack/.env` do homeserver; `~/teqo-backfill` no SHA desejado com
|
||
`pnpm install`; espaço em `/srv/hdd/backups/teqo-camara` para o cache de MP4
|
||
(o cache do C153 foi descartado com o worktree; o backfill baixa de novo).
|
||
|
||
```bash
|
||
ssh homeserver
|
||
source ~/.nvm/nvm.sh # Node 24 (engines do repo)
|
||
cd ~/teqo-backfill && git fetch origin && git checkout <SHA> && pnpm install
|
||
set -a; source ~/stack/.env; set +a # DEEPINFRA_API_KEY
|
||
set -a; source ~/stack/teqo-1313.env; set +a # DATABASE_URL + NODE_ENV=production
|
||
# o host `postgres` só resolve na rede do stack; do host usa-se o proxy socat:
|
||
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
|
||
export CAMARA_IMPORT_CONFIRM=1 # guard C155: escrita em produção
|
||
|
||
# smoke (4 discursos, ~2 min; repetir para provar idempotência: 0 criados/0 ASR):
|
||
pnpm camara:import --all --limit 1 --out /srv/hdd/backups/teqo-camara
|
||
# run completo (tmux; ~10–15h de parede, resumível):
|
||
tmux new -d -s c155 "pnpm camara:import --all --out /srv/hdd/backups/teqo-camara 2>&1 | tee -a /srv/hdd/backups/c155-run.log"
|
||
# validação pós-run (read-only):
|
||
pnpm camara:import --coverage --out /srv/hdd/backups/teqo-camara
|
||
pnpm camara:import --verify-links 3 --out /srv/hdd/backups/teqo-camara
|
||
```
|
||
|
||
O guard `CAMARA_IMPORT_CONFIRM=1` é exigido sempre que a escrita não é
|
||
provadamente local (`NODE_ENV=production`, host remoto ou `ALLOW_REMOTE_DB`);
|
||
`--coverage` e `--verify-links` são read-only e não exigem a flag. A escrita é
|
||
confinada a `speech`/`speechSegment`/rels; nenhum objeto de mídia vai para o
|
||
S3 (os MP4 ficam só no cache local). Crédito CC BY 4.0 da Câmara mantido no
|
||
admin/relatórios.
|
||
|
||
**Resultado registrado (2026-09-13/14):** **997 discursos** em produção
|
||
(54ª=1, 55ª=348, 56ª=414, 57ª=234) — 947 com trecho, 924 com vídeo, 920 com
|
||
segmentos; ASR 925 chamadas / 45,4h / ~US$1,23; LLM ~US$0,11; ~11h de máquina
|
||
(9h de runs + ~2h de retry por data). A 55ª veio pelo fallback de listagem
|
||
por ano/mês (a API responde 500 em **outubro/2018**; ~14 discursos ficaram
|
||
fora — reexecutar `pnpm camara:import --date <dia>` quando a fonte voltar).
|
||
O smoke idempotente (`--all --limit 1` duas vezes) provou 0 criados/0 ASR/0 LLM
|
||
na segunda passada; `--verify-links 3` deu 17/18 ok (1 warning transitório de
|
||
CDN). Relatórios completos em `/srv/hdd/backups/teqo-camara/reports/` e no
|
||
changelog `docs/changelog/2026-09-13-c155.md`.
|
||
|
||
### Rollback
|
||
|
||
O import é aditivo e idempotente; não há migração para desfazer. Para remover
|
||
uma legislatura (ex.: reimportar do zero), apagar os discursos e seus
|
||
segmentos — as rels (`speech_rels`, `speech_topics`, `speech_scopes`) caem por
|
||
cascade (o índice de sentido sai antes; a FK dos vetores é `SET NULL` sobre
|
||
coluna NOT NULL, então o SQL cru precisa apagá-los por conta própria):
|
||
|
||
```sql
|
||
DELETE FROM "speech_embedding" WHERE "speech_id" IN (SELECT id FROM "speech" WHERE "legislature" = '55');
|
||
DELETE FROM "speech_segment" WHERE "speech_id" IN (SELECT id FROM "speech" WHERE "legislature" = '55');
|
||
DELETE FROM "speech" WHERE "legislature" = '55';
|
||
```
|
||
|
||
Para desfazer o acervo inteiro: `DELETE FROM "speech_embedding"; DELETE FROM
|
||
"speech_segment"; DELETE FROM "speech";` (a cobertura `--coverage` volta a
|
||
zero). Reexecutar `--all` reconstrói, e `pnpm acervo:index` refaz o índice.
|
||
|
||
### C229 — índice de sentido do acervo
|
||
|
||
O modo **"Por tema"** do acervo (Câmara + internet) passou a ser similaridade
|
||
por embeddings (C229): as falas importadas precisam entrar no índice vetorial
|
||
antes de aparecerem nessa busca. O passo é manual, idempotente por hash de
|
||
conteúdo + modelo e deve ser rodado **depois** de qualquer import (C155/C225):
|
||
|
||
```bash
|
||
# no homeserver, com o env de produção carregado (DEEPINFRA_API_KEY)
|
||
cd ~/teqo-backfill && git fetch origin && git checkout <SHA> && pnpm install
|
||
set -a; source ~/stack/.env; set +a
|
||
set -a; source ~/stack/teqo-1313.env; set +a
|
||
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
|
||
|
||
# ensaio (read-only): quanto falta indexar e quantos trechos
|
||
pnpm acervo:index --dry-run
|
||
# indexação (write guard do C229)
|
||
ACERVO_INDEX_CONFIRM=1 pnpm acervo:index --source all
|
||
# calibração/verificação do aceite (read-only; imprime top-N com score)
|
||
pnpm acervo:index --probe "combate à oposição" --top 10
|
||
pnpm acervo:index --probe "impeachment" --top 10
|
||
```
|
||
|
||
O relatório JSON fica em `data/acervo-index/reports/` (gitignored) com
|
||
indexadas/puladas/falhas/trechos/tokens/custo. Os temas do aceite e 3–4 temas
|
||
de controle validam `SPEECH_SEMANTIC_MIN_COSINE` (`src/lib/speechSemantic.ts`);
|
||
registre os top-N e o valor final no changelog. Reexecutar só paga o que mudou.
|
||
|
||
## C225 — rodada de falas da internet no acervo de produção
|
||
|
||
A fonte **Falas na internet** do acervo (C216) já tem busca, player, transcrição e
|
||
origem, mas a produção só recebe fala web quando alguém executa esta operação.
|
||
A ingestão é a esteira do C215 (`pnpm falas-web:import`); a descoberta e a
|
||
curadoria são do C218. Esta seção é o procedimento manual, repetível e
|
||
reversível para rodar um lote contra `teqo_1313` e o bucket privado
|
||
`teqo-media`. O guard `FALAS_WEB_IMPORT_CONFIRM=1` continua sendo um ato
|
||
humano: nenhum agente, workflow ou skill o exporta.
|
||
|
||
**Estado desta entrega (2026-09-25):** o guard e este runbook foram entregues;
|
||
esta entrega não executou a rodada de produção. A data, o lote, os criados,
|
||
pulados, falhas, custos e o caminho dos relatórios são preenchidos por quem
|
||
operar depois do merge.
|
||
|
||
### Responsabilidades e limites
|
||
|
||
- A descoberta/curadoria pode ser feita na workstation, mas a cópia do lote e o
|
||
estado operacional devem terminar no diretório durável do homeserver. O
|
||
`last-run.json` do host é a fonte de verdade da próxima janela.
|
||
- O CLI é a única etapa que baixa, transcreve, classifica, espelha e deduplica.
|
||
Não crie cron, fila, collection de estado, migration ou uma segunda esteira.
|
||
- O acervo da Câmara (`origin = camara`, 997 discursos no baseline registrado em
|
||
2026-09-13/14) não é reprocessado por esta rodada.
|
||
- O download temporário pode existir durante o processamento em `/tmp`; o
|
||
destino da mídia espelhada é o bucket privado. O temp é removido pelo `finally`
|
||
do pipeline. Não use um diretório local como fila de upload.
|
||
- `internetSpeechMedia` continua privada e servida pelo acervo autenticado. Não
|
||
copie a URL do bucket para um canal público.
|
||
- Mídia acima de 2 GiB não passa pelo `fs.readFile` do Payload: o follow-up
|
||
C225-large cria a row com um placeholder e sobrescreve a mesma key por upload
|
||
multipart direto no S3. O teto técnico desta esteira é 5 GiB; acima disso a
|
||
falha fica em `pending`, sem fingir que a mídia foi espelhada.
|
||
|
||
### Pré-requisitos
|
||
|
||
1. O commit que contém C215, C216, C218, C223, C224 e C225 está em produção.
|
||
2. No homeserver há Node 24 (`source ~/.nvm/nvm.sh`), `pnpm`, acesso ao GitHub,
|
||
`flock`, `curl`, `psql` e espaço para o clone e os temporários do download.
|
||
3. `~/stack/.env` contém `DEEPINFRA_API_KEY` e `DEEPSEEK_API_KEY`; a primeira
|
||
chave faz a transcrição e a segunda a classificação. `~/stack/teqo-1313.env`
|
||
contém `DATABASE_URL`, `PAYLOAD_SECRET`, `NODE_ENV=production` e as quatro
|
||
variáveis `S3_*` do bucket. Não copie esses arquivos para o repositório.
|
||
4. O `yt-dlp` instalado funciona no host e tem `yt-dlp-ejs` disponível. A
|
||
resolução do CLI usa `YTDLP_PATH → PATH`; o CLI também valida `ffmpeg` e usa
|
||
o binário empacotado do projeto como fallback.
|
||
5. A pessoa responsável tem um arquivo de cookies Netscape revogável para as
|
||
plataformas que exigirem sessão. O arquivo nunca é commitado, impresso nem
|
||
enviado ao Issue.
|
||
|
||
### Workspace e estado durável
|
||
|
||
Use dois diretórios diferentes:
|
||
|
||
- `~/teqo-falas-web`: clone descartável, pinado no SHA que será executado; não é
|
||
o workspace do runner (`~/teqo-deploy`) e não guarda o watermark.
|
||
- `~/falas-web`: estado, lotes, descobertas, relatórios e logs duráveis da
|
||
operação, fora do clone e do diretório gitignored da workstation.
|
||
|
||
```bash
|
||
set -euo pipefail
|
||
export WORKTREE="$HOME/teqo-falas-web"
|
||
export FALAS_WEB_DIR="$HOME/falas-web"
|
||
export EXPECTED_SHA="<SHA-do-merge>"
|
||
install -d -m 700 "$FALAS_WEB_DIR" "$FALAS_WEB_DIR/reports" "$FALAS_WEB_DIR/runs"
|
||
if [ ! -d "$WORKTREE/.git" ]; then
|
||
git clone https://github.com/fsolla/teqo.git "$WORKTREE"
|
||
fi
|
||
cd "$WORKTREE"
|
||
git fetch origin
|
||
git checkout --detach "$EXPECTED_SHA"
|
||
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
|
||
pnpm install --frozen-lockfile
|
||
```
|
||
|
||
Se o C218 for executado dentro desse clone, aponte o diretório gitignored para o
|
||
estado durável uma única vez, sem apagar um diretório existente sem inspeção:
|
||
|
||
```bash
|
||
set -euo pipefail
|
||
if [ -L "$WORKTREE/data/falas-web" ]; then
|
||
test "$(readlink -f "$WORKTREE/data/falas-web")" = "$(readlink -f "$FALAS_WEB_DIR")"
|
||
elif [ -e "$WORKTREE/data/falas-web" ]; then
|
||
printf '%s\n' 'data/falas-web existe e não é o symlink durável esperado.' >&2
|
||
exit 1
|
||
else
|
||
mkdir -p "$WORKTREE/data"
|
||
ln -s "$FALAS_WEB_DIR" "$WORKTREE/data/falas-web"
|
||
fi
|
||
```
|
||
|
||
Se a descoberta ocorrer na workstation, copie para `~/falas-web` apenas o
|
||
artefato `discovery-*.json` e o lote `batch-*.json`. O `last-run.json` do host é
|
||
o dono do estado: não sobrescreva o watermark dele com uma cópia mais antiga da
|
||
workstation. Se o host ainda não tiver estado, a primeira rodada é a varredura
|
||
inicial; caso já exista, o operador incorpora somente o lote pendente ao estado
|
||
durável. Não use `data/falas-web` local da workstation como estado de produção.
|
||
O contrato do estado é o do C218: `lastRunAt` só avança depois de uma importação
|
||
com exit 0; falhas ficam em `pending` com `attempts`; itens duvidosos ficam em
|
||
`review`. A gravação é atômica (arquivo temporário + `mv`) e deve ocorrer por
|
||
último, depois de ler o relatório JSON do C215.
|
||
|
||
### Cookies e yt-dlp
|
||
|
||
O C215 não recebe cookies por argumento. Crie um wrapper fora do repo que
|
||
aponta para o binário real e habilita somente o runtime JavaScript disponível
|
||
na máquina. `--no-js-runtimes` evita que um Deno instalado, mas não preparado,
|
||
vença o Node; `--cookies` aplica a sessão somente à rodada. O arquivo deve ter
|
||
permissão `600` e ser removível sem tocar no clone.
|
||
|
||
```bash
|
||
set -euo pipefail
|
||
install -d -m 700 "$FALAS_WEB_DIR/bin"
|
||
YTDLP_REAL="$(command -v yt-dlp)"
|
||
test -n "$YTDLP_REAL"
|
||
test -r "$FALAS_WEB_DIR/cookies.txt"
|
||
cat > "$FALAS_WEB_DIR/bin/yt-dlp" <<EOF
|
||
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
exec "$YTDLP_REAL" --no-js-runtimes --js-runtimes node --cookies "$FALAS_WEB_DIR/cookies.txt" "\$@"
|
||
EOF
|
||
chmod 700 "$FALAS_WEB_DIR/bin/yt-dlp"
|
||
chmod 600 "$FALAS_WEB_DIR/cookies.txt"
|
||
export YTDLP_PATH="$FALAS_WEB_DIR/bin/yt-dlp"
|
||
```
|
||
|
||
O preflight usa uma URL real do lote, sem baixar o vídeo:
|
||
|
||
```bash
|
||
"$YTDLP_PATH" --dump-json --no-playlist --no-warnings --skip-download '<url-do-lote>'
|
||
```
|
||
|
||
A saída deve ser JSON e o log não deve indicar runtime JavaScript ausente,
|
||
`yt-dlp-ejs` ausente ou cookie inválido. Para revogar a sessão, apague apenas
|
||
`~/falas-web/cookies.txt` e `~/falas-web/bin/yt-dlp`; não apague o lote nem o
|
||
histórico de relatórios. A skill C218 continua sem receber `YTDLP_PATH`,
|
||
cookies ou a flag de produção.
|
||
|
||
### Sessão de produção e preflight
|
||
|
||
O env file preserva os hosts da rede do compose; a sessão no host precisa dos
|
||
endpoints loopback. Não edite `~/stack/teqo-1313.env`: reescreva as variáveis
|
||
somente depois de `source`.
|
||
|
||
```bash
|
||
source ~/.nvm/nvm.sh
|
||
cd "$WORKTREE"
|
||
set -a
|
||
source "$HOME/stack/.env"
|
||
source "$HOME/stack/teqo-1313.env"
|
||
set +a
|
||
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
|
||
export S3_ENDPOINT="${S3_ENDPOINT/host.docker.internal/127.0.0.1}"
|
||
export FALAS_WEB_DIR
|
||
export YTDLP_PATH="$FALAS_WEB_DIR/bin/yt-dlp"
|
||
```
|
||
|
||
Antes de qualquer escrita, confira o alvo exato, as dependências e o acesso ao bucket:
|
||
|
||
```bash
|
||
set -euo pipefail
|
||
test "$NODE_ENV" = production
|
||
case "$DATABASE_URL" in
|
||
*@127.0.0.1:5433/teqo_1313) ;;
|
||
*) printf '%s\n' 'DATABASE_URL não é o alvo de produção esperado.' >&2; exit 1 ;;
|
||
esac
|
||
test "${S3_ENDPOINT%/}" = 'http://127.0.0.1:3900'
|
||
test "$S3_BUCKET" = 'teqo-media'
|
||
for name in PAYLOAD_SECRET DEEPINFRA_API_KEY DEEPSEEK_API_KEY S3_BUCKET S3_ENDPOINT S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY; do
|
||
test -n "${!name:-}" || { printf 'falta %s\n' "$name" >&2; exit 1; }
|
||
done
|
||
test "$(psql "$DATABASE_URL" -Atqc 'select current_database()')" = 'teqo_1313'
|
||
node --input-type=module <<'NODE'
|
||
import { HeadBucketCommand, S3Client } from '@aws-sdk/client-s3'
|
||
|
||
const client = new S3Client({
|
||
region: process.env.S3_REGION || 'garage',
|
||
endpoint: process.env.S3_ENDPOINT,
|
||
forcePathStyle: true,
|
||
credentials: {
|
||
accessKeyId: process.env.S3_ACCESS_KEY_ID,
|
||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
||
},
|
||
})
|
||
await client.send(new HeadBucketCommand({ Bucket: process.env.S3_BUCKET }))
|
||
NODE
|
||
```
|
||
|
||
O `HeadBucket` precisa responder com sucesso; não imprima a resposta nem as
|
||
credenciais. O proxy de build `teqo-1313-build-proxy` em `127.0.0.1:5433` deve
|
||
existir. Se o preflight de download, o Postgres ou o Garage falhar, pare: não
|
||
troque o alvo para um banco remoto da workstation e não contorne as guardas.
|
||
|
||
### Rodada manual
|
||
|
||
Escolha um lote curado em `~/falas-web/batch-<stamp>.json`. A primeira execução
|
||
é um smoke; não rode a varredura inteira de uma vez. Mantenha o lock pelo
|
||
processo inteiro, inclusive leitura do relatório e gravação de `last-run.json`:
|
||
uma segunda invocação do C218 ou do import não pode começar enquanto este
|
||
shell estiver aberto.
|
||
|
||
```bash
|
||
set -euo pipefail
|
||
export RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
|
||
exec 9>"$FALAS_WEB_DIR/.lock"
|
||
flock -n 9 || { printf '%s\n' 'já existe uma rodada web' >&2; exit 1; }
|
||
```
|
||
|
||
1. **Planejar, sem escrita e sem flag:**
|
||
|
||
```bash
|
||
export FINDINGS="$FALAS_WEB_DIR/batch-<stamp>.json"
|
||
pnpm falas-web:import --findings "$FINDINGS" --out "$FALAS_WEB_DIR" --dry-run
|
||
```
|
||
|
||
Leia o relatório em `~/falas-web/reports/`. `invalid` e `duplicates` devem
|
||
ser zero. Confira cada `plannedEntries[].sourceKey` e `action`; um `create`
|
||
para uma URL que já entrou exige corrigir a curadoria antes de ingerir.
|
||
|
||
2. **Smoke, dentro do lock:**
|
||
|
||
```bash
|
||
if FALAS_WEB_IMPORT_CONFIRM=1 pnpm falas-web:import \
|
||
--findings "$FINDINGS" --limit 1 --out "$FALAS_WEB_DIR" 2>&1 |
|
||
tee -a "$FALAS_WEB_DIR/runs/$RUN_ID.log"; then
|
||
:
|
||
else
|
||
exit 1
|
||
fi
|
||
```
|
||
|
||
O relatório deve mostrar a entrada processada, o estágio e o resultado. A
|
||
mídia espelhada deve aparecer no bucket `teqo-media`; o player e a rota de
|
||
mídia do acervo são a prova de leitura, não uma URL pública. A cauda do lote
|
||
permanece no próprio `batch-<stamp>.json`.
|
||
|
||
3. **Lote curado completo:** rode o mesmo comando sem `--limit` e no mesmo lock:
|
||
|
||
```bash
|
||
if FALAS_WEB_IMPORT_CONFIRM=1 pnpm falas-web:import \
|
||
--findings "$FINDINGS" --out "$FALAS_WEB_DIR" 2>&1 |
|
||
tee -a "$FALAS_WEB_DIR/runs/$RUN_ID.log"; then
|
||
:
|
||
else
|
||
exit 1
|
||
fi
|
||
```
|
||
|
||
Exit 0 pode representar sucesso total ou parcial; leia `created`, `updated`,
|
||
`skipped`, `failures`, `asrSeconds`, `asrCostUsd`, `llmCostUsd` e
|
||
`durationMs` no JSON. Exit 1 significa que nenhum achado entrou e exige
|
||
retentativa, não um recibo verde.
|
||
|
||
4. **Prova de idempotência:** repita o lote completo sem `--reprocess`, ainda
|
||
com o lock:
|
||
|
||
```bash
|
||
if FALAS_WEB_IMPORT_CONFIRM=1 pnpm falas-web:import --findings "$FINDINGS" --out "$FALAS_WEB_DIR" 2>&1 |
|
||
tee -a "$FALAS_WEB_DIR/runs/$RUN_ID.log"; then
|
||
:
|
||
else
|
||
exit 1
|
||
fi
|
||
```
|
||
|
||
O relatório deve mostrar `skipped` para itens completos, nenhum download/ASR
|
||
novo e nenhum objeto adicional no bucket. A segunda rodada deve usar a
|
||
mesma identidade `web:<plataforma>:<externalId|url canônica>`; não use uma
|
||
URL alternativa para "forçar" o resultado.
|
||
|
||
5. **Inventário e fonte da rodada:** registre o relatório, o lote e o estado em
|
||
`~/falas-web`; atualize `last-run.json` por último. Uma falha vira `pending`
|
||
com o estágio e a tentativa, e só uma importação com exit 0 avança
|
||
`lastRunAt`. Não marque a janela como coberta por memória da sessão. Mantenha
|
||
o FD 9 aberto até essa gravação; só depois execute `exec 9>&-`.
|
||
|
||
6. **Ensaio de rollback:** antes de encerrar a primeira rodada, escolha uma
|
||
fala de teste, registre o `id`/`sourceKey`, peça confirmação humana para a
|
||
exclusão e remova pelo admin. Confirme no inventário e no Garage que a fala,
|
||
os segmentos e o objeto espelhado sumiram. Remova o `sourceKey` de qualquer
|
||
`pending`/`review` que ainda o referencie. Se essa prova não for feita, a
|
||
rodada fica bloqueada para aprovação operacional.
|
||
|
||
7. **Índice de sentido (C229):** depois do import, rode o passo do C229
|
||
(`ACERVO_INDEX_CONFIRM=1 pnpm acervo:index --source web`, ou `--source all`
|
||
para pegar as duas origens). Sem ele, as falas novas não aparecem na busca
|
||
"Por tema"; o `--probe` confere o aceite e o relatório traz tokens/custo.
|
||
O passo está detalhado na seção "C229 — índice de sentido do acervo".
|
||
|
||
### Inventário read-only
|
||
|
||
As consultas abaixo não escrevem no banco:
|
||
|
||
```bash
|
||
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 <<'SQL'
|
||
SELECT origin, platform, count(*) AS total
|
||
FROM speech
|
||
GROUP BY origin, platform
|
||
ORDER BY origin, platform;
|
||
|
||
SELECT id, "sourceKey", "speechAt", platform, origin
|
||
FROM speech
|
||
WHERE origin = 'web'
|
||
ORDER BY "speechAt" DESC;
|
||
|
||
SELECT count(*) AS web_total,
|
||
count(*) FILTER (WHERE "mirroredMedia" IS NOT NULL) AS web_with_media
|
||
FROM speech
|
||
WHERE origin = 'web';
|
||
SQL
|
||
```
|
||
|
||
Compare a contagem de `origin = 'camara'` com o baseline antes da rodada e
|
||
confirme que ela não diminuiu. Para o bucket, use o cliente S3/Garage aprovado
|
||
pela operação e confira o objeto pelo identificador da mídia, sem imprimir a
|
||
chave de acesso ou o segredo. Depois abra
|
||
`/campanha/comunicacao/acervo` → **Falas na internet** com uma sessão de
|
||
campanha e confirme player, transcrição, data e origem de uma fala da rodada.
|
||
|
||
### Rollback
|
||
|
||
Não copie o `DELETE` SQL do C155 para esta entrega. A remoção de uma fala web
|
||
deve passar pelo Payload, porque o hook `deleteSpeechAssets` remove segmentos e
|
||
registros de `internetSpeechMedia`, e o plugin de storage remove o objeto do
|
||
bucket:
|
||
|
||
1. Faça o inventário read-only e anote o `id`/`sourceKey` exato.
|
||
2. No admin autenticado, exclua uma fala `origin = web` da rodada de teste.
|
||
3. Confirme no inventário que a linha e os segmentos sumiram e, no Garage,
|
||
que o objeto associado sumiu.
|
||
4. Repita para outras falas somente com identificação e confirmação humanas.
|
||
|
||
Não apague diretamente a tabela `speech`, `speech_segment` ou
|
||
`internet_speech_media`; isso pode deixar relações e objetos órfãos. Remoção em
|
||
massa é uma operação destrutiva nova e precisa de item, flag, teste e runbook
|
||
próprios.
|
||
|
||
### Falhas e decisões
|
||
|
||
- **Runtime JS/YouTube:** o wrapper usa Node; se o binário não tiver
|
||
`yt-dlp-ejs`, a aquisição falha no estágio `acquisition`. Atualize o yt-dlp e
|
||
reinstale o componente pela via oficial; não coloque arquivos remotos no repo.
|
||
- **Cookies expirados:** a falha fica no relatório e em `pending`; atualize o
|
||
arquivo revogável e repita o lote. Nunca registre o conteúdo do cookie.
|
||
- **Egress do homeserver:** a rota para APIs/CDNs pode ser intermitente. O
|
||
preflight é um gate: se `--dump-json` ou o Garage falhar, a rodada não começa.
|
||
Não silencie a falha nem troque o banco de produção por um alvo remoto.
|
||
- **Mídia acima de 2 GiB:** o código anterior falhava no `fs.readFile` do Node
|
||
antes de chegar ao S3. O follow-up C225-large usa upload multipart e uma row
|
||
placeholder; confirme no relatório que a etapa `media` terminou e que o objeto
|
||
tem o tamanho real. O teto desta implementação é 5 GiB.
|
||
- **S3 ausente ou endpoint errado:** o novo guard recusa escrita não-local sem
|
||
as quatro `S3_*`; um erro de conectividade aparece no relatório. O env file do
|
||
container não deve ser editado para acomodar o host.
|
||
- **Espaço ou lock:** o `flock` serializa rodadas; um lock ocupado encerra o
|
||
comando. O diretório do clone e o diretório durável precisam de espaço para
|
||
dependências, lotes, relatórios e o temporário de cada download.
|
||
- **Fim da rodada:** registrar o resultado medido no changelog e o
|
||
`last-run.json` durável é parte da operação, não uma tarefa opcional.
|
||
|
||
### Resultado registrado
|
||
|
||
Preencher após uma execução real, sem apagar o histórico anterior:
|
||
|
||
```text
|
||
data/hora:
|
||
lote:
|
||
sourceKeys/resultados:
|
||
criados/atualizados/pulados/falhas:
|
||
custo/tempo:
|
||
bucket e estado durável:
|
||
idempotência:
|
||
falhas/pendências:
|
||
```
|
||
|
||
O resultado da primeira rodada e a prova da segunda são a evidência de que a
|
||
fonte web foi realmente carregada em produção; o merge deste runbook, sozinho,
|
||
não representa uma rodada concluída.
|
||
|
||
## C160 — reparo do link oficial do acervo (PDF direto do Diário)
|
||
|
||
Operação de dados do acervo de falas (C153/C154/C155): troca o
|
||
`officialTextUrl` legado (`dc_20b.asp`/`dc_20.asp`/`montaPdf.asp`, que pendura
|
||
no browser) pelo PDF direto do Diário
|
||
(`https://imagem.camara.leg.br/Imagem/d/pdf/<arquivo>#page=<página>`). É
|
||
script-only (nenhuma migration): resolve uma vez por publicação
|
||
(`<out>/diario/`), é idempotente e reporta cobertura (falas/datas atualizadas,
|
||
falhas e legado restante).
|
||
|
||
**Pré-requisitos:** o SHA do branch/commit com o C160 (a passada original rodou
|
||
de `~/teqo-backfill` no SHA `766e4ae9`, 2026-09-14); `~/stack/.env` +
|
||
`~/stack/teqo-1313.env`; `~/teqo-backfill` com `pnpm install` no SHA. O
|
||
`git fetch` do homeserver alcança o GitHub pelo bundle/com o remote disponível
|
||
(na passada original o clone apontava para repo local e usou-se `git fetch
|
||
~/c160.bundle <branch>`).
|
||
|
||
```bash
|
||
ssh homeserver
|
||
source ~/.nvm/nvm.sh
|
||
cd ~/teqo-backfill && git fetch origin && git checkout <SHA> && pnpm install
|
||
set -a; source ~/stack/.env; set +a
|
||
set -a; source ~/stack/teqo-1313.env; set +a
|
||
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
|
||
export CAMARA_IMPORT_CONFIRM=1 # guard C160: escrita em produção
|
||
|
||
# dry-run (read-only; aquece o cache por publicação em <out>/diario):
|
||
pnpm camara:import --repair-links --dry-run --out /srv/hdd/backups/teqo-camara
|
||
# escrita (idempotente; 2ª passada = 0 atualizações):
|
||
pnpm camara:import --repair-links --out /srv/hdd/backups/teqo-camara
|
||
# validação (0 legadas restantes):
|
||
pnpm camara:import --repair-links --dry-run --out /srv/hdd/backups/teqo-camara
|
||
```
|
||
|
||
O guard `CAMARA_IMPORT_CONFIRM=1` é exigido sempre que a escrita não é
|
||
provadamente local (`NODE_ENV=production`, host remoto ou `ALLOW_REMOTE_DB`);
|
||
`--dry-run` é read-only e não exige a flag. A escrita é confinada a
|
||
`speech.officialTextUrl`; VOD, transcrições e segmentos não são tocados.
|
||
|
||
**Comportamento medido da Câmara (2026-09-14):** o legado `.gov.br` responde
|
||
302 para `.leg.br` (hop lento, 25–135s) e o `.leg.br` responde 302 para
|
||
`montaPdf.asp?narquivo=…&npagina=…` (~5s); o PDF direto responde 200/206
|
||
`application/pdf` em <1s. O resolver reescreve `.gov.br`→`.leg.br` no lookup,
|
||
segue o redirect e parseia o `montaPdf`; a resolução é cacheada uma vez por
|
||
publicação (`diario/<data>-<coleção>[-s<suplemento>].json`) e cada PDF é
|
||
provado com GET Range antes de gravar. Falha de resolução deixa a fala
|
||
intocada, o run sai 1 e a próxima passada é idempotente.
|
||
|
||
**Resultado registrado (2026-09-14):** dry-run achou 965 falas legadas em 504
|
||
publicações (33 em `selCodColecaoCsv=J` e 1 com o parâmetro vazio, normalizadas
|
||
para `D`; a Câmara devolve "Documento não encontrado" nos códigos obsoletos e a
|
||
mesma data/página resolve em `D`) + 32 falas sem link (fallback
|
||
YouTube/oculto); resolução cacheada das 504 publicações (retries absorvem
|
||
`fetch failed` transiente); o write atualizou **965 falas** em 58s
|
||
(`remainingLegacy: 0`, 0 falhas) e a validação read-only deu 965 diretas/0
|
||
legadas com o `--coverage` inalterado (997 discursos). Relatórios em
|
||
`/srv/hdd/backups/teqo-camara/reports/repair-links-*.json` e changelog
|
||
`docs/changelog/2026-09-14-c160.md`.
|
||
|
||
### Rollback
|
||
|
||
Não há migração; o reparo só reescreve `speech.officialTextUrl` e o valor
|
||
anterior de cada fala está em `updates[].previousUrl` no JSON do run. O import
|
||
(`pnpm camara:import --date <dia>`) reconstrói o link a partir da API (a fonte
|
||
é a URL legada devolvida pela Câmara); reexecutar o reparo é idempotente.
|
||
|
||
## C163 — relatório de cidade — mobilização do 2º turno (leitura read-only)
|
||
|
||
O gerador de PDF por cidade (`/work-issue` C163) extrai um snapshot da **base
|
||
de produção** sem escrever nada: a sessão Postgres é aberta com
|
||
`default_transaction_read_only=on` (options da connection string, aplicada pelo
|
||
próprio `scripts/extract-city-report-snapshot.mjs`) e o render acontece fora do
|
||
homeserver. Nenhum artefato é persistido na base; o snapshot e o PDF/MD são
|
||
gitignored (`data/relatorios-cidade/`, `docs/research/relatorios-cidade/`).
|
||
|
||
Fluxo completo e contrato dos JSONs: skill `.agents/skills/relatorio-cidade`.
|
||
Receita do homeserver (checkout de scratch, **nunca** o `~/teqo-deploy` do
|
||
deploy em andamento):
|
||
|
||
```bash
|
||
ssh homeserver
|
||
source ~/.nvm/nvm.sh # Node 24 (engines do repo)
|
||
test -d ~/teqo-report || git clone https://github.com/fsolla/teqo.git ~/teqo-report
|
||
cd ~/teqo-report
|
||
git fetch origin main && git checkout main && git pull --ff-only origin main
|
||
# tsx vem do install completo (o deploy usa --prod e não traz devDeps):
|
||
[ -d node_modules/tsx ] || pnpm install --prod=false
|
||
set -a; source ~/stack/teqo-1313.env; set +a
|
||
# o host `postgres` só resolve na rede do stack; do host usa-se o proxy socat:
|
||
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
|
||
export CITY_REPORT_CONFIRM=1 # guard de intenção explícita
|
||
NODE_OPTIONS="--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs" \
|
||
node scripts/extract-city-report-snapshot.mjs \
|
||
--municipality=<slug> --out=data/relatorios-cidade/<slug>.snapshot.json
|
||
```
|
||
|
||
Depois, na workstation: `scp homeserver:~/teqo-report/data/relatorios-cidade/<slug>.snapshot.json data/relatorios-cidade/`
|
||
e `node scripts/build-city-report.mjs --snapshot=... --research=...`. O snapshot
|
||
carrega `readAt` + SHA do código — o PDF é datado por construção.
|
||
|
||
Guardas: sem `CITY_REPORT_CONFIRM=1` o extrator recusa; a URL com `options`
|
||
read-only é aplicada antes do Payload subir (qualquer write estoura no servidor);
|
||
o builder local não chama API externa (os números oficiais do 2º turno vêm do
|
||
artefato commitado). Nunca aponte o extrator para o banco local esperando
|
||
dados de produção — o caminho é o proxy do homeserver.
|
||
|
||
## C195 — ingestão do pacote do reel na biblioteca privada
|
||
|
||
O comando `pnpm reels:ingest <diretório>` publica um pacote de reel produzido
|
||
fora do site (`metadata.json` + `reel.mp4` + `capa.png` + `narracao.srt` e
|
||
opcionais `reel-audio.mp4`/`narracao.mp3`/`roteiro.md`) na biblioteca privada do
|
||
`/campanha` pela Local API. Roda **na imagem de manutenção do ambiente** (o
|
||
pacote chega por rsync; nenhuma credencial de campanha vai para a workstation) e
|
||
é **idempotente pelo hash do shot list**: re-ingerir o mesmo pacote atualiza a
|
||
mesma entrada e preserva o status (nunca ressuscita um reel despublicado).
|
||
|
||
O caminho é **staging primeiro**, sempre. O dry-run é o padrão e não escreve:
|
||
|
||
```bash
|
||
# da workstation — copia o pacote para o homeserver:
|
||
rsync -av /caminho/do/pacote/ homeserver:/srv/reels/<slug>/
|
||
|
||
# no homeserver:
|
||
ssh homeserver
|
||
cd ~/stack
|
||
# 1) conferência (dry-run; mostra alvo, operação, artefatos e falha se o pacote estiver incompleto):
|
||
docker compose --profile maintenance run --rm \
|
||
-v /srv/reels/<slug>:/pkg:ro \
|
||
teqo-staging-migrate pnpm reels:ingest /pkg
|
||
# 2) escrita no staging (exige TEQO_ENV + confirmação; o par é fail-closed):
|
||
docker compose --profile maintenance run --rm \
|
||
-e TEQO_ENV=staging -e REELS_INGEST_CONFIRM=1 \
|
||
-v /srv/reels/<slug>:/pkg:ro \
|
||
teqo-staging-migrate pnpm reels:ingest /pkg --apply
|
||
# 3) validação no staging (biblioteca/painel) e só então produção, com TEQO_ENV=production
|
||
# e o serviço teqo-1313-migrate.
|
||
```
|
||
|
||
Guardas: fora de teste o `TEQO_ENV=staging|production` é obrigatório e o
|
||
**nome exato** do banco é o discriminador (`teqo_staging`/`teqo_1313` — o host do
|
||
socat é `127.0.0.1` nos dois, então host não distingue); `ALLOW_REMOTE_DB` é
|
||
recusado; sem as 4 `S3_*` o comando recusa (o container gravaria em disco
|
||
efêmero); `--apply` sem `REELS_INGEST_CONFIRM=1` recusa. Falha no meio não deixa
|
||
entrada parcial: os documentos vão numa transação e a publicação é a última
|
||
escrita (objeto órfão no bucket é o único resíduo; o nome determinístico faz o
|
||
re-run sobrescrevê-lo). O plano de implementação completo vive em
|
||
`docs/plans/reels-ingestao-impl.md`; a migration `add_reel_source_hash` é
|
||
aditiva e aplicada pelo deploy.
|
||
|
||
### Rollback
|
||
|
||
Sem migração para desfazer. O rollback funcional é o kill switch: despublicar o
|
||
reel no admin tira da biblioteca e para de servir os arquivos. Para remover de
|
||
vez, apagar o `reel` (e as `reelMedia` órfãs) no admin; re-ingerir recria a
|
||
entrada (as mesmas chaves de objeto são sobrescritas).
|
||
|
||
## C231 — ingestão do acervo de fotos do Flickr
|
||
|
||
O comando `pnpm flickr:import` traz as fotos da conta `depjorgesolla` para o
|
||
acervo privado (`archivePhoto`): listagem + metadados pela API oficial e o
|
||
original de cada foto no bucket. A identidade é o **id da foto no Flickr** —
|
||
reexecutar converge ("novas / já existiam / falharam com motivo") e nunca
|
||
duplica; vídeos do photostream são contados e pulados. Nada é publicado e nada
|
||
no Flickr é tocado.
|
||
|
||
Chave e conta vêm do env do stack (`FLICKR_API_KEY`, `FLICKR_USER_ID` — nunca no
|
||
repo). O dry-run é o padrão e não escreve:
|
||
|
||
```bash
|
||
# no homeserver:
|
||
ssh homeserver
|
||
cd ~/stack
|
||
# 1) plano (dry-run; lê a API e o banco, não baixa nem escreve):
|
||
docker compose --profile maintenance run --rm \
|
||
teqo-staging-migrate pnpm flickr:import --limit 5
|
||
# 2) escrita no staging (exige TEQO_ENV + confirmação; as 4 S3_* são obrigatórias):
|
||
docker compose --profile maintenance run --rm \
|
||
-e TEQO_ENV=staging -e FLICKR_IMPORT_CONFIRM=1 \
|
||
teqo-staging-migrate pnpm flickr:import --apply --limit 5
|
||
# 3) validação no staging e só então produção (TEQO_ENV=production, serviço teqo-1313-migrate)
|
||
# 4) inventário a qualquer momento (read-only, sem Flickr):
|
||
docker compose --profile maintenance run --rm \
|
||
teqo-staging-migrate pnpm flickr:import --verify
|
||
```
|
||
|
||
Os recibos JSON ficam em `data/flickr/reports/` do container — monte um volume
|
||
(`-v $HOME/flickr-reports:/app/data/flickr`) para preservá-los. Guardas: fora de
|
||
teste o `TEQO_ENV=staging|production` é obrigatório com o **nome exato** do
|
||
banco (`teqo_staging`/`teqo_1313`); `ALLOW_REMOTE_DB` é recusado; sem as 4
|
||
`S3_*` o `--apply` recusa (o container gravaria em disco efêmero); e
|
||
`FLICKR_IMPORT_CONFIRM=1` é exigido em alvo não-local. A chave da API nunca
|
||
entra em recibo/log.
|
||
|
||
### Backfill de EXIF (`--refresh-metadata`)
|
||
|
||
Fotos ingeridas antes da correção do mapper gravaram `exif: []` (a API devolve
|
||
`raw`/`clean` como `{ _content }`). O modo re-busca o EXIF das linhas
|
||
existentes, atualiza só o que mudou e escreve apenas no banco — exige
|
||
`FLICKR_API_KEY` e as guardas de escrita, mas **não** exige S3:
|
||
|
||
```bash
|
||
# no homeserver (canary e depois o lote completo):
|
||
docker compose --profile maintenance run --rm \
|
||
-v $HOME/flickr-reports:/app/data/flickr \
|
||
-e TEQO_ENV=production -e FLICKR_IMPORT_CONFIRM=1 \
|
||
teqo-1313-migrate pnpm flickr:import --refresh-metadata --limit 5
|
||
docker compose --profile maintenance run --rm \
|
||
-v $HOME/flickr-reports:/app/data/flickr \
|
||
-e TEQO_ENV=production -e FLICKR_IMPORT_CONFIRM=1 \
|
||
teqo-1313-migrate pnpm flickr:import --refresh-metadata
|
||
```
|
||
|
||
O recibo (`flickr-*-refresh.json`) conta atualizadas/inalteradas/falhas com
|
||
motivo; o `--verify` mostra a cobertura (`EXIF: N`). O modo é idempotente:
|
||
reexecutar converge e linhas inalteradas não são reescritas.
|
||
|
||
Rollback: a migration `add_archive_photo` é aditiva. O rollback funcional é
|
||
apagar as linhas do `archivePhoto` no admin (o objeto correspondente no bucket
|
||
pode ser removido à parte); reexecutar a ingestão recria as mesmas chaves de
|
||
objeto.
|
||
|
||
## C232 — catalogação com IA do acervo de fotos
|
||
|
||
O comando `pnpm archive:catalog` pré-cataloga as fotos do acervo (`archivePhoto`)
|
||
com um engine de visão LOCAL: legenda/descrição, atividade/cena, texto visível,
|
||
temas do vocabulário existente, município (gazetteer) e pessoas públicas (só do
|
||
catálogo curado, lidas do TEXTO — nunca de rosto). A curadoria da assessoria
|
||
vence: campo editado no admin entra em "Campos curados" e a catalogação nunca
|
||
sobrescreve; reexecutar converge ("processadas / puladas / falharam com motivo")
|
||
e só as pendentes são processadas. Nada é publicado.
|
||
|
||
O engine é qualquer servidor OpenAI-compatible na **rede privada** (Ollama,
|
||
llama.cpp, vLLM). Exemplo: servir o modelo na workstation com GPU (o mesmo padrão
|
||
do ML do Immich no tailnet) e apontar o container para lá:
|
||
|
||
```bash
|
||
# na workstation (GPU), ex. com Ollama:
|
||
ollama serve # porta 11434, alcançável pelo tailnet
|
||
ollama pull qwen2.5vl:7b
|
||
|
||
# no homeserver:
|
||
ssh homeserver
|
||
cd ~/stack
|
||
# 0) plano (dry-run; lê a fila, não chama o engine nem escreve):
|
||
docker compose --profile maintenance run --rm \
|
||
-e ARCHIVE_VISION_BASE_URL=http://100.94.122.26:11434/v1 \
|
||
-e ARCHIVE_VISION_MODEL=qwen2.5vl:7b \
|
||
teqo-staging-migrate pnpm archive:catalog --limit 5
|
||
# 1) canário no staging (exige TEQO_ENV + confirmação; as 4 S3_* são obrigatórias):
|
||
docker compose --profile maintenance run --rm \
|
||
-e TEQO_ENV=staging -e ARCHIVE_CATALOG_CONFIRM=1 \
|
||
-e ARCHIVE_VISION_BASE_URL=http://100.94.122.26:11434/v1 \
|
||
-e ARCHIVE_VISION_MODEL=qwen2.5vl:7b \
|
||
teqo-staging-migrate pnpm archive:catalog --apply --limit 5
|
||
# 2) validação no staging e só então produção (TEQO_ENV=production, serviço teqo-1313-migrate)
|
||
# 3) inventário a qualquer momento (read-only, sem engine):
|
||
docker compose --profile maintenance run --rm \
|
||
teqo-staging-migrate pnpm archive:catalog --verify
|
||
```
|
||
|
||
Os recibos JSON ficam em `data/archive/reports/` do container — monte um volume
|
||
(`-v ~/archive-reports:/app/data/archive`) para preservá-los. Guardas: fora de
|
||
teste o `TEQO_ENV=staging|production` é obrigatório com o **nome exato** do banco
|
||
(`teqo_staging`/`teqo_1313`); `ALLOW_REMOTE_DB` é recusado; sem as 4 `S3_*` o
|
||
`--apply` recusa (o acervo não estaria acessível no alvo); `ARCHIVE_CATALOG_CONFIRM=1`
|
||
é exigido em alvo não-local; e o endpoint do engine precisa estar na rede privada
|
||
— host público é recusado e exige `ARCHIVE_VISION_ALLOW_REMOTE=1` explícito
|
||
(registrado no recibo com host/modelo e o escopo). Falha por foto é isolada e
|
||
nomeada (nada é escrito; a próxima execução retenta); o lote de 6,5k é sequencial
|
||
(~4–10 h). A chave do engine (quando houver) nunca entra em recibo/log.
|
||
|
||
Rollback: a migration `add_archive_photo_catalog` é aditiva; o rollback funcional
|
||
é limpar o grupo `catalog`/`curatedFields` no admin. Reexecutar nunca duplica nem
|
||
sobrescreve curadoria.
|
||
|
||
## C245 — catalogação em produção: camada de metadados primeiro
|
||
|
||
O mesmo comando roda em **duas camadas independentes** (migration aditiva
|
||
`add_archive_photo_metadata_checked`, coluna `catalog.metadataCheckedAt`):
|
||
|
||
- **metadados** (`--metadata-only`): sem engine e sem mídia — lê o texto já
|
||
ingerido (título, descrição, álbuns, tags) e deriva **município** (gazetteer)
|
||
e **temas**. Grava `catalog.metadataCheckedAt` e **não** preenche
|
||
`catalogedAt`, então a foto continua na fila da IA. Nunca escreve
|
||
`catalog.people` (a faceta de pessoa é da C244, por facial).
|
||
- **IA** (padrão, seção acima): legenda/descrição/atividade/texto visível;
|
||
quando roda, marca `catalogedAt` **e** `metadataCheckedAt` (a derivação de
|
||
texto roda nessa passada).
|
||
|
||
Ordem em produção (staging primeiro):
|
||
|
||
```bash
|
||
# 1) canário de metadados no staging (sem engine, sem S3):
|
||
ssh homeserver && cd ~/stack
|
||
docker compose --profile maintenance run --rm \
|
||
-e TEQO_ENV=staging -e ARCHIVE_CATALOG_CONFIRM=1 \
|
||
teqo-staging-migrate pnpm archive:catalog --metadata-only --apply --limit 5
|
||
# 2) lote de metadados no staging e, validado, em produção (serviço/banco do alvo):
|
||
docker compose --profile maintenance run --rm \
|
||
-e TEQO_ENV=production -e ARCHIVE_CATALOG_CONFIRM=1 \
|
||
teqo-1313-migrate pnpm archive:catalog --metadata-only --apply
|
||
# 3) depois de cada lote, buste o cache do álbum: o write roda no container de
|
||
# manutenção e o ISR do app não é revalidado por ele.
|
||
curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" \
|
||
-H "x-revalidate-secret: $REVALIDATE_SECRET"
|
||
# 4) inventário das DUAS camadas (read-only; sai 1 se a camada selecionada
|
||
# tiver pendentes):
|
||
docker compose --profile maintenance run --rm teqo-1313-migrate pnpm archive:catalog --verify
|
||
docker compose --profile maintenance run --rm teqo-1313-migrate pnpm archive:catalog --metadata-only --verify
|
||
```
|
||
|
||
A camada de IA não muda os comandos da seção C232: com o engine no ar, o
|
||
`--apply` padrão processa toda foto sem `catalogedAt` (as já marcadas pelos
|
||
metadados inclusive) e preserva o município/temas gravados; depois do lote,
|
||
repita o passo 3.
|
||
|
||
Guardas: `--metadata-only --apply` exige `ARCHIVE_CATALOG_CONFIRM=1` fora de
|
||
dev local e `TEQO_ENV` casando o nome exato do banco (`teqo_staging`/
|
||
`teqo_1313`) — **não** exige `S3_*` nem engine (não há mídia no caminho). O
|
||
recibo JSON registra `layer` e o `--verify` sempre imprime as duas camadas; o
|
||
exit 1 segue a camada selecionada.
|
||
|
||
Rollback: a migration `add_archive_photo_metadata_checked` é aditiva
|
||
(`catalog.metadataCheckedAt` + índice); o rollback funcional é anular o marker
|
||
via API/DB (o campo é `readOnly` no admin) e reexecutar. Reexecutar converge:
|
||
foto com `metadataCheckedAt` não é reprocessada pela camada pura, a IA continua
|
||
pegando quem não tem `catalogedAt` e a curadoria nunca é sobrescrita.
|
||
|
||
## C242 — busca por selfie aberta (índice anônimo do acervo)
|
||
|
||
A busca por selfie (`/fotos/encontre` + `POST /api/fotos/selfie`) cobre **qualquer
|
||
visitante**: o índice é anônimo (foto + descritor 128-d em `archivePhotoFace`),
|
||
sem nome, sem vínculo com pessoa e sem adesão prévia. A selfie nunca sai do
|
||
aparelho do visitante (o navegador envia só o vetor). A flag
|
||
`photoAlbum.selfieSearchEnabled` nasce `false` e é o kill switch imediato.
|
||
|
||
### Abertura (ordem obrigatória)
|
||
|
||
1. **Canal de remoção** no admin (`/admin` → Configurações → Álbum de fotos):
|
||
`https://` ou `mailto:` válido. Sem ele o álbum não publica e o lote de
|
||
aprovação recusa.
|
||
2. **Consentimentos** (textos versionados em `scripts/lib/faceConsentTexts.mjs`):
|
||
no serviço de manutenção do alvo, com o env file do stack:
|
||
```bash
|
||
cd ~/stack
|
||
docker compose --profile maintenance run --rm \
|
||
-e TEQO_ENV=production -e FACE_CONSENT_SEED_CONFIRM=1 \
|
||
teqo-1313-migrate pnpm seed:face-consents --apply
|
||
```
|
||
(dry-run sem `--apply` mostra o estado; o guard exige `TEQO_ENV` casando o
|
||
banco exato.)
|
||
3. **Aprovar o acervo** (drafts → approved; `removed` nunca é tocado). Desde o
|
||
C249 cada draft passa pelo **preflight de integridade** do C246 antes de
|
||
aprovar: o objeto é baixado e decodificado pelo mesmo caminho da rota
|
||
pública; objeto ausente/corrompido fica **fora do lote**, é nomeado no recibo
|
||
(`skippedBroken` com stage + motivo) e o comando sai **1**. O modo plano
|
||
(sem `--apply`) também inspeciona (read-only) e sai 1 se houver quebrados; o
|
||
custo do lote é download+decode de toda a fila, então comece pelo canário:
|
||
```bash
|
||
docker compose --profile maintenance run --rm \
|
||
-e TEQO_ENV=production -e ARCHIVE_PUBLISH_CONFIRM=1 \
|
||
teqo-1313-migrate pnpm archive:publish --apply --limit 20 # canário
|
||
docker compose --profile maintenance run --rm \
|
||
-e TEQO_ENV=production -e ARCHIVE_PUBLISH_CONFIRM=1 \
|
||
teqo-1313-migrate pnpm archive:publish --apply
|
||
```
|
||
Ao fim, buste a tag pública em um processo que a possui (o write do CLI não
|
||
revalida o servidor Next):
|
||
```bash
|
||
curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" \
|
||
-H "x-revalidate-secret: $REVALIDATE_SECRET"
|
||
```
|
||
4. **Indexar** (horas; o engine roda local no maintenance, sem endpoint externo):
|
||
```bash
|
||
docker compose --profile maintenance run --rm \
|
||
-e TEQO_ENV=production -e FACE_INDEX_CONFIRM=1 \
|
||
-v /srv/face-reports:/app/data/face/reports \
|
||
teqo-1313-migrate pnpm faces:index --apply --limit 20 # canário
|
||
docker compose --profile maintenance run --rm \
|
||
-e TEQO_ENV=production -e FACE_INDEX_CONFIRM=1 \
|
||
-v /srv/face-reports:/app/data/face/reports \
|
||
teqo-1313-migrate pnpm faces:index --apply # lote completo
|
||
docker compose --profile maintenance run --rm \
|
||
-v /srv/face-reports:/app/data/face/reports \
|
||
teqo-1313-migrate pnpm faces:index --verify # read-only; sai 1 com pendências
|
||
```
|
||
A busca por selfie reflete a indexação em até ~60 s (cache em memória do
|
||
índice). A faceta "Pessoa pública" do C244 lê um mapa cacheado pela tag
|
||
`archivePhotos`, que o lote não busta sozinho: buste a tag também depois da
|
||
indexação (mesmo `curl` do fim do passo 3).
|
||
5. **Ligar** `Busca por selfie` no global Álbum de fotos. Caminho principal pela
|
||
CLI (C247; grava pelo Local API e busta a tag `global_photoAlbum` no servidor
|
||
vivo na mesma execução — o write fora do Next não revalidaria sozinho):
|
||
```bash
|
||
docker compose --profile maintenance run --rm \
|
||
-e TEQO_ENV=production -e OPS_GLOBAL_CONFIRM=1 \
|
||
-v /srv/ops-global-reports:/app/data/ops-global \
|
||
teqo-1313-migrate pnpm ops:global photoAlbum --selfie-search on --apply
|
||
# rollback: --selfie-search off --apply (o recibo em data/ops-global/reports/
|
||
# carrega o valor anterior e o comando de rollback)
|
||
```
|
||
O admin continua valendo (`/admin` → Configurações → Álbum de fotos). Sem
|
||
deploy; desligar fecha a página (404), a API e a seção da home imediatamente.
|
||
|
||
### Remoção e privacidade
|
||
|
||
- **Sair do índice**: a própria pessoa usa “Minha presença” no fluxo (selfie
|
||
confere; os descritores correspondentes são apagados e o rosto deixa de ser
|
||
encontrado). O pedido de **remoção de foto** continua no canal do álbum.
|
||
- **Desaprovar/remover foto**: os descritores daquela foto são apagados pelos
|
||
hooks da collection (e o read aprovado nunca os mostra).
|
||
- **Rollback**: desligue a flag; a migration `add_archive_photo_face` é aditiva,
|
||
exceto o drop da `face_subject` (tabela vazia em produção).
|
||
|
||
### Guardas e recibos
|
||
|
||
`--apply` exige `TEQO_ENV` casando o banco exato e a flag de confirmação do
|
||
comando (`FACE_INDEX_CONFIRM`/`ARCHIVE_PUBLISH_CONFIRM`/`FACE_CONSENT_SEED_CONFIRM`),
|
||
mais as quatro `S3_*` quando a mídia for remota (indexação). Recibos JSON em
|
||
`data/face/reports/` e `data/archive/reports/` (nunca levam vetor nem bytes).
|
||
|
||
## C244 — filtro "Pessoa pública" por reconhecimento facial no álbum
|
||
|
||
A faceta `pessoa` do álbum (`/fotos?pessoa=<slug>`) e a linha "Quem aparece"
|
||
deixam de sair do texto `catalog.people` da ficha: passam a listar **apenas as
|
||
figuras públicas do catálogo curado** reconhecidas nas fotos pelo índice facial
|
||
do C242. A identificação biométrica é permitida só para esse catálogo (aval
|
||
jurídico registrado na decisão do dono de 2026-10-01); qualquer rosto fora dele
|
||
continua anônimo e nunca é nomeado. Não há score em nenhuma superfície.
|
||
|
||
A curadoria vive na collection `faceFigure` (admin-only, grupo Comunicação):
|
||
uma linha por figura (nome público, slug = `?pessoa=<slug>`, `active`) com 1–8
|
||
descritores de referência (retratos oficiais/arquivo, `source` de proveniência).
|
||
O match é **derivado em leitura** contra o índice existente (nada é
|
||
reprocessado: o vínculo não pode divergir do índice) e cacheado sob a tag
|
||
`archivePhotos`. Sem a linha do aviso (`busca-selfie-indice`) o filtro fica
|
||
**fechado** (fail-closed).
|
||
|
||
### Abertura (ordem obrigatória)
|
||
|
||
1. **Re-seed do aviso PRIMEIRO** — o texto do C242 foi atualizado para declarar
|
||
o filtro de figuras curadas; um enrollment antes disso deixaria o aviso
|
||
público em produção desatualizado:
|
||
```bash
|
||
cd ~/stack
|
||
docker compose --profile maintenance run --rm \
|
||
-e TEQO_ENV=production -e FACE_CONSENT_SEED_CONFIRM=1 \
|
||
teqo-1313-migrate pnpm seed:face-consents --apply
|
||
```
|
||
Confira o texto novo em `/fotos/encontre` (o parágrafo do catálogo curado).
|
||
2. **Catálogo inicial** (58 figuras: as 6 nomeadas + o roster S30), sem
|
||
referências — figura sem referência nunca aparece:
|
||
```bash
|
||
docker compose --profile maintenance run --rm \
|
||
-e TEQO_ENV=production -e FACE_FIGURE_SEED_CONFIRM=1 \
|
||
teqo-1313-migrate pnpm seed:face-figures --apply
|
||
```
|
||
3. **Enrolar as referências** de cada figura (1–3 retratos oficiais/arquivo por
|
||
pessoa; exatamente 1 rosto por imagem, senão o comando recusa):
|
||
```bash
|
||
docker compose --profile maintenance run --rm \
|
||
-e TEQO_ENV=production -e FACE_FIGURE_CONFIRM=1 \
|
||
-v /srv/figuras:/app/figuras \
|
||
teqo-1313-migrate pnpm faces:enroll-figure \
|
||
--figure lula --name "Lula" --full-name "Luiz Inácio Lula da Silva" \
|
||
--source "retrato oficial 2023" --image /app/figuras/lula-1.jpg --apply
|
||
```
|
||
`--replace` substitui as referências existentes; rodar de novo com a mesma
|
||
imagem não duplica (dedupe por vetor). Plan/dry-run é o default (sem engine).
|
||
4. **Bust da tag** (o CLI roda fora do Next):
|
||
```bash
|
||
curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" \
|
||
-H "x-revalidate-secret: $REVALIDATE_SECRET"
|
||
```
|
||
5. **Conferir** no site: a faceta lista só figuras com foto reconhecida; a
|
||
grade de `?pessoa=<slug>` e "Quem aparece" mostram só figuras curadas.
|
||
Nenhum terceiro é nomeado em nenhum estado.
|
||
|
||
### Revisão de falsos positivos (por exceção)
|
||
|
||
O limiar é o mesmo do C240 (0,45, conservador). Ao encontrar um falso positivo
|
||
na revisão por exceção: **desative a figura** ou **remova a referência ruim** no
|
||
admin (`Comunicação → Figuras públicas`; o hook revalida o álbum na hora) e
|
||
registre o caso. Falso positivo é débito de curadoria, nunca dado.
|
||
|
||
### Rollback
|
||
|
||
Sem deploy: desative as figuras (checkbox `Ativa`) ou remova as referências no
|
||
admin — o filtro esvazia imediatamente. A migration `add_face_figure` é
|
||
aditiva; o rollback de código pode deixar a tabela (inofensiva). Desligar o
|
||
álbum (`photoAlbum.published = false`) fecha tudo.
|
||
|
||
### Guardas e recibos
|
||
|
||
`--apply` do seed/enrollment exige `TEQO_ENV` casando o banco exato e a flag do
|
||
comando (`FACE_FIGURE_SEED_CONFIRM`/`FACE_FIGURE_CONFIRM`). As imagens de
|
||
referência são locais (nunca entram no repo nem no S3); recibos JSON em
|
||
`data/face/reports/` sem vetor nem bytes.
|
||
|
||
## C246 — integridade do acervo no Garage (fotos quebradas)
|
||
|
||
O comando `pnpm archive:integrity` varre os originais do acervo (`archivePhoto`):
|
||
baixa **cada** objeto pelo mesmo caminho da rota pública e do índice facial (o SDK
|
||
S3 valida o checksum na resposta) e força o decode com sharp. Objeto ausente é
|
||
`ausente`; falha de leitura (checksum) ou arquivo que não decodifica é
|
||
`corrompida` — o recibo nomeia cada uma com o motivo. A varredura é **read-only**,
|
||
sai **1** enquanto houver mídia quebrada **pública** (linha `approved` quebrada —
|
||
o critério de convergência; uma `draft` quebrada é registrada no recibo mas não
|
||
bloqueia, senão o rebaixamento do `--apply` nunca convergiria) e sempre grava o
|
||
recibo em `data/archive/reports/`.
|
||
|
||
Com `--apply`, cada foto quebrada é recuperada do Flickr pelo `flickrId` (mesmo
|
||
fallback `getSizes` da ingestão C231): o original baixado é decodificado para
|
||
provar que serve, o objeto é substituído **na mesma chave** pelo caminho de
|
||
escrita da própria collection (`payload.update` + `filePath`) — curadoria,
|
||
metadados e `publicationStatus` intocados — e o recibo guarda a URL-fonte, os
|
||
bytes e o sha256. Foto cuja fonte sumiu (ou não decodifica) **sai do público**
|
||
(`approved → draft`) com registro; `removed` nunca é tocado. Rodar `--only` no
|
||
canário primeiro:
|
||
|
||
```bash
|
||
# no homeserver:
|
||
ssh homeserver
|
||
cd ~/stack
|
||
# 1) canário read-only nas fotos do achado (80/83) — não escreve:
|
||
# (a limpeza pós-deploy remove a tag local do migrator; o registry a preserva)
|
||
docker pull "$(docker inspect -f '{{.Config.Image}}' teqo-1313 | sed 's/teqo-1313:/teqo-1313-migrator:/')"
|
||
docker compose --profile maintenance run --rm \
|
||
-v ~/archive-reports:/app/data/archive \
|
||
teqo-1313-migrate pnpm archive:integrity --only 80,83
|
||
# 2) reparo do canário (exige TEQO_ENV + confirmação; as 4 S3_* e FLICKR_API_KEY):
|
||
docker compose --profile maintenance run --rm \
|
||
-e TEQO_ENV=production -e ARCHIVE_INTEGRITY_CONFIRM=1 \
|
||
-v ~/archive-reports:/app/data/archive \
|
||
teqo-1313-migrate pnpm archive:integrity --apply --only 80,83
|
||
# 3) conferir as duas URLs públicas com GET completo (o HEAD não lê o corpo e
|
||
# não valida o checksum — dá 200 mesmo com objeto corrompido):
|
||
# curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \
|
||
# https://jorgesolla1313.com.br/fotos/80/midia
|
||
# (idem para /fotos/83/midia)
|
||
# 4) varredura completa (read-only; ~6,5k downloads no bucket local; sai 1 se achar):
|
||
docker compose --profile maintenance run --rm \
|
||
-v ~/archive-reports:/app/data/archive \
|
||
teqo-1313-migrate pnpm archive:integrity
|
||
# 5) reparo do que a varredura acusou (mesma linha com --apply):
|
||
docker compose --profile maintenance run --rm \
|
||
-e TEQO_ENV=production -e ARCHIVE_INTEGRITY_CONFIRM=1 \
|
||
-v ~/archive-reports:/app/data/archive \
|
||
teqo-1313-migrate pnpm archive:integrity --apply
|
||
# 6) convergência: re-rodar a varredura; 0 pendências = aceite
|
||
```
|
||
|
||
Guardas: fora de teste o `--apply` exige `TEQO_ENV` casando o **nome exato** do
|
||
banco (`teqo_staging`/`teqo_1313`), `ARCHIVE_INTEGRITY_CONFIRM=1` e
|
||
`FLICKR_API_KEY`; alvo não-local exige as 4 `S3_*` **em qualquer modo** (sem S3 a
|
||
varredura mediria o disco efêmero do container); `ALLOW_REMOTE_DB` é recusado.
|
||
`--concurrency` (default 3) e `--limit`/`--only` são os canários; o recibo traz
|
||
varridas/ok/ausentes/corrompidas/removidas, e no reparo recuperadas/
|
||
irrecuperáveis/falhas. A varredura **não** conserta nada: só o `--apply` escreve.
|
||
O download do original no reparo tem teto de **15 min** — um original grande
|
||
(~50 MB, ex. panorâmica 7952px) leva minutos no link lento da casa; falha de
|
||
download é `failed` retentável, nunca rebaixamento.
|
||
|
||
**Originais >50 MB e o checksum do Garage (incidente 2026-10-02):** o adapter
|
||
`storage-s3` sobe arquivos grandes via multipart e o Garage gravava um
|
||
`x-amz-checksum-crc32` que não bate com o corpo — todo GET do objeto falhava na
|
||
validação do SDK (500 no proxy público), mesmo com o conteúdo correto. O client
|
||
do plugin ficou com `requestChecksumCalculation: 'WHEN_REQUIRED'` (não anexa
|
||
checksum de upload; a leitura segue validando o que o objeto tiver). Se um
|
||
objeto legado aparecer com esse sintoma (`checksum mismatch` no recibo e no
|
||
GET), a correção pontual é reescrever os mesmos bytes com **PUT único** pelo
|
||
client S3 (o PUT único grava o checksum certo) — ver changelog
|
||
`2026-10-02-c246-garage-checksum-multipart.md`.
|
||
|
||
Depois de um `--apply` que rebaixou alguma foto, buste a tag pública em um
|
||
processo que a possui (o write do CLI não revalida o servidor Next):
|
||
|
||
```bash
|
||
curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" \
|
||
-H "x-revalidate-secret: $REVALIDATE_SECRET"
|
||
```
|
||
|
||
Fotos reparadas que continuam `approved` voltam a servir sozinhas (mesma chave,
|
||
rota `force-dynamic`) — e o índice facial reprocessa sozinho o que tiver
|
||
marcador stale (`pnpm faces:index`). Uma foto rebaixada a `draft` **não volta
|
||
por engano** no lote: desde o C249 o `pnpm archive:publish` inspeciona cada
|
||
draft pelo mesmo `inspectPrivateMediaObject` desta varredura antes de aprovar —
|
||
a quebrada fica fora do lote, sai nomeada no recibo (`skippedBroken`) e o
|
||
comando termina 1. O aviso antigo ("só rode o publish depois de uma varredura
|
||
limpa") deixou de ser a única proteção; ainda vale rodar a varredura limpa
|
||
primeiro, porque ela é quem **repara** (o publish não repara, só pula e nomeia).
|
||
Rollback: sem migration nesta entrega; o comando nunca apaga objeto nem linha —
|
||
uma foto rebaixada por engano volta a `approved` pela ficha no admin (com o
|
||
canal de remoção configurado) ou pelo publish depois de o objeto inspecionar
|
||
limpo.
|
||
|
||
## C248 — miniaturas AVIF do álbum público (backfill das derivadas)
|
||
|
||
O comando `pnpm archive:thumbnails` gera a miniatura AVIF 720px q60 de cada foto
|
||
**aprovada** do acervo como um objeto-irmão determinístico no mesmo store
|
||
privado do original (`flickr-<id>.<ext>` → `flickr-<id>-grade.avif`); nada de
|
||
banco, nada de original alterado. A rota `/fotos/<id>/midia?tamanho=grade`
|
||
serve a derivada direto quando o cliente manda `Accept: image/avif`; sem o
|
||
token, ou com a derivada ausente/ilegível, ela responde o JPEG on-the-fly de
|
||
sempre e regenera a derivada em background (`after()`). A aprovação de uma foto
|
||
no admin também aquece a derivada (best-effort; falha nunca bloqueia). O lote
|
||
cobre o que foi aprovado antes disso e o que o hook perder (CLI em lote,
|
||
restart).
|
||
|
||
```bash
|
||
# no homeserver:
|
||
ssh homeserver
|
||
cd ~/stack
|
||
# 1) plano read-only: elegíveis do álbum aprovado e quantas derivadas faltam
|
||
docker compose --profile maintenance run --rm \
|
||
-v /srv/archive-reports:/app/data/archive \
|
||
teqo-1313-migrate pnpm archive:thumbnails
|
||
# 2) canário (exige TEQO_ENV + confirmação; as 4 S3_* no alvo):
|
||
docker compose --profile maintenance run --rm \
|
||
-e TEQO_ENV=production -e ARCHIVE_THUMBNAILS_CONFIRM=1 \
|
||
-v /srv/archive-reports:/app/data/archive \
|
||
teqo-1313-migrate pnpm archive:thumbnails --apply --limit 20
|
||
# 3) lote completo (encode AVIF local; ~6,5k fotos):
|
||
docker compose --profile maintenance run --rm \
|
||
-e TEQO_ENV=production -e ARCHIVE_THUMBNAILS_CONFIRM=1 \
|
||
-v /srv/archive-reports:/app/data/archive \
|
||
teqo-1313-migrate pnpm archive:thumbnails --apply
|
||
# 4) aceite: cobertura 100% (read-only; sai 1 se faltar alguma):
|
||
docker compose --profile maintenance run --rm \
|
||
-v /srv/archive-reports:/app/data/archive \
|
||
teqo-1313-migrate pnpm archive:thumbnails --verify
|
||
# 5) conferir uma resposta real negociada (espera content-type image/avif):
|
||
curl -sD - -o /dev/null -H 'Accept: image/avif' \
|
||
"https://jorgesolla1313.com.br/fotos/<id>/midia?tamanho=grade"
|
||
```
|
||
|
||
Guardas: fora de teste o `--apply` exige `TEQO_ENV` casando o **nome exato** do
|
||
banco (`teqo_staging`/`teqo_1313`), `ARCHIVE_THUMBNAILS_CONFIRM=1` e — em alvo
|
||
não-local — as 4 `S3_*` (o original vive no bucket); `ALLOW_REMOTE_DB` é
|
||
recusado. O plano e o `--verify` são read-only. Reexecutar converge: derivada
|
||
existente é pulada por HEAD; original ausente/ilegível vira "pulada" com o
|
||
motivo no recibo (a C246 recupera o original e um novo `--apply` regenera);
|
||
falha de rede/upload fica registrada como falha e retenta na próxima execução.
|
||
**Serialize com `archive:catalog` e `archive:integrity`** (C245/C246 tocam os
|
||
mesmos originais) e rode em janela própria. `--concurrency` default 2 (o mesmo
|
||
teto do processo web), `--only`/`--limit` são os canários; recibos em
|
||
`data/archive/reports/`.
|
||
|
||
Nada a revalidar depois do lote: a rota descobre a derivada por chave
|
||
determinística a cada request (`force-dynamic`, `no-store`). Rollback: sem
|
||
migration nesta entrega; basta remover os objetos `flickr-<id>-grade.avif` do
|
||
bucket (a rota volta ao resize on-the-fly sozinha) — o original nunca é tocado.
|
||
|
||
## C230-followup — importação do Instagram para a Central de Conteúdos
|
||
|
||
O comando `pnpm content:instagram:import` traz as mídias recentes do perfil
|
||
oficial `@depjorgesolla` para a Central pelo caminho oficial (Graph API da
|
||
própria conta; a credencial é a do global `Social Feed`): cada novidade vira
|
||
uma peça no pipeline C220 (download do arquivo do próprio perfil, transcrição e
|
||
catalogação); com `--publish`, é publicada ao fim do lote o que esta rodada
|
||
terminou `pronto`, e com `--publish-existing` também entram os **rascunhos
|
||
prontos já na janela** (lote importado sem publicar), do post mais antigo para
|
||
o mais novo — o listing público ordena por `-publishedAt`, então o mais recente
|
||
lidera. Falha fica rascunho e é nomeada no recibo. Reexecutar converge
|
||
("novas / já estavam / falharam com motivo") e nunca duplica: a identidade é o
|
||
post, não a grafia da URL. Nada de scraping, terceiro ou stories; a mídia de
|
||
terceiro nunca é baixada (o match é dentro do feed da própria conta). A
|
||
varredura lê até o teto da própria edge (10K mídias) com early-stop por data —
|
||
a janela nunca é cortada por um teto arbitrário de páginas.
|
||
|
||
O dry-run é o padrão e não escreve (lê o feed e o banco):
|
||
|
||
```bash
|
||
# no homeserver:
|
||
ssh homeserver
|
||
cd ~/stack
|
||
# 1) plano (dry-run; mostra janela, candidatos e peça-link previstos):
|
||
docker compose --profile maintenance run --rm \
|
||
-v ~/content-instagram:/app/data/content-instagram \
|
||
teqo-1313-migrate pnpm content:instagram:import --days 60
|
||
# 2) canário no alvo (exige TEQO_ENV + confirmação; as 4 S3_* são obrigatórias):
|
||
docker compose --profile maintenance run --rm \
|
||
-e TEQO_ENV=production -e CONTENT_INSTAGRAM_IMPORT_CONFIRM=1 \
|
||
-v ~/content-instagram:/app/data/content-instagram \
|
||
teqo-1313-migrate pnpm content:instagram:import --apply --publish --limit 2
|
||
# 3) lote completo — rode em tmux: o processamento baixa, transcreve e cataloga
|
||
# cada peça em sequência (docker compose run sem -d segura a sessão SSH):
|
||
# --publish-existing fecha a lacuna de um lote que já entrou como rascunho.
|
||
docker compose --profile maintenance run --rm \
|
||
-e TEQO_ENV=production -e CONTENT_INSTAGRAM_IMPORT_CONFIRM=1 \
|
||
-v ~/content-instagram:/app/data/content-instagram \
|
||
teqo-1313-migrate pnpm content:instagram:import --apply --publish --publish-existing --days 60
|
||
```
|
||
|
||
**Retirar peças anteriores a um corte** (ex.: antes do início da campanha
|
||
eleitoral). O comando despublica (kill switch: preserva slug, arquivo e
|
||
`publishedAt`; reversível pela ficha) as peças cujo **post original** é
|
||
anterior à data de corte — uma data civil na Bahia, a mesma do calendário
|
||
eleitoral. Nas Eleições 2026 a propaganda é permitida a partir de **16/08**
|
||
(Lei 9.504/97, art. 36; Resolução TSE nº 23.760/2026), então o corte é
|
||
`--before 2026-08-16`. Peça publicada que o feed não consegue datar nunca é
|
||
tocada: aparece no recibo como "sem data no feed".
|
||
|
||
```bash
|
||
# 1) plano (dry-run; lista o que sairia, o período e o que não tem data):
|
||
docker compose --profile maintenance run --rm \
|
||
-v ~/content-instagram:/app/data/content-instagram \
|
||
teqo-1313-migrate pnpm content:instagram:withdraw --before 2026-08-16
|
||
# 2) retirada (exige TEQO_ENV + confirmação):
|
||
docker compose --profile maintenance run --rm \
|
||
-e TEQO_ENV=production -e CONTENT_INSTAGRAM_WITHDRAW_CONFIRM=1 \
|
||
-v ~/content-instagram:/app/data/content-instagram \
|
||
teqo-1313-migrate pnpm content:instagram:withdraw --before 2026-08-16 --apply
|
||
```
|
||
|
||
`--scan-days <n>` (default 90) é só a profundidade da varredura do feed usada
|
||
para datar as peças publicadas; reexecutar converge (o que já é rascunho sai do
|
||
conjunto). O recibo nomeia cada peça retirada, o período e as falhas.
|
||
|
||
**Apagar peças anteriores a um corte (expurgo definitivo).** Diferente da
|
||
retirada, o `content:instagram:prune` faz **hard delete** da peça e da mídia
|
||
privada (arquivo e still do C226): a linha sai do catálogo, então uma importação
|
||
futura não a recria como rascunho nem o `--publish-existing` a publica de novo.
|
||
Use quando a peça fora do período não deve mais existir — o caso clássico é o
|
||
rascunho pré-campanha que o `--publish-existing` ressuscita. Peça que o feed
|
||
oficial não consegue datar **nunca é tocada** (apagar sem data seria risco de
|
||
apagar peça de campanha) e aparece nomeada no recibo.
|
||
|
||
```bash
|
||
# 1) plano (dry-run; lista o que sairia, o período e o que não tem data):
|
||
docker compose --profile maintenance run --rm \
|
||
-v ~/content-instagram:/app/data/content-instagram \
|
||
teqo-1313-migrate pnpm content:instagram:prune --before 2026-08-16
|
||
# 2) expurgo (exige TEQO_ENV + confirmação; IRREVERSÍVEL):
|
||
docker compose --profile maintenance run --rm \
|
||
-e TEQO_ENV=production -e CONTENT_INSTAGRAM_PRUNE_CONFIRM=1 \
|
||
-v ~/content-instagram:/app/data/content-instagram \
|
||
teqo-1313-migrate pnpm content:instagram:prune --before 2026-08-16 --apply
|
||
```
|
||
|
||
`--scan-days <n>` (default **365**) é a profundidade da varredura do feed usada
|
||
para datar as peças; o plano varre rascunhos e publicadas. Reexecutar converge
|
||
(0 anteriores ao corte). O recibo nomeia cada peça apagada, o período e as
|
||
falhas; a revalidação da Central só roda se alguma publicada saiu (mídia órfã é
|
||
limpa após a linha, best-effort, como no delete da ficha).
|
||
|
||
Os recibos JSON ficam em `data/content-instagram/` do container — monte o
|
||
volume acima para preservá-los. Com `--publish`, o CLI revalida a Central
|
||
pública ao final pela rota oficial de revalidação
|
||
(`POST {NEXT_PUBLIC_SITE_URL}/api/revalidate?tag=contentPieces`, com
|
||
`REVALIDATE_SECRET` do env do stack — a tag entrou na allowlist do endpoint,
|
||
como o álbum do C233); se a revalidação falhar, o
|
||
recibo nomeia o motivo e o fallback manual no homeserver é
|
||
`curl -X POST http://localhost:1313/api/revalidate?tag=contentPieces -H "x-revalidate-secret: $REVALIDATE_SECRET"`
|
||
(no staging, `:1314`). Guardas: fora de teste o
|
||
`TEQO_ENV=staging|production` é obrigatório com o **nome exato** do banco
|
||
(`teqo_staging`/`teqo_1313`); `ALLOW_REMOTE_DB` é recusado; sem as 4 `S3_*` o
|
||
`--apply` recusa (o job gravaria em disco efêmero); e
|
||
`CONTENT_INSTAGRAM_IMPORT_CONFIRM=1` é exigido em alvo não-local. Sem
|
||
credencial no global o comando falha fechado com a mensagem de produto; o token
|
||
nunca entra em recibo/log.
|
||
|
||
**Rede:** o job baixa o arquivo do CDN do Instagram e a rota IPv4 do container
|
||
é instável (post-mortem `2026-09-25-instagram-cdn-ipv4-instavel.md`) — o
|
||
serviço de manutenção usado precisa estar anexado à rede `teqo-ipv6` do stack
|
||
(a mesma do container de produção). Sem ela, parte dos vídeos vira peça-link
|
||
por `indisponivel` (honesto, mas não é o desejado).
|
||
|
||
**Reprocessar uma falha:** um `falhou` fica rascunho e a reimportação **pula**
|
||
o que já existe — o retry é o botão "Reprocessar" da ficha (C211), não uma
|
||
segunda rodada do CLI. `--limit` deixa a cauda no lote: rode de novo (o CLI
|
||
pula o que já entrou).
|
||
|
||
Rollback: nada de schema. O kill switch é despublicar/apagar as peças no admin
|
||
(a Central pública atualiza na hora pelo tag `contentPieces`).
|
||
|
||
## OPS131 — cutover do domínio jorgesolla.com.br
|
||
|
||
Decisão de produto: **A — cutover total**. O site público e `/campanha` passam a
|
||
viver em `https://jorgesolla.com.br`; as passkeys são re-registradas uma vez no
|
||
domínio novo (o RP ID do WebAuthn é o hostname de `NEXT_PUBLIC_SITE_URL`). O
|
||
login por senha continua funcionando durante e depois da troca.
|
||
|
||
### Pré-flight (evidência de 2026-11-08)
|
||
|
||
- **WordPress:** sitemap com **43 posts** publicados (último em 2026-08-17),
|
||
2 categorias (`noticias`, `uncategorized`), tags e author; o page sitemap só
|
||
tem `home`. O REST do WordPress não responde a partir do homeserver/workstation
|
||
(timeout/WAF), mas o inventário foi lido pela API pública via proxy externo.
|
||
- **Teqo:** **43 posts** publicados (último em 2026-08-18) — paridade 43/43, sem
|
||
conteúdo novo a importar. O `pnpm db:seed:posts` não roda a partir do
|
||
homeserver enquanto o REST do WordPress estiver bloqueado; o registro desta
|
||
paridade é a evidência do OPS130.
|
||
- **Redirects:** os 43 permalinks `/YYYY/MM/DD/<slug>/` estão em
|
||
`scripts/lib/legacyWordPressRedirects.mjs` (destino = caminho canônico Teqo).
|
||
Categorias, tags, author, feeds, paginação e arquivos de data vão para
|
||
`/artigos`; `/home` vai para `/`; `wp-admin`/`wp-login.php` para `/`.
|
||
- **Passkeys:** 4 usuários / 7 credenciais no RP ID atual
|
||
(`jorgesolla1313.com.br`); serão re-registradas no domínio novo.
|
||
- **Metadados:** `metadata.URL` já é `https://jorgesolla.com.br`;
|
||
`NEXT_PUBLIC_SITE_URL` de produção ainda é `https://jorgesolla1313.com.br`.
|
||
|
||
### Janela de cutover
|
||
|
||
1. **Congelar o WordPress (read-only)** e guardar o backup final. Confirmar que
|
||
o sitemap continua com os 43 posts; se houver post novo, regenerar o mapa e
|
||
rodar o teste unitário antes de seguir.
|
||
2. **Env de produção:** editar `~/stack/teqo-1313.env` com
|
||
`NEXT_PUBLIC_SITE_URL=https://jorgesolla.com.br` (backup do arquivo antes).
|
||
Como o valor é inlined no build, o deploy precisa rebuildar.
|
||
3. **Cloudflare:** no túnel remotely-managed, adicionar o ingress
|
||
`jorgesolla.com.br` → `http://localhost:1313`; criar o DNS proxied do apex
|
||
para o túnel e o `www` (CNAME para o apex ou para o túnel). Na zona
|
||
`jorgesolla1313.com.br`, criar uma Redirect Rule 301 de
|
||
`https://jorgesolla1313.com.br/*` para `https://jorgesolla.com.br` preservando
|
||
`http.request.uri.path`. No `www`, 301 para o apex. **Não tocar em MX, TXT,
|
||
SPF, DKIM ou DMARC** — o e-mail `@jorgesolla.com.br` continua no mesmo lugar.
|
||
4. **Deploy:** rodar o workflow de deploy (push em `main` ou `workflow_dispatch`)
|
||
e aprovar produção. O build novo inlina o domínio novo.
|
||
5. **Passkeys:** avisar os 4 usuários para re-registrar a passkey em
|
||
`https://jorgesolla.com.br/campanha` depois do switch. Quem usa senha não
|
||
precisa fazer nada; as credenciais antigas ficam no banco, mas o browser não
|
||
as oferece no domínio novo.
|
||
6. **Verificação (read-only):**
|
||
- `https://jorgesolla.com.br/` e `https://www.jorgesolla.com.br/` → 200
|
||
(www 301 para o apex), canonical/OG no domínio novo;
|
||
- `https://jorgesolla1313.com.br/artigos` → 301 para
|
||
`https://jorgesolla.com.br/artigos` (Redirect Rule do Cloudflare);
|
||
- amostras do WordPress: `/2026/08/17/bruno-reis-.../` → caminho canônico,
|
||
`/category/noticias/`, `/tag/lula/`, `/2026/08/` → `/artigos` (o Next
|
||
responde **308 permanente** para os redirects in-app; o Cloudflare usa 301
|
||
no domínio 1313);
|
||
- `/api/media/file/...` e a imagem OG respondem 200 no domínio novo;
|
||
- `/campanha` autentica por senha; uma passkey nova é registrada e usada.
|
||
7. **WordPress:** manter read-only por ~30 dias (janela de rollback), depois
|
||
desligar o host antigo. O backup/dump fica arquivado; as uploads antigas não
|
||
são proxiadas (não há imagem do WordPress no corpo dos 43 posts do Teqo).
|
||
|
||
### Rollback
|
||
|
||
- Reverter `NEXT_PUBLIC_SITE_URL` para `https://jorgesolla1313.com.br` e
|
||
redeployar; as passkeys antigas voltam a valer.
|
||
- Remover o ingress/DNS de `jorgesolla.com.br` do túnel e restaurar o A record
|
||
do WordPress (`128.201.75.110`) enquanto a janela de rollback estiver aberta.
|
||
- O WordPress fica read-only, então o rollback não perde conteúdo novo.
|
||
|
||
## Referências
|
||
|
||
- `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo; parametrizado por `TEQO_ENV`)
|
||
- `.github/workflows/deploy.yml` — preflight → verify → deploy-staging → requeue/deploy-production (OPS103/OPS104)
|
||
- `docs/plans/ops53-ci-deploy-homeserver*.md` — intenção e decisões (era Forgejo)
|
||
- `docs/plans/ops71-ci-github-actions-tracker-forgejo*.md` — o cutover
|
||
- `docs/plans/ops103-staging-homeserver*.md` — staging com verify único e aprovações separadas
|
||
- `docs/plans/ops104-disparo-automatico-do-deploy*.md` — início automático + requeue (staging automático, produção com review)
|
||
- infra-solla: `STATE.md`, `plano-infra-final.md` §"Arquitetura de deploy"
|