# Spec 17 — `up_prod.sh`: subir e atualizar a produção com segurança

> **Dono:** Runtime/Infra · **Status:** Proposta · **Plano:** 04
> **Relaciona:** ADR-005, ADR-008, ADR-013, ADR-014, ADR-022
> **Objetivo:** um **único comando na raiz** que sobe e atualiza o ambiente de produção com
> segurança, idempotência, rollback e **todas** as otimizações do framework/PHP/edge.

## 1. Contrato

```bash
./up_prod.sh [--dry-run] [--no-pull] [--skip-migrate] [--branch <nome>] [--backup-only]
```

- **Idempotente:** rodar 2× seguidas não quebra nada.
- **Seguro:** `set -Eeuo pipefail`; `flock` contra execuções concorrentes; rollback ao commit
  anterior em falha; nunca aplica seeds de demonstração.
- **Sem interação:** pensado para ser chamado por CI/CD ou por uma pessoa, sem prompts.
- **Sem backup não há migrate** (ADR-042): havendo `migrate`, um `pg_dump -Fc` é gerado **antes**
  dele e validado com `pg_restore --list`; dump que falha, sai vazio ou é ilegível **aborta** o
  deploy. Retenção por `BACKUP_KEEP` (padrão 7) e cópia para o disco `BACKUP_DISK`
  (`s3` local por padrão; `offsite` = endpoint externo via `OFFSITE_*`) com `BACKUP_OFFSITE=1`.
  O script **avisa** quando `s3` aponta para o RustFS local: isso não é offsite (G-15).
- **Uma instância = um cliente**: o project name do Compose é o **slug do `APP_NAME`**
  (`tools/project-name.sh`, ex.: `prefeitura-santa-barra`), evitando colisão entre tenants.

## 2. Pré-requisitos validados no início

O preflight roda **antes de qualquer efeito** (backup, build, migrate): o deploy não pode
começar e só depois descobrir que faltava um segredo.

- `docker`/`docker compose` presentes e com permissão.
- `.env` de produção existe e define `APP_ENV=production`, `APP_KEY`, `DB_*`.
- **Segredos obrigatórios** (falha nomeando a variável): `DB_PASSWORD`, `REDIS_PASSWORD`,
  `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` — ausentes **ou ainda com o valor de exemplo**
  (`change-me`) abortam. Sem isso o Compose sobe com o fallback silencioso e o banco
  acaba com a senha de exemplo.
- `WEB_PORT` (opcional, default `8080`); `STORAGE_PATH`/`DATA_PATH` (opcionais, default
  `./storage` e `./s3-local`); em produção aponte `DATA_PATH` para fora da cota do cPanel. Devem ser graváveis.
- `BACKUP_KEEP` (opcional, default `7`), `BACKUP_OFFSITE` (default `0`) e `BACKUP_DISK`
  (default `s3`): retenção dos dumps em `$STORAGE_PATH/backups/` e cópia para o disco escolhido.
- Porta `WEB_PORT` livre em `127.0.0.1`.
- Repositório é um clone git válido (`public_html`).

> **`homolog` = `prod` (ADR-032):** homolog espelha produção — mesmo runtime, mesmas
> timeouts, mesma rede, mesma imagem, mesmo Postgres. As **únicas** diferenças admitidas
> são as declaradas no ADR-032: OPcache com `validate_timestamps=1` e log em `debug`. Nenhuma
> configuração por arquivo de ambiente existe: `APP_ENV` no `.env` é o **único**
> interruptor (não há `docker-compose.prod.yml`, nem `.env.prod`).

## 3. Sequência (fases)

| Fase | Ação | Falha ⇒ |
|---|---|---|
| 0. Lock | `flock` do deploy; registra commit atual (`ROLLBACK_REF`) | aborta |
| 0.4. Retenção do WAL | `tools/wal-retention.sh --json` (poda por idade, idempotente) — roda **antes** do backup: com o disco cheio o `pg_dump` falha | **avisa** (não aborta o deploy) |
| 1. Código | `git pull --ff-only` (ou `fetch --depth=1` + reset) | rollback |
| 2. Imagens | `docker compose pull` + `build` (php-fpm) | rollback |
| 3. Subir | `docker compose up -d --remove-orphans` | rollback |
| 4. Espera | `postgres`/`valkey`/`storage` **healthy** (loop com timeout) | aborta |
| **4.6. Runtime PHP** | `php -r 'echo PHP_VERSION'` no `php-fpm`; exige 8.4.27+ ou 8.5.12+ (CVE-2026-109618/109619) | **aborta** |
| 5. Deps | `composer install --no-dev --optimize-autoloader --classmap-authoritative` | rollback |
| 5.5. Assets | build Vite offline/pinado (`node` por digest) — nunca em runtime (ADR-008) | rollback |
| **5.9. Backup** | **`pg_dump -Fc` do banco + `pg_restore --list`** → `$STORAGE_PATH/backups/`; poda por `BACKUP_KEEP`; cópia + poda remota (`core:backup:push` / `core:backup:prune`) | **aborta** (sem backup não há migrate) |
| 6. Dados | `migrate:status` (evidência no log) + `php artisan migrate --force --isolated` (ADR-013) | rollback |
| 7. Otimizar | `php artisan optimize` + **bucket S3 idempotente** (sem `storage:link` — ADR-030) | rollback |
| 8. Recarregar | reload do `php-fpm` (USR2; PID do master antes/depois no log) + `php artisan queue:restart` + `caddy reload` | rollback |
| 9. Health | `curl -fsS http://127.0.0.1:${WEB_PORT}/up` (listener `DiagnosingHealth`: PG + Valkey + S3) | rollback |
| 10. Fim | `php artisan about` como evidência; libera lock | — |

Todas as fases rodam **dentro do container** (`docker compose exec`), nunca no host.

## 4. Otimizações ativadas (obrigatórias)

- **Framework:** `php artisan optimize` → `config:cache` + `route:cache` + `view:cache` +
  `event:cache` (ADR-008/ADR-014). Pré-condições: zero `env()` fora de `config/`; zero closure
  em rotas.
- **Composer:** `--no-dev --optimize-autoloader --classmap-authoritative`.
- **PHP:** OPcache `validate_timestamps=0`, `max_accelerated_files=20000`,
  `realpath_cache_size=4096K`/`ttl=600`; `opcache.preload` opcional. **JIT fica `off`** — medido
  em 2026-10-20 no container de dev (mesma imagem `php:8.4-fpm-trixie`, PHP 8.4.26), com
  `tools/jit-bench.php` (boot do kernel + 15 `GET /` após 3 de aquecimento, 3 execuções):

  | `opcache.jit` | mediana | amostras (ms) |
  |---|---|---|
  | `off` | **5,8 ms** | 5,68 · 6,05 · 5,82 |
  | `function` | 7,5 ms | 7,51 |
  | `tracing` | **13,7 ms** | 13,73 · 13,44 · 14,02 |

  `tracing` ficou **2,4× pior** (compilação/descartar trace domina o request I/O-bound do
  Laravel); `function`, 1,3× pior. Habilitar sem número é regressão silenciosa (ADR-008).
  Reproduzir: `docker compose exec php-fpm php -d opcache.enable_cli=1 -d opcache.jit=tracing
  -d opcache.jit_buffer_size=64M tools/jit-bench.php` (troque `tracing` por `off`). Memória/interned
  só sobem com `opcache_get_status()` de um request FPM (used/wasted/interned).
- **Edge (Caddy):** HTTP/2, `encode zstd gzip`, assets Vite imutáveis (hash) e
  `Cache-Control`/`ETag` (ADR-022).
- **Banco:** PostgreSQL 18 tunado (`io_method=worker`, etc. — ADR-007).
- **Recarregar sempre o FPM** após publicar código (senão o OPcache serve a versão antiga) e
  **`php artisan queue:restart`** para os workers pegarem o código novo (sem perder jobs).

## 5. Rollback

- Antes do pull, guardar `ROLLBACK_REF=$(git rev-parse HEAD)`.
- Em falha: `git reset --hard "$ROLLBACK_REF"`; re-executar `composer install`, `optimize:clear`
  e `php artisan optimize`; recarregar o FPM; reportar o erro e sair **não-zero**.
- O **banco** não volta sozinho: o dump de 5.9 é a única recuperação (restore em
  `wiki/runbooks/deploy-e-recuperacao.md`; ensaio em `tools/db-restore-drill.sh`).
- Migrações são **expand & contract** (ADR-013): nunca destrutivas, então o rollback de código é
  seguro sem reverter schema.
- `down()` de migração **nunca** apaga (renomeia para `backup_*`).

## 6. Logs e observabilidade

- Log do deploy em `storage/logs/deploy-YYYYMMDD-HHMMSS.log` (com `module=deploy`, `action`,
  `commit`, `duration_ms`).
- Fases ecoadas com timestamp; `--dry-run` mostra tudo sem alterar o ambiente.
- Falhas retornam código ≠ 0 (para o CI/CD).

## 7. Segurança

- Nunca imprime segredos do `.env` (apenas valida presença).
- Não roda como root no host além do necessário; comandos no container como usuário non-root.
- Não expõe portas de banco/cache.
- `.user.ini`/`php.ini`/`.htaccess`/`error_log` do cPanel são **ignorados** (spec 16), nunca
  apagados nem versionados.

### 7.1 Isolamento de rede (D5) — o contrato

Duas redes, e a matriz é **medida** (não declarada):

| Serviço | Redes | Alcança o banco/cache/storage? | Sai para a internet? |
|---|---|---|---|
| `web` (a borda) | `edge` | **não** | sim |
| `php-fpm` / `queue` | `edge` + `data` | **sim** | **sim** (o `composer install` do deploy precisa) |
| `postgres` / `valkey` / `storage` | `data` (`internal: true`) | — | **não** (sem rota padrão) |

O `web` é o único serviço exposto à internet: ele **não** fala com o banco, o cache nem o
storage — um comprometimento da borda não dá acesso aos dados. Os serviços de dados estão
numa rede `internal: true`: sem rota padrão, um dump, uma chave de sessão ou um objeto de
mídia não vazam para fora.

O guard permanente é `tools/network-check.sh` (estático + runtime), rodado pelo `make check`.
A **exceção medida**: o binário do Caddy tem capability de arquivo
(`cap_net_bind_service=ep`); com `cap_drop: [ALL]` o exec é negado — por isso o `web` tem
`cap_add: [NET_BIND_SERVICE]`. O Postgres **não** tem `cap_drop`: o entrypoint da imagem faz
`chown` no PGDATA como root e precisa de `CAP_CHOWN` (sem ela o container não sobe).

## 7.2 O bucket e a mídia (D7) — o contrato

O bucket S3 da mídia é **single-drive** (ADR-030): a perda do host perdia as imagens
publicadas, e o `pg_dump` do banco não as recupera. O D7 fecha isso:

| O quê | Onde | Garantia |
|---|---|---|
| **Versioning** ligado | `storage:ensure-bucket` | o direito de desfazer `delete`/overwrite |
| **PublicAccessBlock** (4 flags) | `storage:ensure-bucket` | o bucket é privado (o app serve por *stream*) |
| Limite de versões | `RUSTFS_API_OBJECT_MAX_VERSIONS=20` | versionamento não vira disco cheio |
| **Cópia offsite** da mídia | `media:backup` (diário) | manifesto com o SHA-256 de cada objeto |
| **Caminho de volta** | `media:restore` | devolve exatamente o que o manifesto lista |
| **Inventário de integridade** | `media:inventory` | objetos, tamanho e SHA-256 por prefixo |

O `make check` chama `storage:ensure-bucket` (aplica e verifica — falha alto se o storage não
chegar nesse estado) e `media:backup --dry-run` (o comando continua sendo executável). O
`up_prod.sh` garante também o bucket `offsite-media`, quando `OFFSITE_MEDIA_BUCKET` existe.

O transporte do backup é o **cliente S3 do próprio app** — nunca o `mc` (MinIO client é AGPL:
violaria o ADR-002).

**Limite honesto:** com o destino apontado para o mesmo RustFS, a cópia protege contra
apagamento, corrupção e *overwrite*, **não** contra a perda do host. Contra a perda do host, o
destino precisa ser outro servidor (`OFFSITE_ENDPOINT`/`OFFSITE_MEDIA_BUCKET`) — decisão do
cliente, não do código.

## 8. Esboço de referência

```bash
#!/usr/bin/env bash
set -Eeuo pipefail
cd "$(dirname "$0")"
exec 9>"storage/.deploy.lock"; flock -n 9 || { echo "deploy em andamento"; exit 1; }

DRY=0; PULL=1; MIGRATE=1
for a in "$@"; do case "$a" in
  --dry-run) DRY=1;; --no-pull) PULL=0;; --skip-migrate) MIGRATE=0;;
  *) echo "flag desconhecida: $a"; exit 2;; esac; done
run(){ echo "+ $*"; [ "$DRY" = 1 ] || "$@"; }

COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
ROLLBACK_REF="$(git rev-parse HEAD)"
rollback(){ echo "!! falha — rollback para $ROLLBACK_REF"; git reset --hard "$ROLLBACK_REF";
  $COMPOSE exec -T php-fpm composer install --no-dev --optimize-autoloader --classmap-authoritative;
  $COMPOSE exec -T php-fpm php artisan optimize; $COMPOSE exec -T php-fpm kill -USR2 1; }
trap 'rc=$?; [ $rc -ne 0 ] && rollback; exit $rc' EXIT

[ "$PULL" = 1 ] && run git pull --ff-only
run $COMPOSE build && run $COMPOSE up -d --remove-orphans
run $COMPOSE exec -T php-fpm composer install --no-dev --optimize-autoloader --classmap-authoritative
[ "$MIGRATE" = 1 ] && run $COMPOSE exec -T php-fpm php artisan migrate:status
[ "$MIGRATE" = 1 ] && run $COMPOSE exec -T php-fpm php artisan migrate --force --isolated
run $COMPOSE exec -T php-fpm php artisan optimize
run $COMPOSE exec -T php-fpm kill -USR2 1            # reload FPM (OPcache); sem storage:link (ADR-030)
run $COMPOSE exec -T php-fpm php artisan queue:restart # workers pegam o código novo
run $COMPOSE exec -T web caddy validate --config /etc/caddy/Caddyfile
run $COMPOSE exec -T web caddy reload   --config /etc/caddy/Caddyfile
run curl -fsS "http://127.0.0.1:${WEB_PORT}/up" >/dev/null
run $COMPOSE exec -T php-fpm php artisan about
```

## 9. Verificação

- [x] `./up_prod.sh --dry-run` mostra o plano sem alterar nada.
- [x] `./up_prod.sh` 2× seguidas: idempotente, termina 0.
- [x] `php artisan about` mostra config/route/view/event **cacheados**.
- [x] OPcache recarregado (código novo em execução imediatamente; `validate_timestamps=Off`).
- [x] Falha simulada (storage unhealthy / healthcheck 502) dispara rollback e sai ≠ 0.
- [x] `flock` impede dois deploys simultâneos.
- [x] Log em `storage/logs/deploy-*.log` com `commit` e `total_ms`/`duration_ms` (JSON).
