# Ambiente de homologação — servidor BVIP

Definição do ambiente onde a modernização é validada por gente, e onde o ensaio
da migração para PostgreSQL vai acontecer.

Escrito em 17/08/2026.

---

## Papel de cada servidor

Decisão de 17/08/2026, com a infraestrutura levantada no mesmo dia.

| # | Servidor | Papel | Observação |
|---|---|---|---|
| 1 | Hostinger `linkwise` — 2 vCPU / 8 GB | Réplica e testes pessoais | **Não serve para homologação:** MySQL 8.4.10 (produção é 8.0.46) e só 2 GB livres |
| 2 | **BVIP** — 24 núcleos / 64 GB, bare metal | **Homologação + laboratório de migração** | MySQL 8.0.46, idêntico à produção. IP fixo, controle total |
| 3 | Orange Pi 5 — 8 núcleos / 8 GB, ARM64 | Nó de redundância | MySQL 8.0.45. Atrás de CGNAT; 3 GB livres |
| 4 | Hostinger produção — 4 vCPU / 16 GB | Produção | |
| 5 | HostGator — 4 vCPU / 16 GB | **Ambiente de e-mail** | Não é VPS ociosa. Uma pilha web ao lado põe o correio da empresa em risco |

### CORREÇÃO de 19/08/2026 — produção é MySQL 8.4.7

A coleta em produção desmentiu a premissa desta escolha. O servidor
`barbeariavip`, banco `franquia_producao`, roda **MySQL 8.4.7 sobre Ubuntu
25.04** — não 8.0.46.

O que eu havia concluído em 17/08 estava invertido: tratei o 8.4.7 como sendo da
réplica e o 8.0.46 como sendo da produção. É o contrário.

Consequências, todas a resolver:

1. ~~O BVIP não espelha mais a produção~~ — **resolvido em 19/08.** O MySQL
   **do host** do BVIP é 8.0.46, mas a homologação não usa aquele: sobe o seu
   próprio em contêiner, e ele passou a ser **8.4**, como a produção. A objeção
   deixa de existir.
2. ~~O ambiente de desenvolvimento está preso em `mysql:8.0`~~ — **corrigido.**
   O `docker-compose.yml` usa `mysql:${MYSQL_VERSAO:-8.4}`, e o CI roda a suíte
   nas duas versões enquanto o parque for misto.

   Verificado antes de mudar: o `mysql.sql` carrega limpo no 8.4 (83 tabelas,
   2 eventos), e uma consulta com `GROUP BY` não agregado sob o `sql_mode` de
   produção se comporta igual. **Detalhe que apareceu no teste:** o 8.4 traz
   `ONLY_FULL_GROUP_BY` no `sql_mode` **padrão** — produção o desliga
   explicitamente. O `command:` do compose já fazia isso, então o ambiente
   continua fiel.

   *As 83 tabelas eram as do `mysql.sql` de então, e o número mudou* (medido
   em 07/10/2026, contando os `CREATE TABLE`): 84 em 03/09/2026, com
   `clientes_sessoes`, e **85** desde 02/10/2026, com `usuarios_horarios`. A
   cópia local `franquiabv_dev` tem **87** (`information_schema`, só a
   contagem, no mesmo dia): as 85, mais `sessoes` (de `sql/sessoes.mysql.sql`,
   que o `mysql.sql` não traz) e `_fixtures_versao` (das fixtures da suíte).
3. **A auditoria do D24 rodou em 8.0.** O inventário de 71 consultas continua
   válido — `ONLY_FULL_GROUP_BY` se comporta igual —, mas convém reconfirmar sob
   8.4 antes do corte.
4. **O D27 inverte de sentido**, e piora: a produção é a versão **alta**, e as
   réplicas BVIP (8.0.46) e Orange Pi (8.0.45) são as **baixas**. Replicação de
   8.4 para 8.0 **não é suportada pelo MySQL**. Ou aquelas réplicas não replicam
   desta produção, ou estão quebradas, ou as versões que levantei estão
   desatualizadas. **Precisa ser conferido antes de qualquer coisa** — é a
   redundância inteira que depende disso.

O que **não** muda: o BVIP segue sendo a máquina certa para o laboratório e a
homologação por folga de recurso, IP fixo e controle. O que muda é que a versão
do MySQL local precisa acompanhar a produção, e o alinhamento do parque vira
assunto urgente em vez de débito adiado.

### Por que o BVIP (raciocínio original, agora com a ressalva acima)

O fator decisivo **era** a versão do MySQL: 8.0.46, que eu supunha ser a da
produção. Toda a
fase atual gira em torno de compatibilidade de SQL — o débito D24,
`ONLY_FULL_GROUP_BY`, detecção de dependência funcional. Homologar isso em
MySQL 8.4 (Servidor 1) ou em MariaDB (a escolha natural num cPanel) devolve
resposta errada nos dois sentidos: aprova consulta que quebraria e reprova
consulta que passaria.

O resto é folga: 24 núcleos e 26 GB livres acomodam a homologação e o
laboratório de migração ao mesmo tempo, em pilhas Docker separadas.

### O que isso resolveu de quebra

A divergência de versão que estava em aberto desde 16/08 — dump de estrutura
reportando 8.0.46 e dump de dados reportando 8.4.7, ambos rotulados como
produção — era o dump de dados ter saído da **réplica** (Servidor 1), não da
produção. Não havia dois servidores de produção.

---

## Subir a homologação

Requisitos já presentes no BVIP: Docker 29.1.3, nginx no host, MySQL 8.0.46 do
host ocupando a 3306 com a réplica.

A homologação **não usa o MySQL do host**: sobe o seu próprio, em contêiner e
na versão 8.4, que é a da produção. A réplica do host segue intocada na 3306.

### Antes: acesso ao repositório

O GitHub não aceita senha em operação de git desde 2021. Para um servidor o
certo não é token pessoal — é **deploy key**: chave SSH **somente leitura** e com
escopo de **um repositório só**. Se o BVIP for comprometido, o atacante ganha
leitura de um repositório, não acesso à conta inteira.

No servidor:

```bash
ssh-keygen -t ed25519 -C "bvip-homologacao" -f ~/.ssh/franquiabv-erp -N ""
cat ~/.ssh/franquiabv-erp.pub
```

Sem frase secreta (`-N ""`) porque o servidor precisa puxar sozinho. O que
compensa isso é a chave ser somente leitura e valer para um repositório.

Copie a saída do `cat` e cadastre em **GitHub → o repositório → Settings → Deploy
keys → Add deploy key**. Cole a chave, dê um nome como `bvip-homologacao`, e
**deixe "Allow write access" DESMARCADO** — a homologação só precisa ler.

Depois, ensine o SSH a usar essa chave para este repositório:

```bash
cat >> ~/.ssh/config <<'CONF'

Host github-franquiabv
    HostName github.com
    User git
    IdentityFile ~/.ssh/franquiabv-erp
    IdentitiesOnly yes
CONF
chmod 600 ~/.ssh/config

ssh -T git@github-franquiabv    # deve dizer que a autenticação funcionou
```

### Subir

> Os comandos abaixo não repetem `-f docker-compose.yml -f
> docker-compose.homologacao.yml` porque o `.env` define `COMPOSE_FILE` com os
> dois. Copiar o `.env.homologacao.example` é o que liga isso — sem ele, cada
> linha precisaria dos dois `-f`, e esquecer numa delas sobe o serviço sem os
> tetos de recurso, competindo com a réplica de produção. Falha silenciosa.

```bash
git clone git@github-franquiabv:SEU-USUARIO/franquiabv-erp.git
cd franquiabv-erp

cp .env.homologacao.example .env
nano .env                      # trocar as duas senhas

cp docker/config/local.php.docker config/autoload/local.php
nano config/autoload/local.php  # ajustar sistema.url para a URL de homologação
# 640 sozinho NÃO basta: o php-fpm roda como www-data e precisa ler o arquivo.
# Sem o chown, o sistema sobe e morre com "Permission denied".
chown root:$(docker compose exec -T app id -g www-data | tr -d '
') config/autoload/local.php
chmod 640 config/autoload/local.php

docker compose up -d
docker compose exec app composer install
docker compose exec app php scripts/verificar-local-php.php
```

O `verificar-local-php.php` é o portão: se acusar falha **fatal**, o sistema não
sobe, e é melhor descobrir agora que depois do primeiro acesso.

### Os provedores de produção ficam fora de alcance

Desde 06/10/2026 (D360, decidido pelo dono), o `docker-compose.homologacao.yml` resolve Asaas, WhatsApp
(Evolution e WAHA), OneSignal, SMS, Yungas, Zoomtec e o SMTP de produção para o loopback **dentro do
contêiner do PHP**, no IPv4 e no IPv6: a chamada morre com "Could not connect to server" e não sai do
BVIP. O ViaCEP continua alcançável. O motivo é o passo 5 de baixo: o `fixtures.sql` tem duas unidades
(90001150 e 90001200) com o WhatsApp ligado e uma chave de mentira, e abrir a tela antiga do WhatsApp numa
sessão delas chamava o provedor de verdade. O `docker-compose.yml` segue sem `extra_hosts`: é o arquivo
que a produção talvez use depois do corte.

Uma homologação nova já nasce assim. A que **já está de pé** só ganha o corte quando o app é recriado --
com o código desta data ou de depois no BVIP:

```bash
docker compose up -d app
docker compose restart nginx    # o nginx guarda o endereço do app da subida
docker compose exec -T app php scripts/conferir-provedores-no-loopback.php --conteiner
```

Saída 0 do último: os nomes de `HOSTS_MORTOS` (`scripts/deploy-teste.sh`) e os dois do Google
(`fcm.googleapis.com` e `oauth2.googleapis.com`, que desde 11/10/2026 o teste abre com o
`docker-compose.teste-firebase.yml` e a homologação continua a cortar) resolvem só para
`127.0.0.1` e `::1` nas quatro perguntas do `getent`, e o ViaCEP não está no `/etc/hosts`. 1: algum nome
resolve para fora -- o app não foi recriado. 2: não mediu. **Até a saída 0 lá, vale a restrição do
D360 na homologação:** nenhum ensaio nem conferência no navegador nas unidades 90001150 e 90001200.

O teste (`sistema.franquiabv.xyz`) herda esta lista e repete a sua no `docker-compose.teste.yml`; o
compose junta as duas sem repetir entrada, e cada nome sai com os dois endereços (medido com `docker
compose config` em 06/10/2026). O laboratório de migração ganha o corte pelo `COMPOSE_FILE` do seu próprio `.env`, num clone à parte (abaixo; até 08/10/2026 era um `-f` no mesmo diretório).

### Depois de subir, o que exercitar

Nesta ordem, porque cada um depende do anterior:

> O `--wait` nao e enfeite. Sem ele, `up -d` devolve o controle assim que o
> container arranca, e o comando seguinte esbarra num servico que ainda nao
> aceita conexao — "Could not connect to server" no MinIO, "socket nao
> encontrado" no PostgreSQL. Parece defeito de configuracao e e so pressa.

> As fixtures sao pre-requisito da suite e **nada no codigo as carrega**. Elas
> criam a unidade, o grupo, o colaborador e o cliente 90001000 que todos os
> testes referenciam. Sem elas, os 67 testes morrem em violacao de chave
> estrangeira. O `CaracterizacaoTestCase` agora acusa isso com uma frase em vez
> de 66 rastros de pilha, mas o passo continua sendo seu.

> **E o cenario tambem envelhece.** Desde 07/09/2026 o `fixtures.sql` declara o
> sha1 do proprio conteudo e o grava em `_fixtures_versao` ao ser carregado; a
> suite compara e recusa rodar contra um banco carregado de uma versao anterior.
> Um banco de homologacao montado ha semanas vai acusar isso -- e a leitura
> certa nao e "a suite quebrou", e sim "este banco e de antes". Recarregue o
> cenario num banco novo. Ver D122.

```bash
# 1. A tabela de sessão (F1.5). Só é necessária se for usar sessão no banco.
docker compose exec -T mysql mysql -uroot -proot franquiabv_homolog < sql/sessoes.mysql.sql
docker compose exec app php scripts/testar-sessao-banco.php

# 2. Armazenamento de objetos, com o MinIO que já vem no compose.
docker compose --profile f15 up -d --wait minio
docker compose exec app php scripts/testar-armazenamento.php

# 3. O laboratório PostgreSQL: schema, isolamento e telefone.
docker compose --profile fase1 up -d --wait postgres
docker compose exec -T postgres psql -U franquiabv -d franquiabv_homolog -v ON_ERROR_STOP=1 -q -f - < postgres/schema.sql
docker compose exec -T postgres psql -U franquiabv -d franquiabv_homolog -v ON_ERROR_STOP=1 -q -f - < postgres/teste-rls.sql
docker compose exec -T postgres psql -U franquiabv -d franquiabv_homolog -v ON_ERROR_STOP=1 -q -f - < postgres/funcoes-telefone.sql

# 4. O portão da F1.3: o mapeamento Doctrine confere com o PostgreSQL?
docker compose exec app php scripts/verificar-schema-postgres.php

# 5. A suíte financeira, que é o portão de tudo.
docker compose exec -T mysql mysql -uroot -proot franquiabv_homolog < tests/Caracterizacao/fixtures.sql
docker compose exec app phpunit --testsuite caracterizacao
```

Todos saem com código de erro se falharem, então servem tanto para conferir na
mão quanto para automatizar.

**O que NÃO exercitar aqui:** os scripts `remediacao-dia0.sh`,
`bloquear-migrar-senhas.sh`, `coletar-producao.sh` e `pre-migracao-limpeza.sql`
são de produção. Rodá-los no BVIP não faz mal, mas também não diz nada — eles
medem e corrigem o estado do servidor de produção.

Depois o nginx do host, para TLS e senha:

```bash
cp docker/nginx/homologacao-host.conf.example /etc/nginx/sites-available/homologacao
nano /etc/nginx/sites-available/homologacao      # trocar o server_name
ln -s /etc/nginx/sites-available/homologacao /etc/nginx/sites-enabled/
htpasswd -c /etc/nginx/.htpasswd-homolog SEU-USUARIO
nginx -t && systemctl reload nginx
certbot --nginx -d homolog.SEU-DOMINIO.com.br
```

### Conflito de porta com o nginx do host

O BVIP já tem nginx nativo. Se a 8080 estiver ocupada, o contêiner não sobe:

```
Bind for 0.0.0.0:8080 failed: port is already allocated
```

Descubra quem está usando e escolha outra no `.env`:

```bash
ss -tlnp | grep -E ':(8080|8081|8025|3307)\b'
```

```
APP_PORT=127.0.0.1:8090
ADMINER_PORT=127.0.0.1:8091
```

Até 07/10/2026 o `.env.teste.example`, o ambiente de teste no mesmo BVIP, também
publicava 8090 e 8091 -- e a homologação que seguisse esta sugestão tomaria as
portas dele, ou o contrário. O teste passou a 8092 e 8093
(`docs/ambiente-teste.md`, "Ferramentas de apoio"). Um `.env` do teste gerado
antes dessa data segue com as antigas: confira as duas pilhas antes de escolher.

O nginx do host continua atendendo 80 e 443 e repassando para a porta escolhida
— é o desenho, não um contorno.

### Portas, e uma armadilha do Docker

Todas as portas são publicadas em `127.0.0.1`, não em `0.0.0.0`. O BVIP tem IP
público: sem o prefixo, o MySQL da homologação, o Adminer e o Mailpit ficariam
abertos para a internet.

**Isso não é redundante com o firewall.** O Docker escreve regras direto no
`iptables`, na cadeia `DOCKER`, que é avaliada **antes** das regras do `ufw`. Um
`ufw deny 3308` não bloqueia uma porta publicada pelo Docker; a única defesa
real é não publicar em interface pública. Por isso o endereço de bind vive no
`.env` e não numa sobreposição de `ports` — mesclar listas de `ports` entre dois
arquivos de compose **acumula** as entradas em vez de substituir, e a publicação
aberta continuaria valendo ao lado da restrita.

| Serviço | Porta | Como alcançar |
|---|---|---|
| Aplicação | 127.0.0.1:8080 | Pelo nginx do host, com TLS e senha |
| MySQL homologação | 127.0.0.1:3308 | Túnel SSH |
| Adminer | 127.0.0.1:8081 | Túnel SSH |
| Mailpit | 127.0.0.1:8025 | Túnel SSH |
| MySQL do host (réplica) | 3306 | Fora do Docker; não faz parte desta pilha |

```bash
ssh -L 8081:127.0.0.1:8081 -L 8025:127.0.0.1:8025 -L 3308:127.0.0.1:3308 usuario@bvip
```

---

## Dados: o que sobe e o que não sobe

**Nenhum dado pessoal.** Das 83 tabelas, 9 carregam PII ou segredo: `clientes`,
`usuarios`, `unidades_socios`, `agendas`, `agendas_exclusoes`, `notificacoes`,
`push_app`, `visagismo` e `unidades` (chaves de WhatsApp). As outras 74 são
transacionais ou de referência.

*Correção de registro (07/10/2026):* o `mysql.sql` tem hoje **85** tabelas, e a
cópia local 87 (ver o item 2 de "Consequências", acima). E a lista das 9 não está
completa. Falta, entre as que o `mysql.sql` traz, **`clientes_sessoes`** -- o
telefone do cliente e o hash do token da sessão do aplicativo por código (D59),
que o endurecimento do teste apaga inteira. E o anonimizador
(`scripts/anonimizar.sql.modelo`) e o endurecimento
(`scripts/endurecer-base-teste.sql`) tratam também `convenios` (o nome, o e-mail
e o telefone da pessoa de contato), `indicacoes` (o link com o hash do cliente),
`pontuacoes_historicos` e `produtos_estoque` (o nome de pessoas escrito em
texto) e `royalties_faturas` (os links de cobrança reais do Asaas). A lista que
vale é a desses dois roteiros, e não esta; "as outras 74" não são todas
transacionais.

`unidades` também guardava o token do painel de BI em `iframe_bi`; a coluna saiu
em 09/09/2026 com o Power BI.

A homologação precisa de três coisas, e nenhuma exige nome de pessoa:

1. **Schema completo** — `mysql.sql`, carregado automaticamente na primeira subida.
2. **Configuração real de negócio** — alíquotas de royalty, dias de convênio,
   catálogo e preços, grupos de permissão. É o que faz o relatório bater com a
   realidade, e não é dado pessoal.
3. **Volume e forma** — 2,3 milhões de vendas, 4 milhões de agendamentos.
   Problema de desempenho e de consulta só aparece com massa; os nomes são
   irrelevantes para isso.

### A ordem importa

**Anonimize antes de subir.** O fluxo local é *dump → carrega → anonimiza*, o
que deixa o dump cru na máquina por um intervalo. Isso é aceitável na sua
estação; num servidor é uma janela com dados de 242 mil pessoas. Inverta:

### Duas armadilhas do `mysqldump`, aprendidas em 23/08/2026

**`--no-tablespaces` não é opcional.** Sem ele o `mysqldump` tenta ler os
tablespaces e falha se o usuário não tiver o privilégio `PROCESS`:

```
Error: 'Access denied; you need (at least one of) the PROCESS privilege(s)'
```

O dado que interessa não está ali — a opção só desliga uma leitura acessória.

**O `root` do MySQL no Ubuntu não autentica por senha.** Ele usa `auth_socket`,
ou seja, só funciona pelo soquete local (`sudo mysql`). Tentar `-h 127.0.0.1 -u
root -p` devolve sempre:

```
Got error: 1698: Access denied for user 'root'@'localhost'
```

Use o usuário da aplicação com `--no-tablespaces`.

**Os eventos não vêm no dump**, porque o `mysqldump` não os inclui por padrão —
e aqui isso é desejável: o `limpar_push_app_24h` apaga registros de hora em hora,
e não convém que ele rode sozinho numa base de homologação.

```bash
# NA SUA MÁQUINA — carrega, anonimiza e exporta só o resultado
gunzip -c dump-producao.sql.gz | grep -v GTID_PURGED \
  | docker compose exec -T mysql mysql -uroot -proot franquiabv_dev
sh scripts/anonimizar.sh
docker compose exec -T mysql mysqldump -uroot -proot franquiabv_dev | gzip > anonimizado.sql.gz

# NO BVIP — só o artefato anonimizado atravessa a rede
scp anonimizado.sql.gz usuario@bvip:~/
gunzip -c ~/anonimizado.sql.gz | grep -v GTID_PURGED \
  | docker compose exec -T mysql mysql -uroot -proot franquiabv_homolog
```

O dump cru nunca sai da sua máquina.

Depois de anonimizar, todo colaborador entra com `user<id>` e senha `dev123456`
— ver `ambiente-desenvolvimento.md`.

### A réplica do host não é a base de homologação

O BVIP tem duas bases, e a distinção é o ponto todo:

- **MySQL do host, porta 3306** — réplica de produção, **dados reais**.
- **MySQL em container, porta 3308** — homologação, **dados anonimizados**.

Não aponte a aplicação de homologação para a 3306. Se precisar de um dump
fresco, a réplica é a origem — mas o caminho passa pela anonimização antes de
virar base de homologação.

---

## Laboratório de migração (Fase 1)

**Num clone próprio, ao lado do da homologação, desde 08/10/2026** (decidido pelo dono em 07/10/2026,
pergunta 37 da página do lote, a opção (a), *"Reescrever para um clone próprio"* -- *"Outro diretório, um
.env modelo novo, portas próprias e o corte dos provedores; só documentação e um modelo, e quem roda é
você"*; **D431**). Outro diretório, outro `.env` (`.env.laboratorio.example`), outro projeto, outras
portas, outros volumes e outro `local.php`: o que se faz aqui não alcança a homologação, e os dois sobem
juntos. **Esta seção é documentação e um modelo; nada dela rodou no BVIP.**

**Até essa data a seção o subia do MESMO diretório da homologação**, com o mesmo `.env` (`docker compose
-p franquiabv-lab -f docker-compose.yml -f docker-compose.desenvolvimento.yml --profile fase1 up -d`), e
três coisas davam errado -- medido com `docker compose config`, só leitura: publicava as mesmas portas da
homologação (e, deduzido e não rodado, não subia com ela de pé); os dois apps montavam o mesmo `config/autoload/local.php`, e
apontar o laboratório para o PostgreSQL apontava também a homologação; e os comandos seguintes, sem `-p`,
iam à homologação -- os roteiros de carga também, que usam `PROJETO=franquiabv` quando ninguém o dá.
**Se o laboratório já rodou assim no BVIP** (a pergunta 37 não respondeu: o campo veio vazio), confira o
`local.php` da homologação, no clone dela, sem imprimir o arquivo:

```bash
grep -c 'PDOPgSql' config/autoload/local.php     # 0: aponta para o MySQL, como deve; 1 ou mais: aponta para o PostgreSQL
```

Dando mais que zero, volte o `local.mysql.php.guardado` (em `config/autoload/`) que aquele roteiro mandava guardar.

### Subir o laboratório

Do `$HOME` de quem roda (o `~` é ele), com a mesma deploy key da homologação -- só leitura, de um
repositório só (acima). O clone, o `.env` e o `local.php` dele:

```bash
(
set -e
git clone git@github-franquiabv:SEU-USUARIO/franquiabv-erp.git ~/franquiabv-lab
cd ~/franquiabv-lab
cp .env.laboratorio.example .env
grep -qx 'COMPOSE_PROJECT_NAME=franquiabv-lab' .env || { echo "PARE: este .env nao e o do laboratorio" >&2; exit 1; }
cp docker/config/local.php.docker config/autoload/local.php   # o do LABORATORIO: este clone tem o seu
)
nano ~/franquiabv-lab/.env     # trocar as duas senhas
# o dono e a permissao de ~/franquiabv-lab/config/autoload/local.php como na homologacao (acima): o php-fpm roda como www-data
```

E subir:

```bash
(
set -e
cd ~/franquiabv-lab
grep -qx 'COMPOSE_PROJECT_NAME=franquiabv-lab' .env || { echo "PARE: este .env nao e o do laboratorio" >&2; exit 1; }
docker compose --profile fase1 up -d --wait
docker compose exec app composer install
docker compose exec -T app php scripts/verificar-local-php.php
docker compose exec -T app php scripts/conferir-provedores-no-loopback.php --conteiner
)
```

**Todo comando desta seção roda de dentro de `~/franquiabv-lab`, sem `-f` e sem `-p`.** O `.env` deste
clone dá o projeto (`COMPOSE_PROJECT_NAME=franquiabv-lab`, que vence o `name: franquiabv` do
`docker-compose.yml`) e os arquivos (`COMPOSE_FILE=docker-compose.yml:docker-compose.desenvolvimento.yml`).
O mesmo texto, rodado do clone da homologação, vai à homologação: **por isso cada bloco é um subshell --
o `(` e o `)` -- com `set -e`, e começa pelo `cd` e pela conferência do `.env`.** O `cd` que falha (o
clone não existe, ou está em outro `~`) e o `.env` que não é o do laboratório **param o bloco**, e nada
de baixo roda; o `exit 1` sai só do subshell, e o terminal fica. Até a revisão de 08/10/2026 a
conferência era `cd ~/franquiabv-lab && grep ... || echo "PARE"`: o `echo` avisava, e as linhas de baixo
rodavam assim mesmo, no diretório em que se estava -- o `cp` do `local.postgres.php.dist` no `local.php`
da homologação, que é o D431 de volta. `sh scripts/ensaiar-blocos-do-laboratorio.sh` roda cada bloco
desta seção sem o clone, com o `.env` errado e com o certo, e reprova o que agir fora dele.
**Com `-f`, o compose não lê o `COMPOSE_FILE`**, e o app sobe sem o corte dos provedores -- era o defeito de antes de 06/10/2026 (D360) --; com `-p`, o compose procura outro
projeto. O portão `conferir-provedores-no-loopback.php` reprova um `docker compose` com `-f` ou `-p`
nesta seção, e confere o projeto, o `COMPOSE_FILE` e as portas do modelo.

**O corte dos provedores** (D360, decidido pelo dono em 06/10/2026) é o `docker-compose.desenvolvimento.yml`,
pelo `COMPOSE_FILE` do modelo: Asaas, WhatsApp, OneSignal, SMS, Yungas, Zoomtec e o SMTP de produção
resolvem para o loopback dentro do contêiner do PHP. O arquivo traz só o corte -- o portão reprova se ele
ganhar outra coisa, porque tudo o que entrar lá entra também aqui --, e não os tetos nem o `restart:
always` da homologação, que mudariam o laboratório além do que o dono pediu. Saída 0 do último comando
de cima: os nove nomes resolvem só para `127.0.0.1` e `::1`, e o ViaCEP fica fora do `/etc/hosts`. 1:
algum nome resolve para fora (o app não foi recriado?). 2: não mediu.

**As portas**, todas no loopback e nenhuma de outra pilha do BVIP (medido com `docker compose config` em
08/10/2026, os três modelos lado a lado, com os perfis `fase1` e `f15`):

| Serviço | Homologação | Teste | Laboratório |
|---|---|---|---|
| Aplicação | 8080 (ou 8090, acima) | 8092 | **8094** |
| Adminer | 8081 (ou 8091) | 8093 | **8095** |
| Mailpit | 8025 | 8026 | **8027** |
| MySQL | 3308 | 3309 | **3310** |
| PostgreSQL | 5433 | 5434 | **5435** |
| MinIO | 9000, 9001 | 9002, 9003 | **9004, 9005** |

A réplica de produção do host segue na 3306. Só por túnel:

```bash
ssh -L 8094:127.0.0.1:8094 -L 8095:127.0.0.1:8095 -L 3310:127.0.0.1:3310 usuario@bvip
```

**A base** é a mesma cópia anonimizada da homologação ("Dados: o que sobe e o que não sobe", acima), no
banco do laboratório -- `franquiabv_dev`, o nome que os roteiros de carga e o `local.postgres.php.dist`
esperam sem nada a mais; ele mora no MySQL deste projeto, e o da homologação é `franquiabv_homolog`:

```bash
(
set -e
cd ~/franquiabv-lab
grep -qx 'COMPOSE_PROJECT_NAME=franquiabv-lab' .env || { echo "PARE: este .env nao e o do laboratorio" >&2; exit 1; }
gunzip -c ~/anonimizado.sql.gz | grep -v GTID_PURGED \
  | docker compose exec -T mysql mysql -uroot -proot franquiabv_dev
)
```

### Carregar o PostgreSQL e apontar a aplicacao para ele

Quatro passos, nesta ordem. A ordem importa: o roteiro de carga espera o schema
pronto, e a limpeza precisa vir antes do dump que alimenta a carga.

**O `export` vem primeiro, sempre.** `migrar-dados.sh`, `criar-papeis.sh`, `conferir-papeis.sh`,
`conferir-migracao.php` e `ensaiar-corte.sh` falam com o projeto `$PROJETO` (`docker compose -p`), e sem
ele usam `franquiabv` -- no BVIP, o projeto da homologação, de qualquer diretório. Os bancos eles já
acham: `franquiabv_dev` é o padrão deles. Dentro do subshell, o `export` vale só para o bloco: não
sobra no terminal para um roteiro rodado depois do clone da homologação.

```bash
(
set -e
cd ~/franquiabv-lab
grep -qx 'COMPOSE_PROJECT_NAME=franquiabv-lab' .env || { echo "PARE: este .env nao e o do laboratorio" >&2; exit 1; }
export PROJETO=franquiabv-lab
docker compose exec -T mysql mysql -uroot -proot franquiabv_dev < scripts/pre-migracao-limpeza.sql
php scripts/gerar-ddl-postgres.php > postgres/schema.sql
docker compose exec -T postgres psql -U franquiabv -d franquiabv_dev -v ON_ERROR_STOP=1 -f - < postgres/schema.sql
SENHA_APP='...' sh scripts/criar-papeis.sh
sh scripts/migrar-dados.sh
php scripts/conferir-migracao.php
SENHA_APP='...' sh scripts/conferir-papeis.sh
)
```

**Os papeis vem DEPOIS do schema, sempre.** Recarregar o schema faz
`DROP SCHEMA bvip CASCADE`, e isso leva junto todas as permissoes. Inverter os
dois passos deixa os papeis sem permissao nenhuma, e o sintoma e a aplicacao
subindo normalmente e falhando na primeira consulta.

O `migrar-dados.sh` **esvazia o destino** antes de comecar, de proposito: um
corte se ensaia varias vezes, e recarregar o schema a mao e o passo que se
esquece. Use `LIMPAR=nao` para carregar por cima, que quase nunca e o que se
quer.

Depois, para a aplicacao **do laboratório** falar com o PostgreSQL -- o `local.php` deste clone, que a
homologação não monta:

```bash
(
set -e
cd ~/franquiabv-lab
grep -qx 'COMPOSE_PROJECT_NAME=franquiabv-lab' .env || { echo "PARE: este .env nao e o do laboratorio" >&2; exit 1; }
cp config/autoload/local.php config/autoload/local.mysql.php.guardado
cp config/autoload/local.postgres.php.dist config/autoload/local.php
docker compose exec -T app sh -c 'rm -rf data/DoctrineORMModule/Proxy/*'
docker compose exec -T app phpunit --testsuite caracterizacao
)
```

**Guarde mesmo o `local.php` anterior.** Ele nao e versionado, e e o unico
lugar onde os dados da conexao MySQL existem.

### Ao conferir a carga, DECLARE O INQUILINO

O RLS tem `FORCE`. Uma consulta sem `app.inquilino_id` devolve **zero linhas** —
sem erro, sem aviso. E zero linhas parece exatamente com uma carga que falhou:

```bash
(
set -e
cd ~/franquiabv-lab
grep -qx 'COMPOSE_PROJECT_NAME=franquiabv-lab' .env || { echo "PARE: este .env nao e o do laboratorio" >&2; exit 1; }
docker compose exec -T postgres psql -U franquiabv -d franquiabv_dev   -c "SET app.inquilino_id='1'; SELECT count(*) FROM unidades;"
)
```

Sem o `SET`, a resposta e 0 com as 61 linhas presentes. Custou tres conferencias
erradas em 27/08/2026 antes de a ficha cair.

### O ensaio geral do corte, no laboratório

O `docs/ensaio-geral-do-corte.md` diz o que ele mede; no BVIP ele roda daqui, com o projeto do laboratório
(o `ensaiar-corte.sh` fala com `docker compose -p "$PROJETO"`, e sem o `export` usa `franquiabv`, a
homologação, de qualquer diretório). Do mesmo jeito que os outros blocos: fora do clone, ele para.

```bash
(
set -e
cd ~/franquiabv-lab
grep -qx 'COMPOSE_PROJECT_NAME=franquiabv-lab' .env || { echo "PARE: este .env nao e o do laboratorio" >&2; exit 1; }
export PROJETO=franquiabv-lab
SENHA_APP='...' sh scripts/ensaiar-corte.sh primeira
SENHA_APP='...' sh scripts/ensaiar-corte.sh segunda
sh scripts/comparar-ensaios.sh ensaios/primeira.txt ensaios/segunda.txt
)
```

**De 11/10 a 08/11/2026 este bloco parava no começo** (o D469): o `ensaiar-corte.sh` só começava com um
`local.php` que tivesse o host `mysql` e o banco escritos, entre aspas, e o deste clone é o do
`docker/config/local.php.docker`, que os lê do ambiente. **Desde 08/11/2026 (a fatia 128)** o roteiro lê a
conexão pelo PHP do contêiner da aplicação (`scripts/conexao-do-local-php.php`, o `local.php` avaliado, com o
ambiente do contêiner), e o `local.php` dos clones leva o banco do clone escrito, no lugar do `getenv`. O
`BANCO_MYSQL` tem de ser o `DB_NAME` do `.env` deste clone (`franquiabv_dev`, o padrão dos dois). Provado
com dublês, no `scripts/ensaiar-clones-do-ensaio-de-corte.sh` (o caso do modelo do Docker); no BVIP, o
bloco ainda não rodou.

### Medir a janela de corte sem se enganar

O BVIP tem 24 núcleos; a produção tem 4 vCPU. Cronometrar a conversão dos 16,2
milhões de registros aqui e usar o número para dimensionar a janela de corte dá
uma estimativa **otimista por um fator grande**.

Limite o contêiner do ensaio ao formato da produção:

```yaml
deploy:
  resources:
    limits:
      cpus: "4"
      memory: 16g
```

**No clone do laboratório, o teto vai num arquivo à parte**, que só o `.env` daquele clone nomeia, em
terceiro no `COMPOSE_FILE` (`docker-compose.yml:docker-compose.desenvolvimento.yml:<o do teto>`) -- e
nunca no `docker-compose.desenvolvimento.yml`, que o portão quer só com o corte, nem num `-f`, que
deixaria o corte de fora.

Ainda erra a favor — 4 núcleos dedicados de bare metal rendem mais que 4 vCPU
compartilhadas, e não há disputa com vizinho nem NVMe compartilhado. Mas erra
por uma margem pequena e conhecida, em vez dos 6× do número cru. Some folga
sobre o resultado e use isso para a janela.

---

## As três réplicas ficam

Decisão do responsável em 17/08/2026, com justificativa que encerra o assunto:
em 2025 a operação dependia de um único datacenter, que ficou **36 horas fora do
ar**, com prejuízo para unidades, franqueados, profissionais e franqueadora. A
redundância existe para que isso não se repita.

Isso reclassifica os servidores. Um nó que guarda a base completa para assumir
em caso de falha **é infraestrutura de produção**, não ambiente de testes. Duas
consequências práticas:

- O servidor 1 está rotulado como "servidor pessoal de testes". Se ele é nó de
  contingência, o rótulo precisa mudar junto com o tratamento: nada de
  experimento pessoal numa máquina que guarda os dados de 242 mil pessoas e que
  pode assumir a operação.
- É mais um motivo para a homologação **não** morar lá. Ela mora no BVIP, em
  contêiner, com base anonimizada e separada.

O Orange Pi, por consequência, **fica só como nó de redundância**. A ideia de
usá-lo como runner de CI está descartada: sobram 3 GB de RAM, e carregar de
trabalho de build uma máquina que faz parte da cadeia de disponibilidade
contraria o motivo de ela existir. O CI segue no GitHub Actions, que ainda tem a
vantagem de rodar em x86 — o Orange Pi é **ARM64**, e validar em ARM o que roda
em x86 na produção introduz uma diferença que ninguém quer depurar.

## Ponto em aberto

**Réplica não é failover.** As três réplicas garantem que o *dado* sobrevive.
Elas não garantem, sozinhas, que o *serviço* volta: para atender, um nó precisa
da aplicação instalada, do `local.php`, dos diretórios de upload, do agendamento
dos crons e do DNS apontando para ele.

Falta saber — e não dá para responder daqui — se existe procedimento de
promoção escrito, e se ele já foi ensaiado alguma vez. Um failover nunca
ensaiado costuma falhar na primeira tentativa, que é sempre de madrugada e sob
pressão. Se o procedimento não existir, escrevê-lo e cronometrá-lo vale mais que
qualquer item em aberto na Fase 0.
